mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f47e65d215 | |||
| 6dcbd387f7 | |||
| fa58fd12a0 | |||
| 913ee4210d | |||
| 99411041b9 | |||
| 39bce329fd | |||
| 55a969a3c1 | |||
| f51dd06a36 | |||
| 24e34c7545 | |||
| 81d40b90d4 | |||
| 9cde29fb14 | |||
| ebe8411e7e | |||
| 381e87474c | |||
| 09fd6f3411 | |||
| d9d9f44cb2 | |||
| 1244a40950 | |||
| a48c2badd9 | |||
| 3f3f12dbd2 | |||
| 99023811d8 | |||
| 480a2979e1 | |||
| 531900cf70 | |||
| c9325794e5 | |||
| 4a11ed9887 | |||
| be7b759820 | |||
| fa76928f62 | |||
| f6db1361ee | |||
| 5d6eaf53f3 | |||
| c4e4469083 | |||
| 800b534741 | |||
| 03b9d07d0b | |||
| f60d1fe8dd | |||
| e5a27dc97c | |||
| c7e0c83174 | |||
| ab958a0b98 | |||
| 5362be4078 | |||
| 6887930428 | |||
| da94942d9c | |||
| a9141c8ade | |||
| 8ab5ec5364 | |||
| 222534dbe1 | |||
| f31c94490d | |||
| 52eaf2702e | |||
| ce81ea44bf | |||
| 29974d3ab9 | |||
| f4c431b028 | |||
| 34fbd9ffd3 | |||
| 09b5e926bf | |||
| d3e8e7cb65 | |||
| 60bed5782f | |||
| 35b6bc55b8 | |||
| 365dd1ff64 | |||
| 7633c0e83f | |||
| 87b1f39c0f | |||
| ca91d2d756 |
@@ -19,9 +19,23 @@ also run as full cloud automation. Every test session follows the same
|
||||
four-step contract:
|
||||
|
||||
```
|
||||
Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
|
||||
Step -1: Plan approval → 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**
|
||||
@@ -29,6 +43,36 @@ 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`
|
||||
@@ -38,9 +82,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
|
||||
@@ -55,27 +99,129 @@ 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 Auth is green
|
||||
### 0.3 Init local dev env without `.env`
|
||||
|
||||
**Auth is the gate for all automated testing.**
|
||||
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,
|
||||
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:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh status
|
||||
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
|
||||
```
|
||||
|
||||
| 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 |
|
||||
Bootstrap flow when no `.env` exists:
|
||||
|
||||
```bash
|
||||
# From repo root. Managed DB flow requires Docker Desktop.
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
```
|
||||
|
||||
If using an existing Postgres instead of the managed Docker DB, set
|
||||
`DATABASE_URL` and skip `setup-db`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
|
||||
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
```
|
||||
|
||||
For backend-only checks, `dev-next` is available, but Web smoke needs the
|
||||
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`
|
||||
- `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`
|
||||
|
||||
`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 |
|
||||
|
||||
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`).
|
||||
|
||||
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:
|
||||
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:
|
||||
[references/auth.md](./references/auth.md).
|
||||
|
||||
## Step 1 — Pick the surface by change scope
|
||||
@@ -148,17 +294,19 @@ Surface guides above carry the detailed workflows. Shared infrastructure:
|
||||
|
||||
All under `.agents/skills/agent-testing/scripts/`:
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
`app-probe.sh` is the LobeHub-specific fast path into app state — auth check,
|
||||
current route, running operations, and `goto <path>` quick navigation
|
||||
@@ -174,12 +322,13 @@ 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 (case table, embedded evidence, verdict) and $DIR/result.json
|
||||
# fill $DIR/report.md (scope, case table with inline evidence, verdict, score) and $DIR/result.json
|
||||
```
|
||||
|
||||
Reports live in `.records/reports/<timestamp>-<slug>/` (gitignored): `report.md`
|
||||
(human-readable, with embedded screenshots), `result.json` (machine-readable
|
||||
pass/fail + score), `assets/` (evidence). Format spec and evidence rules:
|
||||
(human-readable, with screenshots/GIFs embedded directly in the case table),
|
||||
`result.json` (machine-readable pass/fail + score), `assets/` (evidence).
|
||||
Format spec and evidence rules:
|
||||
[references/report.md](./references/report.md).
|
||||
|
||||
Two hard rules worth front-loading:
|
||||
@@ -187,6 +336,21 @@ 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,17 +13,18 @@ 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 credentials |
|
||||
| Auth | Device Code Flow login — see [../references/auth.md](../references/auth.md) |
|
||||
| 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) |
|
||||
|
||||
All CLI dev commands run from `apps/cli/`. Subsequent examples use `$CLI`:
|
||||
|
||||
```bash
|
||||
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
|
||||
source ../../.records/env/agent-testing-cli.env
|
||||
CLI="bun src/index.ts"
|
||||
```
|
||||
|
||||
## Workflow
|
||||
@@ -39,14 +40,23 @@ 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 logged in, **the user must run the login themselves**
|
||||
(interactive browser authorization):
|
||||
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:
|
||||
|
||||
```bash
|
||||
cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3010
|
||||
```
|
||||
|
||||
Credentials persist in `apps/cli/.lobehub-dev/`. Details:
|
||||
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:
|
||||
[../references/auth.md](../references/auth.md).
|
||||
|
||||
### Step 3 — Test with CLI commands
|
||||
@@ -133,10 +143,10 @@ $CLI provider test <provider-id>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -1,37 +1,72 @@
|
||||
# Auth Setup for Local Agent Testing
|
||||
|
||||
**Auth is the gate for all automated testing.** Prepare and verify it before
|
||||
writing any test step. The one-stop entry point is:
|
||||
**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:
|
||||
|
||||
```bash
|
||||
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
|
||||
SCRIPT="./.agents/skills/agent-testing/scripts/setup-auth.sh"
|
||||
TEST_ENV="./.agents/skills/agent-testing/scripts/test-env.sh"
|
||||
eval "$($TEST_ENV --exports)"
|
||||
```
|
||||
|
||||
`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).
|
||||
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`.
|
||||
|
||||
## Per-surface overview
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
## CLI — Device Code Flow
|
||||
## CLI — Seeded API key
|
||||
|
||||
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` (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):
|
||||
`LOBEHUB_CLI_HOME=.lobehub-dev`, which the current CLI stores under
|
||||
`$HOME/.lobehub-dev`.
|
||||
|
||||
```bash
|
||||
cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3010
|
||||
@@ -40,10 +75,30 @@ 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
|
||||
`settings.json` exists and `serverUrl` matches).
|
||||
`LOBEHUB_CLI_API_KEY` when present, otherwise checks the stored server URL).
|
||||
- `UNAUTHORIZED` on API calls means the token expired — re-run login.
|
||||
|
||||
## Web — better-auth cookie injection (agent-browser)
|
||||
## 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
|
||||
|
||||
`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
|
||||
@@ -53,31 +108,19 @@ 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.
|
||||
|
||||
### One-key path
|
||||
### Web — decision flow
|
||||
|
||||
1. Ask the user to copy the Cookie header **from a Network request, NOT
|
||||
`document.cookie`** (`document.cookie` cannot see HttpOnly cookies, which is
|
||||
exactly where better-auth puts its session):
|
||||
- Open the logged-in tab (`http://localhost:<port>/…`) in Chrome.
|
||||
- `Cmd+Option+I` → **Network** tab → refresh → click any same-origin request.
|
||||
- Under **Request Headers**, right-click the `Cookie:` line → **Copy value**.
|
||||
2. Inject and verify in one shot:
|
||||
|
||||
```bash
|
||||
pbpaste | ./.agents/skills/agent-testing/scripts/setup-auth.sh web
|
||||
```
|
||||
|
||||
The script filters the header down to the better-auth cookies
|
||||
(`better-auth.session_token`, `better-auth.state`), builds the Playwright
|
||||
`storageState` JSON, loads it into the `agent-browser` session (default name
|
||||
`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
|
||||
1. `$SCRIPT status --surface web` — green? Start testing. Do not ask for a Cookie header.
|
||||
2. Not green and using the seeded local env → `$SCRIPT web-seed`.
|
||||
3. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
|
||||
4. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:` → **Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
|
||||
5. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
|
||||
|
||||
### Using the authenticated session
|
||||
|
||||
```bash
|
||||
agent-browser --session lobehub-dev open "http://localhost:3010/"
|
||||
agent-browser --session lobehub-dev open "$SERVER_URL/"
|
||||
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
|
||||
@@ -90,12 +133,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` | 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 | — |
|
||||
| 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 | — |
|
||||
|
||||
## Electron
|
||||
|
||||
|
||||
@@ -3,33 +3,71 @@
|
||||
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 |
|
||||
| ------------------- | --------------------------------------------------------- | --------------------------------- |
|
||||
| `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) |
|
||||
| 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` |
|
||||
|
||||
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.
|
||||
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>
|
||||
```
|
||||
|
||||
## Health check
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3010/
|
||||
curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
|
||||
```
|
||||
|
||||
## Start / restart
|
||||
|
||||
```bash
|
||||
# Start (from repo root)
|
||||
# Start backend only.
|
||||
# With root .env: use the existing local config.
|
||||
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:
|
||||
bun run dev
|
||||
|
||||
# Without root .env:
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
|
||||
# Local QStash. Run in a separate terminal only when testing workflow paths.
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh qstash
|
||||
|
||||
# Restart — required to pick up server-side code changes
|
||||
lsof -ti:3010 | xargs kill
|
||||
lsof -ti:"$PORT" | 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
|
||||
@@ -48,8 +86,13 @@ 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 |
|
||||
| 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 |
|
||||
| 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.
|
||||
|
||||
@@ -11,7 +11,7 @@ output):
|
||||
|
||||
```
|
||||
.records/reports/<YYYYMMDD-HHMMSS>-<slug>/
|
||||
├── report.md # human-readable report (embedded screenshots, case table, verdict)
|
||||
├── report.md # human-readable report (case table with inline screenshots, verdict)
|
||||
├── result.json # machine-readable results (pass/fail counts, score)
|
||||
└── assets/ # evidence: screenshots, HAR files, CLI transcripts
|
||||
```
|
||||
@@ -25,13 +25,16 @@ output):
|
||||
```
|
||||
|
||||
The script creates the directory, pre-fills branch / commit / date in both
|
||||
files, and prints the directory path.
|
||||
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.
|
||||
|
||||
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,
|
||||
@@ -48,33 +51,91 @@ 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.
|
||||
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 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'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 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.md sections
|
||||
|
||||
| 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 |
|
||||
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.
|
||||
|
||||
Status values: `pass` / `fail` / `blocked` (couldn't run — e.g. auth or env
|
||||
missing; a blocked case is not a pass).
|
||||
@@ -115,7 +176,8 @@ 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.
|
||||
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.
|
||||
- **Screenshots must be visually verified** with the Read tool before being
|
||||
cited.
|
||||
- **Report failures faithfully** — a failing case with clear evidence is a good
|
||||
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
#!/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 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 container
|
||||
#
|
||||
# Overrides:
|
||||
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres 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}"
|
||||
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 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 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 \
|
||||
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 \
|
||||
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'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 "DATABASE_URL=$DATABASE_URL"
|
||||
note "PORT=$PORT"
|
||||
note "QSTASH_URL=$QSTASH_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
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
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,39 +24,53 @@ DATE_HUMAN=$(date '+%Y-%m-%d %H:%M')
|
||||
DATE_ISO=$(date '+%Y-%m-%dT%H:%M:%S%z')
|
||||
|
||||
cat > "$DIR/report.md" << EOF
|
||||
# Test Report: $TITLE
|
||||
# 测试报告:$TITLE
|
||||
|
||||
## Scope
|
||||
## 范围
|
||||
|
||||
<!-- What changed / what is being verified -->
|
||||
<!-- 测试目标 / 变更范围 / 重点风险 -->
|
||||
|
||||
- Branch: \`$BRANCH\`
|
||||
- Commit: \`$COMMIT\`
|
||||
- Date: $DATE_HUMAN
|
||||
- 分支:\`$BRANCH\`
|
||||
- 当前提交:\`$COMMIT\`
|
||||
- 日期:$DATE_HUMAN
|
||||
- 表面:<!-- CLI / Electron + CDP / Web / Bot:<platform> -->
|
||||
- 测试页 / 入口:<!-- e.g. /settings or http://localhost:3010 -->
|
||||
- 重点:<!-- 本轮最关心的体验、功能或回归点 -->
|
||||
|
||||
## Environment
|
||||
## 用例
|
||||
|
||||
- Server: <!-- e.g. http://localhost:3010 -->
|
||||
- Surfaces: <!-- cli / electron / web / bot:<platform> -->
|
||||
| # | 用例 | 结果 | 关键现象 | 证据 |
|
||||
| - | ---- | ---- | -------- | ---- |
|
||||
| 1 | | 待测 | |  |
|
||||
|
||||
## Cases
|
||||
## 结论
|
||||
|
||||
| # | Case | Surface | Steps | Expected | Actual | Status | Evidence |
|
||||
| - | ---- | ------- | ----- | -------- | ------ | ------ | -------- |
|
||||
| 1 | | | | | | | |
|
||||
整体结论:\`pending\`。
|
||||
|
||||
## Evidence
|
||||
<!-- 用 1-2 段概括用户最需要知道的结果;失败和阻塞必须明确说明影响。 -->
|
||||
|
||||
<!-- Embed screenshots:  -->
|
||||
<!-- CLI transcripts in fenced blocks, with the exact command -->
|
||||
仍需处理 / 跟进:
|
||||
|
||||
## Verdict
|
||||
- <!-- TODO -->
|
||||
|
||||
- Passed: 0 / 0
|
||||
- Failed: 0
|
||||
- Blocked: 0
|
||||
- Score (optional): —
|
||||
- Open issues / follow-ups:
|
||||
## 本轮验证
|
||||
|
||||
<!-- 如有自动化或命令行验证,保留精简命令与结果;没有则写“未运行额外自动化验证”。 -->
|
||||
|
||||
\`\`\`bash
|
||||
# command
|
||||
\`\`\`
|
||||
|
||||
结果:
|
||||
|
||||
- <!-- TODO -->
|
||||
|
||||
## 评分
|
||||
|
||||
- 通过:0
|
||||
- 失败:0
|
||||
- 阻塞:0
|
||||
- 评分:— / 100
|
||||
EOF
|
||||
|
||||
cat > "$DIR/result.json" << EOF
|
||||
|
||||
@@ -5,29 +5,114 @@
|
||||
# test step. Background and failure modes: ../references/auth.md
|
||||
#
|
||||
# Usage:
|
||||
# setup-auth.sh status # check server + CLI + web auth readiness
|
||||
# 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 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 http://localhost:3010) dev server under test
|
||||
# SERVER_URL (default from test-env.sh) 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
|
||||
|
||||
SERVER_URL="${SERVER_URL:-http://localhost:3010}"
|
||||
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)}"
|
||||
SESSION="${SESSION:-lobehub-dev}"
|
||||
AUTH_DIR="${AUTH_DIR:-$HOME/.lobehub-agent-testing}"
|
||||
STATE_FILE="$AUTH_DIR/web-state.json"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||
CLI_HOME="$REPO_ROOT/apps/cli/.lobehub-dev"
|
||||
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}"
|
||||
|
||||
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)
|
||||
@@ -41,11 +126,35 @@ check_server() {
|
||||
}
|
||||
|
||||
check_cli() {
|
||||
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)"
|
||||
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)"
|
||||
else
|
||||
bad "CLI not logged in to $SERVER_URL"
|
||||
note "ask the user to run:"
|
||||
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 "cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server $SERVER_URL"
|
||||
return 1
|
||||
fi
|
||||
@@ -54,13 +163,24 @@ 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 "copy the Cookie header from Chrome DevTools (Network tab), then:"
|
||||
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 (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() {
|
||||
@@ -84,16 +204,75 @@ check_electron() {
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
echo "agent-testing auth status (SERVER_URL=$SERVER_URL):"
|
||||
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):"
|
||||
local rc=0
|
||||
check_server || rc=1
|
||||
check_cli || rc=1
|
||||
check_web || rc=1
|
||||
check_electron || rc=1
|
||||
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
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
echo "all green — safe to start automated testing."
|
||||
echo "$surface auth green — safe to start automated testing on this surface."
|
||||
else
|
||||
echo "auth NOT ready — fix the ✘ items before writing any test step."
|
||||
echo "$surface auth NOT ready — fix the ✘ items before writing any test step."
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
@@ -105,23 +284,148 @@ 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"
|
||||
python3 - "$STATE_FILE" << 'PY'
|
||||
import json, sys, time
|
||||
local raw
|
||||
raw="$(cat)"
|
||||
COOKIE_INPUT="$raw" python3 - "$STATE_FILE" << 'PY'
|
||||
import json, os, sys, time
|
||||
|
||||
raw = sys.stdin.read().strip()
|
||||
if raw.lower().startswith("cookie:"):
|
||||
raw = raw.split(":", 1)[1].strip()
|
||||
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)
|
||||
|
||||
WANTED = {"better-auth.session_token", "better-auth.state"}
|
||||
raw = "; ".join(cookie_lines)
|
||||
|
||||
WANTED = {"better-auth.session_token", "better-auth.session_data", "better-auth.state"}
|
||||
exp = int(time.time()) + 30 * 24 * 3600 # 30 days
|
||||
|
||||
cookies = []
|
||||
for pair in raw.split("; "):
|
||||
for pair in raw.split(";"):
|
||||
pair = pair.strip()
|
||||
if "=" not in pair:
|
||||
continue
|
||||
name, _, value = pair.partition("=")
|
||||
@@ -146,14 +450,79 @@ with open(sys.argv[1], "w") as f:
|
||||
json.dump({"cookies": cookies, "origins": []}, f, indent=2)
|
||||
print(f"wrote {len(cookies)} cookie(s) to {sys.argv[1]}")
|
||||
PY
|
||||
agent-browser --session "$SESSION" state load "$STATE_FILE"
|
||||
cmd_web_verify
|
||||
}
|
||||
|
||||
cmd_web_seed() {
|
||||
check_server || return 1
|
||||
mkdir -p "$AUTH_DIR"
|
||||
|
||||
local cookie_jar="$AUTH_DIR/web-seed-cookie.jar"
|
||||
local response_body="$AUTH_DIR/web-seed-response.json"
|
||||
local payload code
|
||||
payload="$(
|
||||
SEED_EMAIL="$SEED_EMAIL" SEED_PASSWORD="$SEED_PASSWORD" python3 - << 'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
"callbackURL": "/",
|
||||
"email": os.environ["SEED_EMAIL"],
|
||||
"password": os.environ["SEED_PASSWORD"],
|
||||
}))
|
||||
PY
|
||||
)"
|
||||
|
||||
code=$(curl -sS -o "$response_body" -w '%{http_code}' \
|
||||
-c "$cookie_jar" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST "$SERVER_URL/api/auth/sign-in/email" \
|
||||
--data "$payload" 2> /dev/null || true)
|
||||
|
||||
if [[ ! "$code" =~ ^[23] ]]; then
|
||||
bad "seed user sign-in failed at $SERVER_URL/api/auth/sign-in/email (http_code='$code')"
|
||||
note "make sure the seed user exists:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local cookie_header
|
||||
cookie_header="$(cookie_header_from_jar "$cookie_jar")"
|
||||
if [[ -z "$cookie_header" ]]; then
|
||||
bad "seed sign-in succeeded but no cookies were written to $cookie_jar"
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$cookie_header" | cmd_web
|
||||
}
|
||||
|
||||
cmd_web_verify() {
|
||||
agent-browser --session "$SESSION" open "$SERVER_URL/" > /dev/null
|
||||
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
|
||||
local url
|
||||
url=$(agent-browser --session "$SESSION" get 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
|
||||
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"
|
||||
@@ -163,12 +532,22 @@ cmd_web_verify() {
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
status) cmd_status ;;
|
||||
status)
|
||||
shift || true
|
||||
cmd_status "$@"
|
||||
;;
|
||||
cli-seed) cmd_cli_seed ;;
|
||||
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|web|web-verify}" >&2
|
||||
echo "Usage: $0 {status|cli-seed|cli|open-chrome|web-seed|web|web-verify}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
#!/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"
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
#!/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
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/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,23 +10,32 @@ 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 injected into agent-browser — [../references/auth.md](../references/auth.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)
|
||||
|
||||
```bash
|
||||
pbpaste | ./.agents/skills/agent-testing/scripts/setup-auth.sh web # after copying the Cookie header
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh web-seed
|
||||
```
|
||||
|
||||
## Option A — agent-browser with injected auth (recommended)
|
||||
Then drive the verified session:
|
||||
|
||||
```bash
|
||||
SESSION=lobehub-dev
|
||||
|
||||
agent-browser --session $SESSION open "http://localhost:3010/"
|
||||
agent-browser --session $SESSION open "$SERVER_URL/"
|
||||
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
|
||||
|
||||
@@ -19,12 +19,6 @@ 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
|
||||
@@ -33,22 +27,4 @@ jobs:
|
||||
upstream_sync_branch: main
|
||||
target_sync_branch: main
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||
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
|
||||
test_mode: false
|
||||
+2
-3
@@ -59,6 +59,7 @@ bun.lockb
|
||||
# Build outputs
|
||||
dist/
|
||||
public/_spa/
|
||||
public/_spa-auth/
|
||||
public/spa/
|
||||
es/
|
||||
lib/
|
||||
@@ -92,10 +93,8 @@ 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
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/agent-gateway-client": "workspace:*",
|
||||
"@lobechat/device-control": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/device-identity": "workspace:*",
|
||||
"@lobechat/heterogeneous-agents": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
packages:
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/device-control'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
|
||||
@@ -2,9 +2,16 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
defaultGetLocalFilePreview,
|
||||
defaultGetProjectFileIndex,
|
||||
type DeviceControlDeps,
|
||||
executeDeviceRpc,
|
||||
} from '@lobechat/device-control';
|
||||
import type {
|
||||
AgentRunRequestMessage,
|
||||
DeviceSystemInfo,
|
||||
RpcRequestMessage,
|
||||
SystemInfoRequestMessage,
|
||||
ToolCallRequestMessage,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
@@ -262,19 +269,23 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
|
||||
// Handle tool call requests
|
||||
client.on('tool_call_request', async (request: ToolCallRequestMessage) => {
|
||||
const { requestId, timeout, toolCall } = request;
|
||||
const { operationId, requestId, timeout, toolCall } = request;
|
||||
if (isDaemonChild) {
|
||||
appendLog(`[TOOL] ${toolCall.apiName} (${requestId})`);
|
||||
appendLog(
|
||||
`[TOOL] ${toolCall.apiName}${operationId ? ` op=${operationId}` : ''} (${requestId})`,
|
||||
);
|
||||
} else {
|
||||
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
|
||||
log.toolCall(toolCall.apiName, requestId, toolCall.arguments, operationId);
|
||||
}
|
||||
|
||||
const result = await executeToolCall(toolCall.apiName, toolCall.arguments, timeout);
|
||||
|
||||
if (isDaemonChild) {
|
||||
appendLog(`[RESULT] ${result.success ? 'OK' : 'FAIL'} (${requestId})`);
|
||||
appendLog(
|
||||
`[RESULT] ${result.success ? 'OK' : 'FAIL'}${operationId ? ` op=${operationId}` : ''} (${requestId})`,
|
||||
);
|
||||
} else {
|
||||
log.toolResult(requestId, result.success, result.content);
|
||||
log.toolResult(requestId, result.success, result.content, operationId);
|
||||
}
|
||||
|
||||
client.sendToolCallResponse({
|
||||
@@ -288,6 +299,31 @@ 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`
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-console */
|
||||
import pc from 'picocolors';
|
||||
|
||||
let verbose = false;
|
||||
@@ -41,18 +40,20 @@ export const log = {
|
||||
console.log(`${timestamp()} ${pc.bold('[STATUS]')} ${color(status)}`);
|
||||
},
|
||||
|
||||
toolCall: (apiName: string, requestId: string, args?: string) => {
|
||||
toolCall: (apiName: string, requestId: string, args?: string, operationId?: string) => {
|
||||
console.log(
|
||||
`${timestamp()} ${pc.magenta('[TOOL]')} ${pc.bold(apiName)} ${pc.dim(`(${requestId})`)}`,
|
||||
`${timestamp()} ${pc.magenta('[TOOL]')} ${pc.bold(apiName)}${operationId ? ` ${pc.dim(`op=${operationId}`)}` : ''} ${pc.dim(`(${requestId})`)}`,
|
||||
);
|
||||
if (args && verbose) {
|
||||
console.log(` ${pc.dim(args)}`);
|
||||
}
|
||||
},
|
||||
|
||||
toolResult: (requestId: string, success: boolean, content?: string) => {
|
||||
toolResult: (requestId: string, success: boolean, content?: string, operationId?: string) => {
|
||||
const icon = success ? pc.green('OK') : pc.red('FAIL');
|
||||
console.log(`${timestamp()} ${pc.magenta('[RESULT]')} ${icon} ${pc.dim(`(${requestId})`)}`);
|
||||
console.log(
|
||||
`${timestamp()} ${pc.magenta('[RESULT]')} ${icon}${operationId ? ` ${pc.dim(`op=${operationId}`)}` : ''} ${pc.dim(`(${requestId})`)}`,
|
||||
);
|
||||
if (content && verbose) {
|
||||
const preview = content.length > 200 ? content.slice(0, 200) + '...' : content;
|
||||
console.log(` ${pc.dim(preview)}`);
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@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:*",
|
||||
|
||||
@@ -8,6 +8,7 @@ packages:
|
||||
- '../../packages/electron-client-ipc'
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '../../packages/device-control'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/local-file-shell'
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,
|
||||
@@ -13,11 +14,8 @@ import type {
|
||||
GetCommandOutputParams,
|
||||
GlobFilesParams,
|
||||
GrepContentParams,
|
||||
InitWorkspaceParams,
|
||||
KillCommandParams,
|
||||
ListLocalFileParams,
|
||||
ListProjectSkillsParams,
|
||||
LocalFilePreviewUrlParams,
|
||||
LocalReadFileParams,
|
||||
LocalReadFilesParams,
|
||||
LocalSearchFilesParams,
|
||||
@@ -30,15 +28,16 @@ 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';
|
||||
import WorkspaceCtr from './WorkspaceCtr';
|
||||
|
||||
const logger = createLogger('controllers:GatewayConnectionCtr');
|
||||
|
||||
/**
|
||||
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
|
||||
@@ -167,14 +166,6 @@ 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);
|
||||
}
|
||||
@@ -353,91 +344,33 @@ 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. Currently only `initWorkspace` (scan the bound project root for
|
||||
* skills + AGENTS.md); add new server-only device methods here.
|
||||
* method name. The dispatch logic lives in `@lobechat/device-control` so the
|
||||
* desktop main process and the CLI daemon share one device RPC surface.
|
||||
*/
|
||||
private async executeDeviceRpc(method: string, params: unknown): Promise<unknown> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
return runDeviceRpc(method, params, this.deviceControlDeps);
|
||||
}
|
||||
|
||||
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 } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import type { AgentStreamEvent, UsageData } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import {
|
||||
AgentStreamPipeline,
|
||||
buildAgentInput,
|
||||
@@ -188,6 +188,21 @@ 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;
|
||||
@@ -470,11 +485,20 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
session.agentType === 'claude-code' ? 'claude-code' : 'codex',
|
||||
command,
|
||||
);
|
||||
const cliMissingError = this.buildCliMissingError(session);
|
||||
|
||||
if (!status || status.available || !cliMissingError) return;
|
||||
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;
|
||||
}
|
||||
|
||||
return cliMissingError;
|
||||
return this.buildCliMissingError(session);
|
||||
}
|
||||
|
||||
private get shouldTraceCliOutput(): boolean {
|
||||
@@ -911,6 +935,7 @@ 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);
|
||||
@@ -934,7 +959,12 @@ 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'));
|
||||
spawnEnv = { ...buildInheritedSpawnEnv(), ...proxyEnv, ...session.env };
|
||||
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 };
|
||||
|
||||
if (session.agentType === 'codex') {
|
||||
const initialModel = await resolveCodexInitialModel({
|
||||
@@ -945,6 +975,12 @@ 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({
|
||||
@@ -966,7 +1002,10 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
const useStdin = spawnPlan.stdinPayload !== undefined;
|
||||
const cliArgs = spawnPlan.args;
|
||||
const resolvedCliSpawnPlan = await resolveCliSpawnPlan(session.command, cliArgs);
|
||||
const resolvedCliSpawnPlan = await resolveCliSpawnPlan(
|
||||
session.resolvedCommandPath ?? session.command,
|
||||
cliArgs,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
'Spawning agent:',
|
||||
@@ -1001,6 +1040,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
reject,
|
||||
resolve,
|
||||
session,
|
||||
initialCumulativeUsage,
|
||||
spawnEnv,
|
||||
traceSession,
|
||||
useStdin,
|
||||
@@ -1070,6 +1110,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
|
||||
private handleSpawnedAgentProcess({
|
||||
cwd,
|
||||
initialCumulativeUsage,
|
||||
intervention,
|
||||
params,
|
||||
proc,
|
||||
@@ -1088,6 +1129,7 @@ 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;
|
||||
@@ -1128,6 +1170,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
const pipeline = new AgentStreamPipeline({
|
||||
agentType: session.agentType,
|
||||
cwd,
|
||||
initialCumulativeUsage,
|
||||
initialModel: session.model,
|
||||
operationId: params.operationId,
|
||||
});
|
||||
|
||||
@@ -437,11 +437,13 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async getLocalFilePreviewUrl({
|
||||
accept,
|
||||
path: filePath,
|
||||
workingDirectory,
|
||||
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
|
||||
try {
|
||||
const url = await this.app.localFileProtocolManager.createPreviewUrl({
|
||||
accept,
|
||||
filePath,
|
||||
workspaceRoot: workingDirectory,
|
||||
});
|
||||
@@ -459,11 +461,13 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async getLocalFilePreview({
|
||||
accept,
|
||||
path: filePath,
|
||||
workingDirectory,
|
||||
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewResult> {
|
||||
try {
|
||||
const preview = await this.app.localFileProtocolManager.readPreviewFile({
|
||||
accept,
|
||||
filePath,
|
||||
workspaceRoot: workingDirectory,
|
||||
});
|
||||
|
||||
@@ -1,244 +1,53 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
initWorkspace as runInitWorkspace,
|
||||
listProjectSkills as runListProjectSkills,
|
||||
statPath as runStatPath,
|
||||
type WorkspaceScanDeps,
|
||||
} from '@lobechat/device-control';
|
||||
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
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export default class WorkspaceCtr extends ControllerModule {
|
||||
static override readonly groupName = 'workspace';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
private get scanDeps(): WorkspaceScanDeps {
|
||||
return { approveProjectRoot: (root) => this.approveProjectRootForPreview(root) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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 };
|
||||
return runListProjectSkills(params, this.scanDeps);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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 };
|
||||
return runInitWorkspace(params, this.scanDeps);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' }> {
|
||||
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;
|
||||
return runStatPath(params);
|
||||
}
|
||||
|
||||
private async approveProjectRootForPreview(root: string) {
|
||||
|
||||
@@ -480,6 +480,87 @@ 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,6 +225,7 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
accept: undefined,
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -247,6 +248,28 @@ 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',
|
||||
filePath: '/workspace/image.png',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
url: 'localfile://file/workspace/image.png?token=abc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocalFilePreview', () => {
|
||||
@@ -263,6 +286,7 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
|
||||
accept: undefined,
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -289,6 +313,34 @@ 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',
|
||||
filePath: '/workspace/image.png',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
preview: {
|
||||
base64: Buffer.from('image-bytes').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
type: 'image',
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWriteFile', () => {
|
||||
|
||||
@@ -54,6 +54,21 @@ 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.
|
||||
*
|
||||
@@ -213,16 +228,26 @@ export class LocalFileProtocolManager {
|
||||
}
|
||||
|
||||
async createPreviewUrl({
|
||||
accept,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
accept?: PreviewFileAccept;
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
}): Promise<string | null> {
|
||||
const normalizedFilePath = normalizeAbsolutePath(filePath);
|
||||
if (!normalizedFilePath) return null;
|
||||
|
||||
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
|
||||
const realFilePath = accept
|
||||
? (
|
||||
await this.readPreviewFile({
|
||||
accept,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
})
|
||||
)?.realPath
|
||||
: await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
|
||||
if (!realFilePath) return null;
|
||||
|
||||
this.cleanupExpiredTokens();
|
||||
@@ -237,9 +262,11 @@ export class LocalFileProtocolManager {
|
||||
}
|
||||
|
||||
async readPreviewFile({
|
||||
accept,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
accept?: PreviewFileAccept;
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
}): Promise<PreviewFileReadResult | null> {
|
||||
@@ -250,9 +277,12 @@ export class LocalFileProtocolManager {
|
||||
if (!fileStat.isFile()) return null;
|
||||
|
||||
const buffer = await readFile(realFilePath);
|
||||
const contentType = resolveLocalFileMimeType(realFilePath, buffer);
|
||||
if (!isAcceptedPreviewContentType(contentType, accept)) return null;
|
||||
|
||||
return {
|
||||
buffer,
|
||||
contentType: resolveLocalFileMimeType(realFilePath, buffer),
|
||||
contentType,
|
||||
realPath: realFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@ 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,6 +119,21 @@ 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();
|
||||
@@ -296,6 +311,21 @@ 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 read preview payloads outside the approved workspace root', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveIndexedProjectRoot('/Users/alice/project');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -180,6 +181,76 @@ 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 () => {
|
||||
@@ -200,6 +271,12 @@ 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 { platform } from 'node:os';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
@@ -190,6 +190,11 @@ 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 {
|
||||
@@ -209,6 +214,27 @@ 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,
|
||||
@@ -216,7 +242,20 @@ export const detectHeterogeneousCliCommand = async (
|
||||
const validator = HETEROGENEOUS_CLI_AGENT_OPTIONS[agentType];
|
||||
if (!validator) return { available: false };
|
||||
|
||||
return detectValidatedCommand(command, validator);
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -261,14 +300,17 @@ 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 = createValidatedDetector({
|
||||
candidates: ['codex'],
|
||||
export const codexDetector: IToolDetector = {
|
||||
description: 'Codex - OpenAI agentic coding CLI',
|
||||
detect: () => detectHeterogeneousCliCommand('codex', 'codex'),
|
||||
name: 'codex',
|
||||
priority: 2,
|
||||
validateKeywords: ['codex'],
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Google Gemini CLI
|
||||
|
||||
@@ -61,6 +61,7 @@ import { chainCompressContext } from '@lobechat/prompts';
|
||||
import {
|
||||
type ChatToolPayload,
|
||||
type ExecSubAgentParams,
|
||||
type ExecVirtualSubAgentParams,
|
||||
type MessageToolCall,
|
||||
type UIChatMessage,
|
||||
} from '@lobechat/types';
|
||||
@@ -323,7 +324,7 @@ const buildPostProcessUrl = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the per-tool-call server sub-agent runner injected into the tool
|
||||
* Build the per-tool-call server virtual 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).
|
||||
@@ -331,17 +332,18 @@ 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 sub-agent execution is not
|
||||
* available (no `execSubAgent` callback, or missing agent/topic context).
|
||||
* returns immediately. Returns `undefined` when virtual sub-agent execution is
|
||||
* not available (no `execVirtualSubAgent` callback, or missing agent/topic
|
||||
* context).
|
||||
*/
|
||||
const buildServerSubAgentRunner = (
|
||||
const buildServerVirtualSubAgentRunner = (
|
||||
ctx: RuntimeExecutorContext,
|
||||
state: AgentState,
|
||||
chatToolPayload: ChatToolPayload,
|
||||
parentMessageId: string,
|
||||
): ServerSubAgentRunner | undefined => {
|
||||
const execSubAgent = ctx.execSubAgent;
|
||||
if (!execSubAgent) return undefined;
|
||||
const execVirtualSubAgent = ctx.execVirtualSubAgent;
|
||||
if (!execVirtualSubAgent) return undefined;
|
||||
|
||||
const agentId = state.metadata?.agentId;
|
||||
const topicId = ctx.topicId ?? state.metadata?.topicId;
|
||||
@@ -364,16 +366,15 @@ const buildServerSubAgentRunner = (
|
||||
topicId,
|
||||
});
|
||||
|
||||
// 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({
|
||||
// 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({
|
||||
agentId: targetAgentId ?? agentId,
|
||||
groupId: state.metadata?.groupId ?? undefined,
|
||||
instruction,
|
||||
parentMessageId: placeholder.id,
|
||||
parentOperationId: ctx.operationId,
|
||||
resumeParentOnComplete: true,
|
||||
timeout,
|
||||
title: description,
|
||||
topicId,
|
||||
@@ -387,7 +388,7 @@ const buildServerSubAgentRunner = (
|
||||
await ctx.messageModel.deleteMessage(placeholder.id);
|
||||
} catch (error) {
|
||||
log(
|
||||
'buildServerSubAgentRunner: failed to clean up placeholder %s: %O',
|
||||
'buildServerVirtualSubAgentRunner: failed to clean up placeholder %s: %O',
|
||||
placeholder.id,
|
||||
error,
|
||||
);
|
||||
@@ -522,11 +523,17 @@ export interface RuntimeExecutorContext {
|
||||
discordContext?: any;
|
||||
evalContext?: EvalContext;
|
||||
/**
|
||||
* Callback to spawn a sub-agent task server-side.
|
||||
* Callback to run a legacy agent invocation server-side.
|
||||
* Injected by AiAgentService so exec_sub_agent / exec_sub_agents executors
|
||||
* can dispatch callAgent-triggered tasks without a circular import.
|
||||
* can dispatch callAgent-triggered runs 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;
|
||||
@@ -2476,7 +2483,7 @@ export const createRuntimeExecutors = (
|
||||
scope: state.metadata?.scope,
|
||||
serverDB: ctx.serverDB,
|
||||
skipResultTruncation: true,
|
||||
subAgent: buildServerSubAgentRunner(
|
||||
subAgent: buildServerVirtualSubAgentRunner(
|
||||
ctx,
|
||||
state,
|
||||
chatToolPayload,
|
||||
@@ -2718,14 +2725,15 @@ export const createRuntimeExecutors = (
|
||||
|
||||
log('[%s:%d] Tool execution completed', operationId, stepIndex);
|
||||
|
||||
// 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';
|
||||
// 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';
|
||||
|
||||
executeToolSpan.setAttributes(
|
||||
buildExecuteToolResultAttributes({ attempts: execution.attempts, success: isSuccess }),
|
||||
@@ -2741,7 +2749,7 @@ export const createRuntimeExecutors = (
|
||||
isSuccess,
|
||||
// Pass tool message ID as parentMessageId for the next LLM call
|
||||
parentMessageId: toolMessageId,
|
||||
...(isExecTaskState && { stop: true }),
|
||||
...(isLegacyAgentInvocationState && { stop: true }),
|
||||
toolCall: chatToolPayload,
|
||||
toolCallId: chatToolPayload.id,
|
||||
},
|
||||
@@ -3048,7 +3056,7 @@ export const createRuntimeExecutors = (
|
||||
scope: state.metadata?.scope,
|
||||
serverDB: ctx.serverDB,
|
||||
skipResultTruncation: true,
|
||||
subAgent: buildServerSubAgentRunner(
|
||||
subAgent: buildServerVirtualSubAgentRunner(
|
||||
ctx,
|
||||
state,
|
||||
chatToolPayload,
|
||||
|
||||
@@ -132,6 +132,14 @@ 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', () => {
|
||||
|
||||
@@ -9,10 +9,16 @@ 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(),
|
||||
@@ -329,4 +335,122 @@ describe('agentRouter', () => {
|
||||
expect(agentModelMock.update).toHaveBeenCalledWith(mockInput.id, { pinned: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit lock', () => {
|
||||
const wsCtx = () => ({ ...mockCtx, workspaceId: 'ws-1' });
|
||||
|
||||
describe('updateAgentConfig write guard', () => {
|
||||
it('rejects the update when another member holds the lock', async () => {
|
||||
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
|
||||
await expect(
|
||||
caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } }),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(agentServiceMock.updateAgentConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the update when no other member holds the lock', async () => {
|
||||
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } });
|
||||
|
||||
expect(agentServiceMock.updateAgentConfig).toHaveBeenCalledWith('agent-1', {
|
||||
systemRole: 'x',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not check the lock for personal (non-workspace) agents', async () => {
|
||||
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
|
||||
const caller = agentRouter.createCaller(mockCtx);
|
||||
await caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } });
|
||||
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(agentServiceMock.updateAgentConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireAgentLock', () => {
|
||||
it('returns unlocked without touching the lock service for personal agents', async () => {
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
|
||||
|
||||
const caller = agentRouter.createCaller(mockCtx);
|
||||
const result = await caller.acquireAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
expect(acquireSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('broadcasts lock.changed on a holder edge (first claim)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.acquireAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'agent-1', type: 'agent' },
|
||||
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.acquireAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentLock', () => {
|
||||
it('reports another member as the holder', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
const result = await caller.getAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: 'other-user', lockedByOther: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseAgentLock', () => {
|
||||
it('broadcasts unlocked only when it actually freed the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.releaseAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'agent-1', type: 'agent' },
|
||||
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast when the lease expired / was taken over', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false);
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.releaseAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,15 @@ 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;
|
||||
@@ -439,4 +445,126 @@ describe('agentGroupRouter', () => {
|
||||
expect(result).toEqual(mockUpdatedGroup);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit lock', () => {
|
||||
const wsCtx = () => ({ serverDB: {}, userId, workspaceId: 'ws-1' });
|
||||
|
||||
describe('updateGroup write guard', () => {
|
||||
it('rejects the update when another member holds the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
|
||||
await expect(
|
||||
caller.updateGroup({ id: 'group-1', value: { title: 'New' } }),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(chatGroupModelMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the update when no other member holds the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
chatGroupModelMock.update.mockResolvedValue({ id: 'group-1' });
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.updateGroup({ id: 'group-1', value: { title: 'New' } });
|
||||
|
||||
expect(chatGroupModelMock.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not check the lock for personal (non-workspace) groups', async () => {
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
chatGroupModelMock.update.mockResolvedValue({ id: 'group-1' });
|
||||
|
||||
const caller = agentGroupRouter.createCaller(mockCtx);
|
||||
await caller.updateGroup({ id: 'group-1', value: { title: 'New' } });
|
||||
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(chatGroupModelMock.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireGroupLock', () => {
|
||||
it('returns unlocked without touching the lock service for personal groups', async () => {
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
|
||||
|
||||
const caller = agentGroupRouter.createCaller(mockCtx);
|
||||
const result = await caller.acquireGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
expect(acquireSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('broadcasts lock.changed on a holder edge (first claim)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.acquireGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'group-1', type: 'chatGroup' },
|
||||
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.acquireGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupLock', () => {
|
||||
it('reports another member as the holder', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
const result = await caller.getGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: 'other-user', lockedByOther: true });
|
||||
});
|
||||
|
||||
it('returns unlocked for personal groups', async () => {
|
||||
const caller = agentGroupRouter.createCaller(mockCtx);
|
||||
const result = await caller.getGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseGroupLock', () => {
|
||||
it('broadcasts unlocked only when it actually freed the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.releaseGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'group-1', type: 'chatGroup' },
|
||||
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast when the lease expired / was taken over', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false);
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.releaseGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('aiChatRouter', () => {
|
||||
expect(mockCreateUserAndAssistantMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateUserAndAssistantMessages).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ touchTopicUpdatedAt: false }),
|
||||
expect.not.objectContaining({ touchTopicUpdatedAt: expect.anything() }),
|
||||
);
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
@@ -161,7 +161,7 @@ describe('aiChatRouter', () => {
|
||||
expect(mockCreateMessage).toHaveBeenCalled();
|
||||
expect(mockCreateUserAndAssistantMessages).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ touchTopicUpdatedAt: true }),
|
||||
expect.not.objectContaining({ touchTopicUpdatedAt: expect.anything() }),
|
||||
);
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -4,12 +4,15 @@ 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> = {}) => {
|
||||
@@ -91,18 +94,90 @@ describe('pushTokenRouter', () => {
|
||||
});
|
||||
|
||||
describe('unregister', () => {
|
||||
it('should call model.unregister with deviceId', async () => {
|
||||
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).
|
||||
mockUnregister.mockResolvedValueOnce(undefined);
|
||||
const caller = createCaller();
|
||||
|
||||
await caller.unregister({ deviceId: 'device-1' });
|
||||
const result = await caller.unregister({ deviceId: 'device-1' });
|
||||
|
||||
expect(mockUnregister).toHaveBeenCalledWith('device-1');
|
||||
expect(mockDeleteByExpoTokenAndDevice).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should silently succeed without expoToken AND without session', async () => {
|
||||
// Path C — v1.0.7 + dead session: the only safe move is silent OK.
|
||||
// Orphan row will be cleaned up by the process-push-receipts worker via
|
||||
// Expo DeviceNotRegistered receipts. Returning 200 here stops the storm.
|
||||
const caller = createCaller({ userId: undefined });
|
||||
|
||||
const result = await caller.unregister({ deviceId: 'device-1' });
|
||||
|
||||
expect(mockDeleteByExpoTokenAndDevice).not.toHaveBeenCalled();
|
||||
expect(mockUnregister).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should succeed for an unauthenticated caller carrying expoToken', async () => {
|
||||
// New clients (>=1.0.8) hit Path A regardless of session.
|
||||
const caller = createCaller({ userId: undefined });
|
||||
|
||||
const result = await caller.unregister({
|
||||
deviceId: 'device-1',
|
||||
expoToken: 'ExponentPushToken[abc]',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockDeleteByExpoTokenAndDevice).toHaveBeenCalled();
|
||||
expect(mockUnregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer expoToken precision over the legacy userId fallback', async () => {
|
||||
// If both are available, always take Path A — the (expoToken, deviceId)
|
||||
// pair is more precise and doesn't risk deleting a wrong row.
|
||||
const caller = createCaller();
|
||||
|
||||
await caller.unregister({
|
||||
deviceId: 'device-1',
|
||||
expoToken: 'ExponentPushToken[abc]',
|
||||
});
|
||||
|
||||
expect(mockDeleteByExpoTokenAndDevice).toHaveBeenCalled();
|
||||
expect(mockUnregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject empty deviceId', async () => {
|
||||
const caller = createCaller();
|
||||
await expect(caller.unregister({ deviceId: '' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should reject empty expoToken when provided', async () => {
|
||||
const caller = createCaller();
|
||||
await expect(
|
||||
caller.unregister({ deviceId: 'device-1', expoToken: '' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ 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(),
|
||||
@@ -58,6 +59,7 @@ export interface DocumentHistoryListItem {
|
||||
isCurrent: boolean;
|
||||
savedAt: string;
|
||||
saveSource: DocumentHistorySaveSource;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ListHistoryOutput {
|
||||
@@ -123,6 +125,7 @@ export interface CompareHistoryItemsInput {
|
||||
}
|
||||
|
||||
export interface UpdateDocumentInput {
|
||||
breakAutosaveWindow?: boolean;
|
||||
content?: string;
|
||||
editorData?: string;
|
||||
fileType?: string;
|
||||
|
||||
@@ -17,6 +17,8 @@ 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) => {
|
||||
@@ -28,6 +30,7 @@ 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),
|
||||
@@ -440,6 +443,19 @@ 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);
|
||||
}),
|
||||
@@ -458,4 +474,48 @@ export const agentRouter = router({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return ctx.agentModel.update(input.id, { pinned: input.pinned });
|
||||
}),
|
||||
|
||||
acquireAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const prev = await ctx.editLockService.getActiveHolder('agent', input.agentId);
|
||||
const result = await ctx.editLockService.acquire('agent', input.agentId);
|
||||
if ((result.holderId ?? null) !== (prev ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id: input.agentId, type: 'agent' },
|
||||
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
|
||||
getAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const holder = await ctx.editLockService.getActiveHolder('agent', input.agentId);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== ctx.userId,
|
||||
};
|
||||
}),
|
||||
|
||||
releaseAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return;
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease expired and another member took over, the lock is still held.
|
||||
const released = await ctx.editLockService.release('agent', input.agentId);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id: input.agentId, type: 'agent' },
|
||||
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ 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';
|
||||
|
||||
/**
|
||||
@@ -55,6 +57,7 @@ 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),
|
||||
},
|
||||
});
|
||||
@@ -402,6 +405,19 @@ 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(
|
||||
@@ -409,6 +425,47 @@ 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,6 +85,7 @@ export const agentSignalRouter = router({
|
||||
return enqueueAgentSignalSourceEvent(sourceEvent, {
|
||||
agentId: input.agentId,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId ?? undefined,
|
||||
});
|
||||
}),
|
||||
listReceipts: agentSignalProcedure
|
||||
|
||||
@@ -370,7 +370,6 @@ export const aiChatRouter = router({
|
||||
{ assistantMessage, userMessage },
|
||||
{
|
||||
...(modelTiming ? { timing: modelTiming } : {}),
|
||||
touchTopicUpdatedAt: !isCreateNewTopic,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -163,6 +163,50 @@ export const deviceRouter = router({
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Rename a branch in a directory on a remote device, via the device's
|
||||
* `renameGitBranch` RPC.
|
||||
*/
|
||||
renameGitBranch: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string(),
|
||||
from: z.string(),
|
||||
path: z.string(),
|
||||
to: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.renameGitBranch({
|
||||
deviceId: input.deviceId,
|
||||
from: input.from,
|
||||
path: input.path,
|
||||
to: input.to,
|
||||
userId: ctx.userId,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Delete a branch in a directory on a remote device, via the device's
|
||||
* `deleteGitBranch` RPC.
|
||||
*/
|
||||
deleteGitBranch: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
branch: z.string(),
|
||||
deviceId: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.deleteGitBranch({
|
||||
branch: input.branch,
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Pull (`--ff-only`) the current branch of a directory on a remote device, via
|
||||
* the device's `pullGitBranch` RPC.
|
||||
@@ -275,9 +319,17 @@ export const deviceRouter = router({
|
||||
* receives render data, not a `localfile://` URL; saving remains unsupported.
|
||||
*/
|
||||
getLocalFilePreview: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string(), workingDirectory: z.string() }))
|
||||
.input(
|
||||
z.object({
|
||||
accept: z.enum(['image']).optional(),
|
||||
deviceId: z.string(),
|
||||
path: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
deviceGateway.getLocalFilePreview({
|
||||
accept: input.accept,
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -253,6 +253,27 @@ export const documentRouter = router({
|
||||
return ctx.documentService.queryDocuments(input);
|
||||
}),
|
||||
|
||||
acquireDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.documentService.acquireDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
getDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.documentService.getDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
releaseDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.documentService.releaseDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
updateDocument: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(updateDocumentInputSchema)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PushTokenModel } from '@/database/models/pushToken';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import {
|
||||
deletePushTokenByExpoTokenAndDevice,
|
||||
PushTokenModel,
|
||||
} from '@/database/models/pushToken';
|
||||
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
|
||||
const pushTokenProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const authedPushTokenProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
|
||||
return opts.next({
|
||||
@@ -13,7 +16,7 @@ const pushTokenProcedure = authedProcedure.use(serverDatabase).use(async (opts)
|
||||
});
|
||||
|
||||
export const pushTokenRouter = router({
|
||||
register: pushTokenProcedure
|
||||
register: authedPushTokenProcedure
|
||||
.input(
|
||||
z.object({
|
||||
appVersion: z.string().optional(),
|
||||
@@ -27,10 +30,53 @@ export const pushTokenRouter = router({
|
||||
return ctx.pushTokenModel.upsert(input);
|
||||
}),
|
||||
|
||||
unregister: pushTokenProcedure
|
||||
.input(z.object({ deviceId: z.string().min(1) }))
|
||||
/**
|
||||
* Public on purpose: clients call this during sign-out, and in the wild many
|
||||
* of those calls arrive after the session is already gone (expired OIDC
|
||||
* token / cleared cookie). Authenticating by session here causes a 401
|
||||
* storm on every such logout.
|
||||
*
|
||||
* Authorization model (Path A — new clients ≥ 1.0.8): the caller presents the
|
||||
* (deviceId, expoToken) pair it received at registration. Holding both = proof
|
||||
* of ownership of the row, same trust model as APNs/FCM unregister.
|
||||
*
|
||||
* Backwards compat for v1.0.7 (only sends `deviceId`):
|
||||
* - Path B — when the request still carries a valid session, fall back to
|
||||
* the original (userId, deviceId) delete. This covers the *active*
|
||||
* sign-out path so PushChannel doesn't keep notifying a signed-out device
|
||||
* until the user uninstalls (Expo's DeviceNotRegistered receipt only
|
||||
* fires on uninstall, not on logout).
|
||||
* - Path C — when there's no session either, silently succeed. The orphan
|
||||
* row will be cleaned up by the existing `process-push-receipts` worker
|
||||
* via Expo's DeviceNotRegistered receipts. Returning 200 here is what
|
||||
* actually stops the 401 storm in production.
|
||||
*/
|
||||
unregister: publicProcedure
|
||||
.use(serverDatabase)
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string().min(1),
|
||||
expoToken: z.string().min(1).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.pushTokenModel.unregister(input.deviceId);
|
||||
const { deviceId, expoToken } = input;
|
||||
|
||||
// Path A: new clients — precise delete by (expoToken, deviceId), no session needed
|
||||
if (expoToken) {
|
||||
await deletePushTokenByExpoTokenAndDevice(ctx.serverDB, { deviceId, expoToken });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Path B: legacy v1.0.7 + valid session — fall back to (userId, deviceId)
|
||||
if (ctx.userId) {
|
||||
const pushTokenModel = new PushTokenModel(ctx.serverDB, ctx.userId);
|
||||
await pushTokenModel.unregister(deviceId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Path C: legacy v1.0.7 with no session — silent OK, cron worker cleans up
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import { TopicModel } from '@/database/models/topic';
|
||||
import { workspaceMembers } from '@/database/schemas';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
import { TaskService } from '@/server/services/task';
|
||||
import { TaskLifecycleService } from '@/server/services/taskLifecycle';
|
||||
import { TaskRunnerService } from '@/server/services/taskRunner';
|
||||
@@ -26,6 +28,7 @@ const taskProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
|
||||
ctx: {
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
|
||||
briefModel: new BriefModel(ctx.serverDB, ctx.userId, wsId),
|
||||
editLockService: new EditLockService(ctx.userId),
|
||||
taskLifecycle: new TaskLifecycleService(ctx.serverDB, ctx.userId, wsId),
|
||||
taskModel: new TaskModel(ctx.serverDB, ctx.userId, wsId),
|
||||
taskService: new TaskService(ctx.serverDB, ctx.userId, wsId),
|
||||
@@ -927,6 +930,20 @@ export const taskRouter = router({
|
||||
const model = ctx.taskModel;
|
||||
await assertAssigneeAgentBelongsToUser(ctx.agentModel, data.assigneeAgentId);
|
||||
const resolved = await resolveOrThrow(model, id);
|
||||
|
||||
// Collaborative edit lock: reject writes to a workspace task another member
|
||||
// is actively editing. Inert until a client acquires the lock.
|
||||
if (ctx.workspaceId) {
|
||||
const blockedBy = await ctx.editLockService.getBlockingHolder('task', resolved.id);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Task is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedParentTaskId =
|
||||
parentTaskId === undefined
|
||||
? undefined
|
||||
@@ -947,6 +964,44 @@ export const taskRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
acquireTaskLock: taskProcedureWrite.input(idInput).mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
|
||||
const prev = await ctx.editLockService.getActiveHolder('task', resolved.id);
|
||||
const result = await ctx.editLockService.acquire('task', resolved.id);
|
||||
if ((result.holderId ?? null) !== (prev ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id: resolved.id, type: 'task' },
|
||||
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
|
||||
getTaskLock: taskProcedureWrite.input(idInput).query(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
|
||||
const holder = await ctx.editLockService.getActiveHolder('task', resolved.id);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== ctx.userId,
|
||||
};
|
||||
}),
|
||||
|
||||
releaseTaskLock: taskProcedureWrite.input(idInput).mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return;
|
||||
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease expired and another member took over, the lock is still held.
|
||||
const released = await ctx.editLockService.release('task', resolved.id);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id: resolved.id, type: 'task' },
|
||||
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}),
|
||||
|
||||
updateConfig: taskProcedureWrite
|
||||
.input(idInput.merge(z.object({ config: z.record(z.unknown()) })))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { AgentOperationModel } from '@/database/models/agentOperation';
|
||||
import { LlmGenerationTracingModel } from '@/database/models/llmGenerationTracing';
|
||||
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
|
||||
import { VerifyCriterionModel } from '@/database/models/verifyCriterion';
|
||||
import { VerifyRubricModel } from '@/database/models/verifyRubric';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import {
|
||||
VerifyExecutorService,
|
||||
@@ -35,18 +36,19 @@ const checkItemSchema = z.object({
|
||||
verifierType: verifierTypeSchema,
|
||||
});
|
||||
|
||||
const verifyProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const verifyProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
const workspaceId = ctx.workspaceId ?? undefined;
|
||||
return opts.next({
|
||||
ctx: {
|
||||
criterionModel: new VerifyCriterionModel(ctx.serverDB, ctx.userId),
|
||||
executorService: new VerifyExecutorService(ctx.serverDB, ctx.userId),
|
||||
tracingModel: new LlmGenerationTracingModel(ctx.serverDB, ctx.userId),
|
||||
feedbackService: new VerifyFeedbackService(ctx.serverDB, ctx.userId),
|
||||
operationModel: new AgentOperationModel(ctx.serverDB, ctx.userId),
|
||||
planGenerator: new VerifyPlanGeneratorService(ctx.serverDB, ctx.userId),
|
||||
resultModel: new VerifyCheckResultModel(ctx.serverDB, ctx.userId),
|
||||
rubricModel: new VerifyRubricModel(ctx.serverDB, ctx.userId),
|
||||
criterionModel: new VerifyCriterionModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
executorService: new VerifyExecutorService(ctx.serverDB, ctx.userId, workspaceId),
|
||||
tracingModel: new LlmGenerationTracingModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
feedbackService: new VerifyFeedbackService(ctx.serverDB, ctx.userId, workspaceId),
|
||||
operationModel: new AgentOperationModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
planGenerator: new VerifyPlanGeneratorService(ctx.serverDB, ctx.userId, workspaceId),
|
||||
resultModel: new VerifyCheckResultModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
rubricModel: new VerifyRubricModel(ctx.serverDB, ctx.userId, workspaceId),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -231,6 +231,57 @@ describe('AgentService', () => {
|
||||
// Avatar should not be present for non-builtin agents
|
||||
expect((result as any)?.avatar).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT inherit the member personal default model for a workspace inbox', async () => {
|
||||
// Workspace inbox is persisted with an empty model/provider.
|
||||
const mockAgent = {
|
||||
id: 'agent-1',
|
||||
slug: 'inbox',
|
||||
};
|
||||
const serverDefaultConfig = { model: 'system-default-model', provider: 'system-provider' };
|
||||
|
||||
const mockAgentModel = {
|
||||
getBuiltinAgent: vi.fn().mockResolvedValue(mockAgent),
|
||||
};
|
||||
|
||||
(AgentModel as any).mockImplementation(() => mockAgentModel);
|
||||
(parseAgentConfig as any).mockReturnValue(serverDefaultConfig);
|
||||
// The member opening the workspace inbox has a personal default model.
|
||||
mockUserModel.getUserSettingsDefaultAgentConfig.mockResolvedValueOnce({
|
||||
config: { model: 'opus-4.6', provider: 'anthropic' },
|
||||
});
|
||||
|
||||
const workspaceService = new AgentService(mockDb, mockUserId, mockWorkspaceId);
|
||||
const result = await workspaceService.getBuiltinAgent('inbox');
|
||||
|
||||
// Should fall back to the system default, NOT the member's personal model.
|
||||
expect(result?.model).toBe('system-default-model');
|
||||
expect(result?.provider).toBe('system-provider');
|
||||
});
|
||||
|
||||
it('should still apply the personal default model for a personal inbox', async () => {
|
||||
const mockAgent = {
|
||||
id: 'agent-1',
|
||||
slug: 'inbox',
|
||||
};
|
||||
|
||||
const mockAgentModel = {
|
||||
getBuiltinAgent: vi.fn().mockResolvedValue(mockAgent),
|
||||
};
|
||||
|
||||
(AgentModel as any).mockImplementation(() => mockAgentModel);
|
||||
(parseAgentConfig as any).mockReturnValue({});
|
||||
mockUserModel.getUserSettingsDefaultAgentConfig.mockResolvedValueOnce({
|
||||
config: { model: 'user-preferred-model', provider: 'user-provider' },
|
||||
});
|
||||
|
||||
// No workspaceId → personal scope keeps the personal default behavior.
|
||||
const newService = new AgentService(mockDb, mockUserId);
|
||||
const result = await newService.getBuiltinAgent('inbox');
|
||||
|
||||
expect(result?.model).toBe('user-preferred-model');
|
||||
expect(result?.provider).toBe('user-provider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentConfig', () => {
|
||||
|
||||
@@ -174,6 +174,13 @@ export class AgentService {
|
||||
* 2. serverDefaultAgentConfig - from environment variable
|
||||
* 3. userDefaultAgentConfig - from user settings (defaultAgent.config)
|
||||
* 4. agent - actual agent config from database
|
||||
*
|
||||
* Workspace exception: a workspace is a shared resource, so its agents must
|
||||
* NOT inherit any individual member's *personal* default model. Otherwise a
|
||||
* shared agent persisted with an empty model (e.g. the workspace inbox)
|
||||
* resolves to whoever opens it — the creator's personal default leaks in and
|
||||
* the workspace looks "initialized" with their model. For workspace-scoped
|
||||
* reads we skip the user layer and fall back to the system default instead.
|
||||
*/
|
||||
private mergeDefaultConfig(
|
||||
agent: any,
|
||||
@@ -181,12 +188,17 @@ export class AgentService {
|
||||
): LobeAgentConfig | null {
|
||||
if (!agent) return null;
|
||||
|
||||
const userDefaultAgentConfig =
|
||||
(defaultAgentConfig as { config?: PartialDeep<LobeAgentConfig> })?.config || {};
|
||||
|
||||
// Merge configs in order: DEFAULT -> server -> user -> agent
|
||||
// Merge configs in order: DEFAULT -> server -> [user] -> agent
|
||||
const serverDefaultAgentConfig = getServerDefaultAgentConfig();
|
||||
const baseConfig = merge(DEFAULT_AGENT_CONFIG, serverDefaultAgentConfig);
|
||||
|
||||
// Skip the personal default layer for workspace-scoped agents (see above).
|
||||
if (this.workspaceId) {
|
||||
return merge(baseConfig, cleanObject(agent));
|
||||
}
|
||||
|
||||
const userDefaultAgentConfig =
|
||||
(defaultAgentConfig as { config?: PartialDeep<LobeAgentConfig> })?.config || {};
|
||||
const withUserConfig = merge(baseConfig, userDefaultAgentConfig);
|
||||
|
||||
return merge(withUserConfig, cleanObject(agent));
|
||||
|
||||
@@ -25,7 +25,12 @@ import {
|
||||
invokeAgentSpanName,
|
||||
tracer as agentRuntimeTracer,
|
||||
} from '@lobechat/observability-otel/modules/agent-runtime';
|
||||
import { type ChatToolPayload, type ExecSubAgentParams, type UIChatMessage } from '@lobechat/types';
|
||||
import {
|
||||
type ChatToolPayload,
|
||||
type ExecSubAgentParams,
|
||||
type ExecVirtualSubAgentParams,
|
||||
type UIChatMessage,
|
||||
} from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
@@ -126,13 +131,17 @@ const toAgentSignalSnapshotEvents = (
|
||||
*/
|
||||
export interface AgentRuntimeDelegate {
|
||||
/**
|
||||
* Fork a sub-agent through the full high-level pipeline
|
||||
* Run a legacy agent invocation through the full high-level pipeline
|
||||
* (AiAgentService.execSubAgent → execAgent: agent-config resolution, tool
|
||||
* engine, context engineering, createOperation). Returns a deferred result;
|
||||
* the parent op parks (`waiting_for_async_tool`) until the completion bridge
|
||||
* backfills the placeholder and resumes it.
|
||||
* engine, context engineering, createOperation).
|
||||
*/
|
||||
execSubAgent?: (params: ExecSubAgentParams) => Promise<unknown>;
|
||||
/**
|
||||
* Fork a `lobe-agent.callSubAgent` virtual child run. The child is marked as a
|
||||
* sub-agent and owns the completion bridge that backfills the parent tool
|
||||
* placeholder before resuming the parked parent operation.
|
||||
*/
|
||||
execVirtualSubAgent?: (params: ExecVirtualSubAgentParams) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface AgentRuntimeServiceOptions {
|
||||
@@ -1864,10 +1873,7 @@ export class AgentRuntimeService {
|
||||
if (!tool || typeof tool !== 'object') continue;
|
||||
|
||||
const toolPayload = tool as { id?: unknown; result_msg_id?: unknown };
|
||||
if (
|
||||
typeof toolPayload.id === 'string' &&
|
||||
typeof toolPayload.result_msg_id === 'string'
|
||||
) {
|
||||
if (typeof toolPayload.id === 'string' && typeof toolPayload.result_msg_id === 'string') {
|
||||
toolResultMessageIds.set(toolPayload.id, toolPayload.result_msg_id);
|
||||
}
|
||||
}
|
||||
@@ -1944,6 +1950,7 @@ export class AgentRuntimeService {
|
||||
userTimezone: metadata?.userTimezone,
|
||||
evalContext: metadata?.evalContext,
|
||||
execSubAgent: this.delegate.execSubAgent,
|
||||
execVirtualSubAgent: this.delegate.execVirtualSubAgent,
|
||||
hookDispatcher,
|
||||
loadAgentState: this.coordinator.loadAgentState.bind(this.coordinator),
|
||||
messageModel: this.messageModel,
|
||||
|
||||
@@ -344,11 +344,16 @@ export class CompletionLifecycle {
|
||||
metadata?.assistantMessageId,
|
||||
metadata?.userId || this.userId,
|
||||
);
|
||||
void runVerifyOnCompletion(this.serverDB, metadata?.userId || this.userId, {
|
||||
deliverable: event.lastAssistantContent ?? '',
|
||||
goal,
|
||||
operationId,
|
||||
});
|
||||
void runVerifyOnCompletion(
|
||||
this.serverDB,
|
||||
metadata?.userId || this.userId,
|
||||
{
|
||||
deliverable: event.lastAssistantContent ?? '',
|
||||
goal,
|
||||
operationId,
|
||||
},
|
||||
this.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
if (reason === 'error') {
|
||||
|
||||
+50
-3
@@ -21,6 +21,12 @@ vi.mock('@/database/models/thread', () => ({
|
||||
ThreadModel: vi.fn().mockImplementation(() => mockThreadModel),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/agentOperation', () => ({
|
||||
AgentOperationModel: vi.fn().mockImplementation(() => ({
|
||||
findById: vi.fn().mockResolvedValue({ trigger: 'cli' }),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock other models
|
||||
vi.mock('@/database/models/agent', () => ({
|
||||
AgentModel: vi.fn().mockImplementation(() => ({
|
||||
@@ -115,7 +121,7 @@ describe('AiAgentService.execSubAgent', () => {
|
||||
service = new AiAgentService(mockDb, userId);
|
||||
});
|
||||
|
||||
describe('successful task execution', () => {
|
||||
describe('successful isolated execution', () => {
|
||||
it('should create Thread with correct parameters', async () => {
|
||||
// Mock execAgent to return success
|
||||
vi.spyOn(service, 'execAgent').mockResolvedValue({
|
||||
@@ -208,6 +214,7 @@ describe('AiAgentService.execSubAgent', () => {
|
||||
agentId: 'agent-1',
|
||||
appContext: {
|
||||
groupId: 'group-1',
|
||||
isSubAgent: false,
|
||||
threadId: 'thread-123',
|
||||
topicId: 'topic-1',
|
||||
},
|
||||
@@ -223,6 +230,46 @@ describe('AiAgentService.execSubAgent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should run deferred lobe-agent children through execVirtualSubAgent', async () => {
|
||||
const execAgentSpy = vi.spyOn(service, 'execAgent').mockResolvedValue({
|
||||
agentId: 'agent-1',
|
||||
assistantMessageId: 'assistant-msg-1',
|
||||
autoStarted: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
message: 'Agent operation created successfully',
|
||||
messageId: 'queue-msg-1',
|
||||
operationId: 'op-123',
|
||||
status: 'created',
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
topicId: 'topic-1',
|
||||
userMessageId: 'user-msg-1',
|
||||
});
|
||||
|
||||
await service.execVirtualSubAgent({
|
||||
agentId: 'agent-1',
|
||||
instruction: 'Nested research task',
|
||||
parentMessageId: 'tool-msg-1',
|
||||
parentOperationId: 'parent-op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
expect(execAgentSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appContext: expect.objectContaining({
|
||||
isSubAgent: true,
|
||||
threadId: 'thread-123',
|
||||
topicId: 'topic-1',
|
||||
}),
|
||||
hooks: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'sub-agent-bridge', type: 'onComplete' }),
|
||||
]),
|
||||
parentOperationId: 'parent-op-1',
|
||||
trigger: 'cli',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should store operationId and startedAt in Thread metadata', async () => {
|
||||
vi.spyOn(service, 'execAgent').mockResolvedValue({
|
||||
agentId: 'agent-1',
|
||||
@@ -409,7 +456,7 @@ describe('AiAgentService.execSubAgent', () => {
|
||||
parentMessageId: 'parent-msg-1',
|
||||
topicId: 'topic-1',
|
||||
}),
|
||||
).rejects.toThrow('Failed to create thread for task execution');
|
||||
).rejects.toThrow('Failed to create thread for agent execution');
|
||||
});
|
||||
|
||||
it('should throw error when Thread creation throws', async () => {
|
||||
@@ -427,7 +474,7 @@ describe('AiAgentService.execSubAgent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('task message summary update', () => {
|
||||
describe('source message summary update', () => {
|
||||
it('should pass sourceMessageId (parentMessageId) to callbacks for summary update', async () => {
|
||||
const execAgentSpy = vi.spyOn(service, 'execAgent').mockResolvedValue({
|
||||
agentId: 'agent-1',
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
ExecGroupAgentResult,
|
||||
ExecSubAgentParams,
|
||||
ExecSubAgentResult,
|
||||
ExecVirtualSubAgentParams,
|
||||
LobeAgentAgencyConfig,
|
||||
MessagePluginItem,
|
||||
UserInterventionConfig,
|
||||
@@ -318,9 +319,10 @@ export class AiAgentService {
|
||||
// high-level pipelines mid-step. See AgentRuntimeDelegate. New high-level
|
||||
// capabilities the runtime calls into go in this `delegate` object.
|
||||
//
|
||||
// `execSubAgent` is an auto-bound arrow field, so no `.bind(this)`.
|
||||
// Arrow fields are auto-bound, so no `.bind(this)`.
|
||||
delegate: {
|
||||
execSubAgent: this.execSubAgent,
|
||||
execVirtualSubAgent: this.execVirtualSubAgent,
|
||||
},
|
||||
workspaceId: wsId,
|
||||
});
|
||||
@@ -415,9 +417,10 @@ export class AiAgentService {
|
||||
* Execute a single agent step against this service's runtime.
|
||||
*
|
||||
* Delegates to the internal AgentRuntimeService, which is already wired with
|
||||
* the `execSubAgent` fork callback. The QStash step worker drives stepping
|
||||
* through here so `lobe-agent.callSubAgent` can fork sub-agents — building a
|
||||
* bare runtime there would lose the callback and fail with SUB_AGENT_UNAVAILABLE.
|
||||
* the agent-invocation fork callbacks. The QStash step worker drives stepping
|
||||
* through here so `lobe-agent.callSubAgent` can fork virtual sub-agents —
|
||||
* building a bare runtime there would lose the callback and fail with
|
||||
* SUB_AGENT_UNAVAILABLE.
|
||||
*/
|
||||
executeStep(params: AgentExecutionParams): Promise<AgentExecutionResult> {
|
||||
return this.agentRuntimeService.executeStep(params);
|
||||
@@ -2296,7 +2299,7 @@ export class AiAgentService {
|
||||
: undefined;
|
||||
|
||||
// 13. Create user message in database
|
||||
// Include threadId if provided (for SubAgent task execution in isolated Thread)
|
||||
// Include threadId if provided (for isolated agent execution)
|
||||
const userMessageRecord = runFromHistory
|
||||
? undefined
|
||||
: await this.messageModel.create({
|
||||
@@ -2344,7 +2347,7 @@ export class AiAgentService {
|
||||
}
|
||||
|
||||
// 14. Create assistant message placeholder in database
|
||||
// Include threadId if provided (for SubAgent task execution in isolated Thread)
|
||||
// Include threadId if provided (for isolated agent execution)
|
||||
const assistantMessageRecord = await this.messageModel.create({
|
||||
agentId: persistAgentId,
|
||||
content: LOADING_FLAT,
|
||||
@@ -2856,35 +2859,46 @@ export class AiAgentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute SubAgent task (supports both Group and Single Agent mode)
|
||||
* Execute an agent in an isolated Thread context.
|
||||
*
|
||||
* This method is called by Supervisor (Group mode) or Agent (Single mode)
|
||||
* to delegate tasks to SubAgents. Each task runs in an isolated Thread context.
|
||||
*
|
||||
* - Group mode: pass groupId, Thread will be associated with the Group
|
||||
* - Single Agent mode: omit groupId, Thread will only be associated with the Agent
|
||||
*
|
||||
* Flow:
|
||||
* 1. Create Thread (type='isolation', status='processing')
|
||||
* 2. Delegate to execAgent with threadId in appContext
|
||||
* 3. Store operationId in Thread metadata
|
||||
* Group/callAgent paths use this entry. It does not mark the child as a
|
||||
* virtual sub-agent and it does not install the async completion bridge.
|
||||
*/
|
||||
// Arrow field (not a method) so it stays bound to this instance when handed to
|
||||
// AgentRuntimeService as the `execSubAgent` fork callback — no `.bind(this)`.
|
||||
execSubAgent = async (params: ExecSubAgentParams): Promise<ExecSubAgentResult> => {
|
||||
const {
|
||||
groupId,
|
||||
topicId,
|
||||
parentMessageId,
|
||||
agentId,
|
||||
instruction,
|
||||
title,
|
||||
parentOperationId,
|
||||
resumeParentOnComplete,
|
||||
} = params;
|
||||
// Arrow field (not a method) so it stays bound when handed to AgentRuntimeService.
|
||||
execSubAgent = async (params: ExecSubAgentParams): Promise<ExecSubAgentResult> =>
|
||||
this.execAgentThreadRun(params, {
|
||||
isSubAgent: false,
|
||||
logScope: 'execSubAgent',
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute a virtual sub-agent created by `lobe-agent.callSubAgent`.
|
||||
*
|
||||
* This path is a child operation of the current agent run. It is marked as a
|
||||
* sub-agent so it cannot recursively spawn more sub-agents, and it registers
|
||||
* the bridge that backfills the parent's placeholder tool message.
|
||||
*/
|
||||
execVirtualSubAgent = async (params: ExecVirtualSubAgentParams): Promise<ExecSubAgentResult> =>
|
||||
this.execAgentThreadRun(params, {
|
||||
isSubAgent: true,
|
||||
logScope: 'execVirtualSubAgent',
|
||||
resumeParentOnComplete: true,
|
||||
});
|
||||
|
||||
private async execAgentThreadRun(
|
||||
params: ExecSubAgentParams | ExecVirtualSubAgentParams,
|
||||
options: {
|
||||
isSubAgent: boolean;
|
||||
logScope: 'execSubAgent' | 'execVirtualSubAgent';
|
||||
resumeParentOnComplete?: boolean;
|
||||
},
|
||||
): Promise<ExecSubAgentResult> {
|
||||
const { groupId, topicId, parentMessageId, agentId, instruction, title, parentOperationId } =
|
||||
params;
|
||||
|
||||
log(
|
||||
'execSubAgent: agentId=%s, groupId=%s, topicId=%s, instruction=%s',
|
||||
'%s: agentId=%s, groupId=%s, topicId=%s, instruction=%s',
|
||||
options.logScope,
|
||||
agentId,
|
||||
groupId,
|
||||
topicId,
|
||||
@@ -2903,7 +2917,7 @@ export class AiAgentService {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 1. Create Thread for isolated task execution
|
||||
// 1. Create Thread for isolated agent execution
|
||||
const thread = await this.threadModel.create({
|
||||
agentId,
|
||||
groupId,
|
||||
@@ -2914,10 +2928,10 @@ export class AiAgentService {
|
||||
});
|
||||
|
||||
if (!thread) {
|
||||
throw new Error('Failed to create thread for task execution');
|
||||
throw new Error('Failed to create thread for agent execution');
|
||||
}
|
||||
|
||||
log('execSubAgent: created thread %s', thread.id);
|
||||
log('%s: created thread %s', options.logScope, thread.id);
|
||||
|
||||
// 2. Update Thread status to processing with startedAt timestamp
|
||||
const startedAt = new Date().toISOString();
|
||||
@@ -2926,14 +2940,19 @@ export class AiAgentService {
|
||||
status: ThreadStatus.Processing,
|
||||
});
|
||||
|
||||
// 3. Create hooks for updating Thread metadata and task message
|
||||
const threadHooks = this.createThreadHooks(thread.id, startedAt, parentMessageId);
|
||||
// For the deferred-tool path, also register the completion bridge that
|
||||
// 3. Create hooks for updating Thread metadata and source message
|
||||
const threadHooks = this.createThreadHooks(
|
||||
thread.id,
|
||||
startedAt,
|
||||
parentMessageId,
|
||||
options.logScope,
|
||||
);
|
||||
// For the virtual sub-agent path, also register the completion bridge that
|
||||
// backfills the parent's placeholder tool message and resumes the parked
|
||||
// parent op once the whole batch is done. Registered last so its
|
||||
// tool-message backfill (content + pluginState) is the final write.
|
||||
// parent op once the child run is done. Registered last so its tool-message
|
||||
// backfill (content + pluginState) is the final write.
|
||||
const hooks =
|
||||
resumeParentOnComplete && parentOperationId
|
||||
options.resumeParentOnComplete && parentOperationId
|
||||
? [
|
||||
...threadHooks,
|
||||
this.createSubAgentBridgeHook(parentOperationId, parentMessageId, thread.id),
|
||||
@@ -2953,16 +2972,23 @@ export class AiAgentService {
|
||||
).findById(parentOperationId);
|
||||
inheritedTrigger = parentOp?.trigger ?? undefined;
|
||||
} catch (error) {
|
||||
log('execSubAgent: failed to read parent operation trigger: %O', error);
|
||||
log('%s: failed to read parent operation trigger: %O', options.logScope, error);
|
||||
}
|
||||
}
|
||||
|
||||
const appContext: NonNullable<InternalExecAgentParams['appContext']> = {
|
||||
groupId,
|
||||
isSubAgent: options.isSubAgent,
|
||||
threadId: thread.id,
|
||||
topicId,
|
||||
};
|
||||
|
||||
// 4. Delegate to execAgent with threadId in appContext and hooks
|
||||
// The instruction will be created as user message in the Thread
|
||||
// Use headless mode to skip human approval in async task execution
|
||||
// Use headless mode to skip human approval in async agent execution
|
||||
const result = await this.execAgent({
|
||||
agentId,
|
||||
appContext: { groupId, threadId: thread.id, topicId },
|
||||
appContext,
|
||||
autoStart: true,
|
||||
hooks,
|
||||
parentOperationId,
|
||||
@@ -2972,7 +2998,8 @@ export class AiAgentService {
|
||||
});
|
||||
|
||||
log(
|
||||
'execSubAgent: delegated to execAgent, operationId=%s, success=%s',
|
||||
'%s: delegated to execAgent, operationId=%s, success=%s',
|
||||
options.logScope,
|
||||
result.operationId,
|
||||
result.success,
|
||||
);
|
||||
@@ -3028,7 +3055,7 @@ export class AiAgentService {
|
||||
success: result.success ?? false,
|
||||
threadId: thread.id,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create step lifecycle callbacks for updating Thread metadata
|
||||
@@ -3036,12 +3063,13 @@ export class AiAgentService {
|
||||
*
|
||||
* @param threadId - The Thread ID to update
|
||||
* @param startedAt - The start time ISO string
|
||||
* @param sourceMessageId - The task message ID (sourceMessageId from Thread) to update with summary
|
||||
* @param sourceMessageId - The source message ID from Thread to update with summary
|
||||
*/
|
||||
private createThreadMetadataCallbacks(
|
||||
threadId: string,
|
||||
startedAt: string,
|
||||
sourceMessageId: string,
|
||||
logScope: 'execSubAgent' | 'execVirtualSubAgent' = 'execSubAgent',
|
||||
): StepLifecycleCallbacks {
|
||||
// Accumulator for tracking metrics across steps
|
||||
let accumulatedToolCalls = 0;
|
||||
@@ -3067,9 +3095,9 @@ export class AiAgentService {
|
||||
totalToolCalls: accumulatedToolCalls,
|
||||
},
|
||||
});
|
||||
log('execSubAgent: updated thread %s metadata after step %d', threadId, state.stepCount);
|
||||
log('%s: updated thread %s metadata after step %d', logScope, threadId, state.stepCount);
|
||||
} catch (error) {
|
||||
log('execSubAgent: failed to update thread metadata: %O', error);
|
||||
log('%s: failed to update thread metadata: %O', logScope, error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3101,13 +3129,13 @@ export class AiAgentService {
|
||||
}
|
||||
}
|
||||
|
||||
// Log error when task fails
|
||||
// Log error when the isolated run fails
|
||||
if (reason === 'error' && finalState.error) {
|
||||
console.error('execSubAgent: task failed for thread %s:', threadId, finalState.error);
|
||||
console.error('%s: run failed for thread %s:', logScope, threadId, finalState.error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract summary from last assistant message and update task message content
|
||||
// Extract summary from last assistant message and update source message content
|
||||
const lastAssistantMessage = finalState.messages
|
||||
?.slice()
|
||||
.reverse()
|
||||
@@ -3117,7 +3145,7 @@ export class AiAgentService {
|
||||
await this.messageModel.update(sourceMessageId, {
|
||||
content: lastAssistantMessage.content,
|
||||
});
|
||||
log('execSubAgent: updated task message %s with summary', sourceMessageId);
|
||||
log('%s: updated source message %s with summary', logScope, sourceMessageId);
|
||||
}
|
||||
|
||||
// Format error for proper serialization (Error objects don't serialize with JSON.stringify)
|
||||
@@ -3140,13 +3168,14 @@ export class AiAgentService {
|
||||
});
|
||||
|
||||
log(
|
||||
'execSubAgent: thread %s completed with status %s, reason: %s',
|
||||
'%s: thread %s completed with status %s, reason: %s',
|
||||
logScope,
|
||||
threadId,
|
||||
status,
|
||||
reason,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('execSubAgent: failed to update thread on completion: %O', error);
|
||||
console.error('%s: failed to update thread on completion: %O', logScope, error);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -3160,6 +3189,7 @@ export class AiAgentService {
|
||||
threadId: string,
|
||||
startedAt: string,
|
||||
sourceMessageId: string,
|
||||
logScope: 'execSubAgent' | 'execVirtualSubAgent',
|
||||
): AgentHook[] {
|
||||
let accumulatedToolCalls = 0;
|
||||
|
||||
@@ -3186,7 +3216,7 @@ export class AiAgentService {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log('Thread hook afterStep: failed to update metadata: %O', error);
|
||||
log('%s: thread hook afterStep failed to update metadata: %O', logScope, error);
|
||||
}
|
||||
},
|
||||
id: 'thread-metadata-update',
|
||||
@@ -3226,14 +3256,15 @@ export class AiAgentService {
|
||||
|
||||
if (event.reason === 'error' && finalState.error) {
|
||||
console.error(
|
||||
'Thread hook onComplete: task failed for thread %s:',
|
||||
'%s: thread hook onComplete run failed for thread %s:',
|
||||
logScope,
|
||||
threadId,
|
||||
finalState.error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Update task message with summary
|
||||
// Update source message with summary
|
||||
const lastAssistantMessage = finalState.messages
|
||||
?.slice()
|
||||
.reverse()
|
||||
@@ -3263,13 +3294,14 @@ export class AiAgentService {
|
||||
});
|
||||
|
||||
log(
|
||||
'Thread hook onComplete: thread %s status=%s reason=%s',
|
||||
'%s: thread hook onComplete thread %s status=%s reason=%s',
|
||||
logScope,
|
||||
threadId,
|
||||
status,
|
||||
event.reason,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Thread hook onComplete: failed to update: %O', error);
|
||||
console.error('%s: thread hook onComplete failed to update: %O', logScope, error);
|
||||
}
|
||||
},
|
||||
id: 'thread-completion',
|
||||
|
||||
@@ -990,6 +990,7 @@ export class BotMessageRouter {
|
||||
agentId,
|
||||
db: serverDB,
|
||||
userId,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
},
|
||||
{ ignoreError: true },
|
||||
);
|
||||
@@ -1175,6 +1176,7 @@ export class BotMessageRouter {
|
||||
agentId,
|
||||
db: serverDB,
|
||||
userId,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
},
|
||||
{ ignoreError: true },
|
||||
);
|
||||
@@ -1392,6 +1394,7 @@ export class BotMessageRouter {
|
||||
agentId,
|
||||
db: serverDB,
|
||||
userId,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
},
|
||||
{ ignoreError: true },
|
||||
);
|
||||
|
||||
@@ -718,7 +718,37 @@ describe('DeviceGateway', () => {
|
||||
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
|
||||
{
|
||||
method: 'getLocalFilePreview',
|
||||
params: { path: '/proj/App.tsx', workingDirectory: '/proj' },
|
||||
params: { accept: undefined, path: '/proj/App.tsx', workingDirectory: '/proj' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards image-only preview constraints to the device rpc', async () => {
|
||||
configure();
|
||||
const data = {
|
||||
preview: {
|
||||
base64: 'aW1hZ2U=',
|
||||
contentType: 'image/png',
|
||||
type: 'image',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockClient.invokeRpc.mockResolvedValue({ data, success: true });
|
||||
|
||||
const proxy = new DeviceGateway();
|
||||
await proxy.getLocalFilePreview({
|
||||
accept: 'image',
|
||||
deviceId: 'dev-1',
|
||||
path: '/proj/image.png',
|
||||
userId: 'user-1',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
|
||||
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
|
||||
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
|
||||
{
|
||||
method: 'getLocalFilePreview',
|
||||
params: { accept: 'image', path: '/proj/image.png', workingDirectory: '/proj' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,9 +14,11 @@ import type {
|
||||
DeviceGitBranchInfo,
|
||||
DeviceGitBranchListItem,
|
||||
DeviceGitCheckoutResult,
|
||||
DeviceGitDeleteBranchResult,
|
||||
DeviceGitFileRevertResult,
|
||||
DeviceGitLinkedPullRequestResult,
|
||||
DeviceGitRemoteBranchListItem,
|
||||
DeviceGitRenameBranchResult,
|
||||
DeviceGitSyncResult,
|
||||
DeviceGitWorkingTreeFiles,
|
||||
DeviceGitWorkingTreePatches,
|
||||
@@ -272,6 +274,73 @@ export class DeviceGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a branch in a directory on a remote device via the `renameGitBranch`
|
||||
* device RPC.
|
||||
*/
|
||||
async renameGitBranch(params: {
|
||||
deviceId: string;
|
||||
from: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
to: string;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitRenameBranchResult> {
|
||||
const { userId, deviceId, from, to, path, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitRenameBranchResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'renameGitBranch', params: { from, path, to } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('renameGitBranch: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return { error: result.error || 'Rename failed', success: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('renameGitBranch: error for deviceId=%s — %O', deviceId, error);
|
||||
return { error: (error as Error)?.message || 'Rename failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a branch in a directory on a remote device via the `deleteGitBranch`
|
||||
* device RPC.
|
||||
*/
|
||||
async deleteGitBranch(params: {
|
||||
branch: string;
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitDeleteBranchResult> {
|
||||
const { userId, deviceId, branch, path, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitDeleteBranchResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'deleteGitBranch', params: { branch, path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('deleteGitBranch: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return { error: result.error || 'Delete failed', success: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('deleteGitBranch: error for deviceId=%s — %O', deviceId, error);
|
||||
return { error: (error as Error)?.message || 'Delete failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull (`--ff-only`) the current branch of a directory on a remote device via
|
||||
* the `pullGitBranch` device RPC.
|
||||
@@ -473,20 +542,24 @@ export class DeviceGateway {
|
||||
* exposing a `localfile://` URL to web callers.
|
||||
*/
|
||||
async getLocalFilePreview(params: {
|
||||
accept?: 'image';
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<DeviceLocalFilePreviewResult> {
|
||||
const { userId, deviceId, path, workingDirectory, timeout = 30_000 } = params;
|
||||
const { accept, userId, deviceId, path, workingDirectory, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceLocalFilePreviewResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'getLocalFilePreview', params: { path, workingDirectory } },
|
||||
{
|
||||
method: 'getLocalFilePreview',
|
||||
params: { accept, path, workingDirectory },
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
@@ -665,7 +738,7 @@ export class DeviceGateway {
|
||||
}
|
||||
|
||||
async executeToolCall(
|
||||
params: { deviceId: string; userId: string },
|
||||
params: { deviceId: string; operationId?: string; userId: string },
|
||||
toolCall: { apiName: string; arguments: string; identifier: string },
|
||||
timeout = 30_000,
|
||||
): Promise<DeviceToolCallResult> {
|
||||
@@ -679,7 +752,8 @@ export class DeviceGateway {
|
||||
}
|
||||
|
||||
log(
|
||||
'executeToolCall: userId=%s, deviceId=%s, tool=%s/%s',
|
||||
'executeToolCall: operationId=%s, userId=%s, deviceId=%s, tool=%s/%s',
|
||||
params.operationId ?? 'N/A',
|
||||
params.userId,
|
||||
params.deviceId,
|
||||
toolCall.identifier,
|
||||
@@ -688,7 +762,12 @@ export class DeviceGateway {
|
||||
|
||||
try {
|
||||
return await client.executeToolCall(
|
||||
{ deviceId: params.deviceId, timeout, userId: params.userId },
|
||||
{
|
||||
deviceId: params.deviceId,
|
||||
operationId: params.operationId,
|
||||
timeout,
|
||||
userId: params.userId,
|
||||
},
|
||||
toolCall,
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { documentHistories, documents, files, users } from '@lobechat/database/s
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { DOCUMENT_HISTORY_SOURCE_LIMITS } from '@/const/documentHistory';
|
||||
import {
|
||||
DOCUMENT_HISTORY_AUTOSAVE_WINDOW_MS,
|
||||
DOCUMENT_HISTORY_SOURCE_LIMITS,
|
||||
} from '@/const/documentHistory';
|
||||
import { getTestDB } from '@/database/core/getTestDB';
|
||||
import { DocumentModel } from '@/database/models/document';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
@@ -420,7 +423,7 @@ describe('DocumentHistoryService', () => {
|
||||
documentId: doc.id,
|
||||
editorData: { v: i },
|
||||
saveSource: 'autosave',
|
||||
savedAt: new Date(2026, 3, 1, 0, i, 0),
|
||||
savedAt: new Date(2026, 3, 1, 0, i * 10, 0),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -463,6 +466,182 @@ describe('DocumentHistoryService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('autosave window coalescing', () => {
|
||||
const base = new Date('2026-04-01T10:00:00Z');
|
||||
const minutes = (n: number) => new Date(base.getTime() + n * 60 * 1000);
|
||||
|
||||
const listRows = (documentId: string) =>
|
||||
serverDB
|
||||
.select()
|
||||
.from(documentHistories)
|
||||
.where(eq(documentHistories.documentId, documentId))
|
||||
.orderBy(desc(documentHistories.savedAt), desc(documentHistories.id));
|
||||
|
||||
it('should overwrite the latest autosave row within the window', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 1 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: base,
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 2 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: minutes(5),
|
||||
});
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].editorData).toEqual({ v: 2 });
|
||||
expect(rows[0].savedAt).toEqual(minutes(5));
|
||||
});
|
||||
|
||||
it('should insert a new row once the save falls into the next window bucket', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
const windowMinutes = DOCUMENT_HISTORY_AUTOSAVE_WINDOW_MS / 60_000;
|
||||
|
||||
for (const [i, at] of [
|
||||
base,
|
||||
minutes(5),
|
||||
minutes(windowMinutes),
|
||||
minutes(windowMinutes + 5),
|
||||
].entries()) {
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: i + 1 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: at,
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].editorData).toEqual({ v: 4 });
|
||||
expect(rows[0].savedAt).toEqual(minutes(windowMinutes + 5));
|
||||
expect(rows[1].editorData).toEqual({ v: 2 });
|
||||
expect(rows[1].savedAt).toEqual(minutes(5));
|
||||
});
|
||||
|
||||
it('should start a new window when a non-autosave version is the latest', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 1 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: base,
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 2 },
|
||||
saveSource: 'manual',
|
||||
savedAt: minutes(1),
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 3 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: minutes(2),
|
||||
});
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.saveSource)).toEqual(['autosave', 'manual', 'autosave']);
|
||||
});
|
||||
|
||||
it('should never coalesce manual saves', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 1 },
|
||||
saveSource: 'manual',
|
||||
savedAt: base,
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 2 },
|
||||
saveSource: 'manual',
|
||||
savedAt: minutes(1),
|
||||
});
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should insert a new row within the same window when breakAutosaveWindow is true', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 1 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: base,
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
breakAutosaveWindow: true,
|
||||
documentId: doc.id,
|
||||
editorData: { v: 2 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: minutes(3),
|
||||
});
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].editorData).toEqual({ v: 2 });
|
||||
expect(rows[0].savedAt).toEqual(minutes(3));
|
||||
expect(rows[1].editorData).toEqual({ v: 1 });
|
||||
expect(rows[1].savedAt).toEqual(base);
|
||||
});
|
||||
|
||||
it('should coalesce into the break row on the next autosave without the flag', async () => {
|
||||
const doc = await createTestDocument('Hello');
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 1 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: base,
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
breakAutosaveWindow: true,
|
||||
documentId: doc.id,
|
||||
editorData: { v: 2 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: minutes(3),
|
||||
});
|
||||
|
||||
await historyService.createHistory({
|
||||
documentId: doc.id,
|
||||
editorData: { v: 3 },
|
||||
saveSource: 'autosave',
|
||||
savedAt: minutes(5),
|
||||
});
|
||||
|
||||
const rows = await listRows(doc.id);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].editorData).toEqual({ v: 3 });
|
||||
expect(rows[0].savedAt).toEqual(minutes(5));
|
||||
expect(rows[1].editorData).toEqual({ v: 1 });
|
||||
expect(rows[1].savedAt).toEqual(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDocumentHistoryItem', () => {
|
||||
it('should resolve head as current document state', async () => {
|
||||
const editorData = createValidEditorData('Head content');
|
||||
|
||||
@@ -5,14 +5,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocumentModel } from '@/database/models/document';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
|
||||
import { EditLockService } from '../../editLock';
|
||||
import { FileService } from '../../file';
|
||||
import { publishResourceEvent } from '../../resourceEvents';
|
||||
import { DocumentHistoryService } from '../history';
|
||||
import { DocumentService } from '../index';
|
||||
|
||||
vi.mock('@/server/modules/AgentRuntime/redis', () => ({ getAgentRuntimeRedisClient: () => null }));
|
||||
vi.mock('@/database/models/document');
|
||||
vi.mock('@/database/models/file');
|
||||
vi.mock('../../file');
|
||||
vi.mock('../history');
|
||||
// Spy on the realtime broadcast so we can assert lock.changed is published only
|
||||
// on a genuine state change (holder edge / actual release).
|
||||
vi.mock('../../resourceEvents', () => ({ publishResourceEvent: vi.fn() }));
|
||||
|
||||
const publishResourceEventMock = vi.mocked(publishResourceEvent);
|
||||
vi.mock('@lobechat/file-loaders', () => ({
|
||||
loadFile: vi.fn(),
|
||||
UnsupportedFileTypeError: class UnsupportedFileTypeError extends Error {
|
||||
@@ -794,6 +802,168 @@ describe('DocumentService', () => {
|
||||
'Document not found: missing-doc',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a workspace save when another member holds the edit lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue(createCurrentDocument({ workspaceId: 'ws-1' }));
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
await expect(wsService.updateDocument('doc-1', { content: 'x' })).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
});
|
||||
expect(mockDocumentModel.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow a workspace save when no other member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.update.mockResolvedValue({ id: 'doc-1' });
|
||||
mockDocumentModel.findById.mockResolvedValue(createCurrentDocument({ workspaceId: 'ws-1' }));
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
|
||||
await wsService.updateDocument('doc-1', { content: 'x' });
|
||||
|
||||
expect(mockDocumentModel.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a metadata-only save while another member holds the lock (only the body is locked)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.update.mockResolvedValue({ id: 'doc-1' });
|
||||
// Current body matches what the autosave re-sends — only title changes.
|
||||
mockDocumentModel.findById.mockResolvedValue(
|
||||
createCurrentDocument({ content: 'body', editorData: { blocks: [] }, workspaceId: 'ws-1' }),
|
||||
);
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
|
||||
await wsService.updateDocument('doc-1', {
|
||||
content: 'body',
|
||||
editorData: { blocks: [] },
|
||||
title: 'New Title',
|
||||
});
|
||||
|
||||
// Content unchanged → the lock guard never runs and the meta save lands.
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(mockDocumentModel.update).toHaveBeenCalledWith(
|
||||
'doc-1',
|
||||
expect.objectContaining({ title: 'New Title' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a body change while locked even when the content string is unchanged', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue(
|
||||
createCurrentDocument({ editorData: { blocks: [] }, workspaceId: 'ws-1' }),
|
||||
);
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
// editorData changed (historyAppended) → guard runs even with no `content`.
|
||||
await expect(
|
||||
wsService.updateDocument('doc-1', { editorData: { blocks: [{ type: 'paragraph' }] } }),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(mockDocumentModel.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('document edit lock', () => {
|
||||
it('reports unlocked for personal documents without touching the lock service', async () => {
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
|
||||
|
||||
const result = await service.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
expect(acquireSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates to the edit lock service in workspace mode', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
const acquireSpy = vi
|
||||
.spyOn(EditLockService.prototype, 'acquire')
|
||||
.mockResolvedValue({ expiresAt, holderId: userId, lockedByOther: false });
|
||||
|
||||
const result = await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(acquireSpy).toHaveBeenCalledWith('document', 'doc-1');
|
||||
expect(result).toEqual({ expiresAt, holderId: userId, lockedByOther: false });
|
||||
});
|
||||
|
||||
it('reports another member as holder when the lock is taken', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt,
|
||||
holderId: 'other-user',
|
||||
lockedByOther: true,
|
||||
});
|
||||
|
||||
const result = await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(result).toEqual({ expiresAt, holderId: 'other-user', lockedByOther: true });
|
||||
});
|
||||
|
||||
it('releaseDocumentLock is a no-op for personal documents', async () => {
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release');
|
||||
await service.releaseDocumentLock('doc-1');
|
||||
expect(releaseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releaseDocumentLock delegates to the lock service in workspace mode', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
await wsService.releaseDocumentLock('doc-1');
|
||||
expect(releaseSpy).toHaveBeenCalledWith('document', 'doc-1');
|
||||
});
|
||||
|
||||
it('acquireDocumentLock broadcasts lock.changed on a holder edge (first claim)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('acquireDocumentLock does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releaseDocumentLock broadcasts unlocked only when it actually freed the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
|
||||
await wsService.releaseDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('releaseDocumentLock does NOT broadcast when the lease expired / was taken over', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false);
|
||||
|
||||
await wsService.releaseDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveDocumentHistory', () => {
|
||||
@@ -837,6 +1007,37 @@ describe('DocumentService', () => {
|
||||
);
|
||||
expect(mockDocumentHistoryService.createHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not check the lock for personal documents', async () => {
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
|
||||
await service.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call');
|
||||
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(mockDocumentHistoryService.createHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a workspace history snapshot when another member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
await expect(
|
||||
wsService.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call'),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(mockDocumentHistoryService.createHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a workspace history snapshot when no other member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
|
||||
await wsService.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call');
|
||||
|
||||
expect(mockDocumentHistoryService.createHistory).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trySaveCurrentDocumentHistory', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { documentHistories, documents } from '@lobechat/database/schemas';
|
||||
import { and, desc, eq, gte, inArray, lt, or } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
DOCUMENT_HISTORY_AUTOSAVE_WINDOW_MS,
|
||||
DOCUMENT_HISTORY_QUERY_LIST_LIMIT,
|
||||
DOCUMENT_HISTORY_SOURCE_LIMITS,
|
||||
} from '@/const/documentHistory';
|
||||
@@ -46,6 +47,7 @@ export class DocumentHistoryService {
|
||||
buildWorkspaceWhere({ userId: this.userId, workspaceId: this.workspaceId }, documentHistories);
|
||||
|
||||
createHistory = async (params: {
|
||||
breakAutosaveWindow?: boolean;
|
||||
documentId: string;
|
||||
editorData: Record<string, any>;
|
||||
saveSource: DocumentHistorySaveSource;
|
||||
@@ -61,6 +63,32 @@ export class DocumentHistoryService {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
// Autosave versions coalesce into fixed 10-min windows (Notion-like),
|
||||
// bucketed on the clock grid so the anchor stays immutable even though the
|
||||
// overwritten row's savedAt keeps moving — a sliding anchor would collapse
|
||||
// an entire continuous editing session into a single version.
|
||||
// Any non-autosave version in between closes the window.
|
||||
if (params.saveSource === 'autosave' && !params.breakAutosaveWindow) {
|
||||
const latest = await this.db.query.documentHistories.findFirst({
|
||||
orderBy: [desc(documentHistories.savedAt), desc(documentHistories.id)],
|
||||
where: and(eq(documentHistories.documentId, params.documentId), this.historiesOwnership()),
|
||||
});
|
||||
|
||||
const withinWindow =
|
||||
latest?.saveSource === 'autosave' &&
|
||||
Math.floor(latest.savedAt.getTime() / DOCUMENT_HISTORY_AUTOSAVE_WINDOW_MS) ===
|
||||
Math.floor(params.savedAt.getTime() / DOCUMENT_HISTORY_AUTOSAVE_WINDOW_MS);
|
||||
|
||||
if (withinWindow) {
|
||||
await this.db
|
||||
.update(documentHistories)
|
||||
.set({ editorData: params.editorData, savedAt: params.savedAt })
|
||||
.where(and(eq(documentHistories.id, latest.id), this.historiesOwnership()));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.db.insert(documentHistories).values({
|
||||
documentId: params.documentId,
|
||||
editorData: params.editorData,
|
||||
@@ -185,6 +213,7 @@ export class DocumentHistoryService {
|
||||
isCurrent: true,
|
||||
saveSource: 'system',
|
||||
savedAt: headDocument.updatedAt,
|
||||
userId: headDocument.userId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -193,6 +222,7 @@ export class DocumentHistoryService {
|
||||
isCurrent: false,
|
||||
saveSource: row.saveSource as DocumentHistorySaveSource,
|
||||
savedAt: row.savedAt,
|
||||
userId: row.userId,
|
||||
}));
|
||||
|
||||
// If head consumed a slot and we fetched a full page of history rows,
|
||||
|
||||
@@ -15,13 +15,16 @@ import { isValidEditorData } from '@/libs/editor/isValidEditorData';
|
||||
import { normalizeEditorDataDiffNodes } from '@/libs/editor/normalizeDiffNodes';
|
||||
import { type LobeDocument } from '@/types/document';
|
||||
|
||||
import { EditLockService } from '../editLock';
|
||||
import { FileService } from '../file';
|
||||
import { publishResourceEvent } from '../resourceEvents';
|
||||
import { DocumentHistoryService } from './history';
|
||||
import type {
|
||||
CompareDocumentHistoryItemsParams,
|
||||
CompareDocumentHistoryItemsResult,
|
||||
DocumentHistoryAccessOptions,
|
||||
DocumentHistorySaveSource,
|
||||
DocumentLockResult,
|
||||
GetDocumentHistoryItemParams,
|
||||
ListDocumentHistoryParams,
|
||||
ListDocumentHistoryResult,
|
||||
@@ -50,6 +53,7 @@ export class DocumentService {
|
||||
private documentModel: DocumentModel;
|
||||
private documentHistoryServiceInstance?: DocumentHistoryService;
|
||||
private fileServiceInstance?: FileService;
|
||||
private editLockService: EditLockService;
|
||||
private db: LobeChatDatabase;
|
||||
|
||||
private workspaceId?: string;
|
||||
@@ -60,6 +64,7 @@ export class DocumentService {
|
||||
this.workspaceId = workspaceId;
|
||||
this.fileModel = new FileModel(db, userId, workspaceId);
|
||||
this.documentModel = new DocumentModel(db, userId, workspaceId);
|
||||
this.editLockService = new EditLockService(userId);
|
||||
}
|
||||
|
||||
private get fileService() {
|
||||
@@ -202,6 +207,63 @@ export class DocumentService {
|
||||
return this.documentModel.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire (or refresh) the collaborative edit lock for a workspace document.
|
||||
*
|
||||
* Doubles as the heartbeat: an active editor calls this on an interval to keep
|
||||
* the lease alive, and a locked-out member calls it to take the lock over once
|
||||
* it frees up. Locking only applies in workspace context — personal documents
|
||||
* always report as unlocked.
|
||||
*/
|
||||
async acquireDocumentLock(id: string): Promise<DocumentLockResult> {
|
||||
if (!this.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
|
||||
const prevHolder = await this.editLockService.getActiveHolder('document', id);
|
||||
const result = await this.editLockService.acquire('document', id);
|
||||
|
||||
// Broadcast only on a holder edge (first claim / takeover). This method also
|
||||
// serves the periodic heartbeat, so a steady-state refresh (same holder)
|
||||
// must not emit an event.
|
||||
if ((result.holderId ?? null) !== (prevHolder ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id, type: 'document' },
|
||||
{ actorId: this.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only peek of the current edit lock (does not acquire). Lets a client
|
||||
* render a workspace page read-only on open when another member holds it.
|
||||
*/
|
||||
async getDocumentLock(id: string): Promise<DocumentLockResult> {
|
||||
if (!this.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const holder = await this.editLockService.getActiveHolder('document', id);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== this.userId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the edit lock if the current user holds it. No-op in personal mode.
|
||||
*/
|
||||
async releaseDocumentLock(id: string): Promise<void> {
|
||||
if (!this.workspaceId) return;
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease had expired and another member took over, the lock is still held and
|
||||
// a bogus holderId:null would wrongly flip their viewers to editable.
|
||||
const released = await this.editLockService.release('document', id);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id, type: 'document' },
|
||||
{ actorId: this.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
|
||||
async listDocumentHistory(
|
||||
params: ListDocumentHistoryParams,
|
||||
options?: DocumentHistoryAccessOptions,
|
||||
@@ -236,6 +298,21 @@ export class DocumentService {
|
||||
throw new Error(`Document not found: ${documentId}`);
|
||||
}
|
||||
|
||||
// Same collaborative edit-lock guard as updateDocument: don't record a
|
||||
// history snapshot for a workspace document another member is editing, so a
|
||||
// locked-out actor (e.g. a Copilot mutation that will itself be rejected)
|
||||
// can't pollute the version timeline.
|
||||
if (this.workspaceId) {
|
||||
const blockedBy = await this.editLockService.getBlockingHolder('document', documentId);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Document is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEditorData = normalizeEditorDataDiffNodes(editorData);
|
||||
const savedAt = new Date();
|
||||
await this.documentHistoryService.createHistory({
|
||||
@@ -331,7 +408,8 @@ export class DocumentService {
|
||||
* Update document
|
||||
*/
|
||||
async updateDocument(id: string, params: UpdateDocumentParams): Promise<UpdateDocumentResult> {
|
||||
return this.db.transaction(async (tx) => {
|
||||
let changed = false;
|
||||
const result = await this.db.transaction(async (tx) => {
|
||||
const transactionDb = tx as unknown as LobeChatDatabase;
|
||||
const documentModel = new DocumentModel(transactionDb, this.userId, this.workspaceId);
|
||||
const fileModel = new FileModel(transactionDb, this.userId, this.workspaceId);
|
||||
@@ -361,6 +439,26 @@ export class DocumentService {
|
||||
nextEditorDataAccepted !== undefined &&
|
||||
!isEqual(nextEditorDataAccepted, currentEditorDataAccepted);
|
||||
|
||||
// Collaborative edit lock guard: reject writes to a workspace document that
|
||||
// another member is actively editing, so concurrent edits can't clobber
|
||||
// each other. Only the rich-text BODY is locked — metadata-only saves
|
||||
// (title/emoji) pass through, since the autosave always re-sends the
|
||||
// unchanged body. The lease auto-expires in Redis; when Redis is down this
|
||||
// returns null (fail-open) so the lock can't block saving.
|
||||
const contentChanged =
|
||||
historyAppended ||
|
||||
(params.content !== undefined && params.content !== currentDocument.content);
|
||||
if (this.workspaceId && contentChanged) {
|
||||
const blockedBy = await this.editLockService.getBlockingHolder('document', id);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Document is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (params.content !== undefined) {
|
||||
@@ -390,11 +488,15 @@ export class DocumentService {
|
||||
updates.parentId = params.parentId;
|
||||
}
|
||||
|
||||
// The lock lease is refreshed by the client heartbeat (acquireDocumentLock),
|
||||
// so a save does not need to touch it.
|
||||
|
||||
let savedAt: Date | undefined;
|
||||
|
||||
if (historyAppended) {
|
||||
savedAt = new Date();
|
||||
await documentHistoryService.createHistory({
|
||||
breakAutosaveWindow: params.breakAutosaveWindow,
|
||||
documentId: id,
|
||||
editorData: currentEditorDataAccepted,
|
||||
saveSource: params.saveSource ?? 'autosave',
|
||||
@@ -413,12 +515,25 @@ export class DocumentService {
|
||||
await fileModel.update(currentDocument.fileId, fileUpdates);
|
||||
}
|
||||
|
||||
changed = Object.keys(updates).length > 0 || historyAppended;
|
||||
|
||||
return {
|
||||
historyAppended,
|
||||
id,
|
||||
savedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Notify other workspace members that the document changed so their open
|
||||
// editor refreshes immediately (best-effort; the heartbeat is the fallback).
|
||||
if (this.workspaceId && changed) {
|
||||
void publishResourceEvent(
|
||||
{ id, type: 'document' },
|
||||
{ actorId: this.userId, type: 'doc.updated' },
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface DocumentHistoryListItem {
|
||||
isCurrent: boolean;
|
||||
savedAt: Date;
|
||||
saveSource: DocumentHistorySaveSource;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface DocumentHistoryItemResult {
|
||||
@@ -54,6 +55,7 @@ export interface ListDocumentHistoryResult {
|
||||
export type DatabaseLike = LobeChatDatabase | Transaction;
|
||||
|
||||
export interface UpdateDocumentParams {
|
||||
breakAutosaveWindow?: boolean;
|
||||
content?: string;
|
||||
editorData?: Record<string, any>;
|
||||
fileType?: string;
|
||||
@@ -73,3 +75,12 @@ export interface UpdateDocumentResult {
|
||||
export interface SaveDocumentHistoryResult {
|
||||
savedAt: Date;
|
||||
}
|
||||
|
||||
export interface DocumentLockResult {
|
||||
/** Lease expiry of the active lock, if any. */
|
||||
expiresAt: Date | null;
|
||||
/** The user id currently holding the lock, or null when unlocked. */
|
||||
holderId: string | null;
|
||||
/** True when another active user holds the lock (caller is locked out). */
|
||||
lockedByOther: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditLockService } from '../index';
|
||||
|
||||
/**
|
||||
* Minimal in-memory fake of the ioredis calls EditLockService uses:
|
||||
* `set(k, v, 'EX', ttl[, 'NX'])`, `get(k)`, and the compare-and-delete `eval`.
|
||||
*/
|
||||
const makeFakeRedis = () => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
eval: vi.fn(async (_script: string, _numKeys: number, key: string, arg: string) => {
|
||||
if (store.get(key) === arg) {
|
||||
store.delete(key);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string, ...args: unknown[]) => {
|
||||
if (args.includes('NX') && store.has(key)) return null;
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
}),
|
||||
store,
|
||||
};
|
||||
};
|
||||
|
||||
describe('EditLockService', () => {
|
||||
it('acquires a free lock and reports the caller as holder', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
|
||||
const result = await svc.acquire('document', 'doc-1');
|
||||
|
||||
expect(result.holderId).toBe('user-1');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||
expect(redis.store.get('editlock:document:doc-1')).toBe('user-1');
|
||||
});
|
||||
|
||||
it('reports another member as holder when the lock is already taken', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
const result = await new EditLockService('user-2', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: 'user-1', lockedByOther: true });
|
||||
});
|
||||
|
||||
it('lets the holder refresh their own lease', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
await svc.acquire('document', 'doc-1');
|
||||
|
||||
const result = await svc.acquire('document', 'doc-1');
|
||||
|
||||
expect(result.holderId).toBe('user-1');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
});
|
||||
|
||||
it('getActiveHolder reports the current holder, or undefined when free', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).getActiveHolder('document', 'doc-1'),
|
||||
).toBeUndefined();
|
||||
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
expect(
|
||||
await new EditLockService('user-2', redis as any).getActiveHolder('document', 'doc-1'),
|
||||
).toBe('user-1');
|
||||
});
|
||||
|
||||
it('keys locks per resource type, so the same id does not collide across types', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'shared-id');
|
||||
|
||||
// A different resource family with the same id is independently lockable.
|
||||
const result = await new EditLockService('user-2', redis as any).acquire('agent', 'shared-id');
|
||||
|
||||
expect(result.holderId).toBe('user-2');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
expect(redis.store.get('editlock:document:shared-id')).toBe('user-1');
|
||||
expect(redis.store.get('editlock:agent:shared-id')).toBe('user-2');
|
||||
});
|
||||
|
||||
it('getBlockingHolder returns the holder only when it is someone else', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
expect(
|
||||
await new EditLockService('user-2', redis as any).getBlockingHolder('document', 'doc-1'),
|
||||
).toBe('user-1');
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).getBlockingHolder('document', 'doc-1'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('only releases the lock for the current holder', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
// A non-holder release is a no-op and reports it did not release.
|
||||
expect(await new EditLockService('user-2', redis as any).release('document', 'doc-1')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(redis.store.get('editlock:document:doc-1')).toBe('user-1');
|
||||
|
||||
// The holder can release, and reports the lock was actually freed.
|
||||
expect(await new EditLockService('user-1', redis as any).release('document', 'doc-1')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(redis.store.has('editlock:document:doc-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to unlocked / no-op when Redis is unavailable', async () => {
|
||||
const svc = new EditLockService('user-1', null);
|
||||
|
||||
expect(await svc.acquire('document', 'doc-1')).toEqual({
|
||||
expiresAt: null,
|
||||
holderId: null,
|
||||
lockedByOther: false,
|
||||
});
|
||||
expect(await svc.getBlockingHolder('document', 'doc-1')).toBeNull();
|
||||
await expect(svc.release('document', 'doc-1')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('fails open when Redis is configured but commands reject (unreachable)', async () => {
|
||||
// ioredis is non-null but every command rejects after retries — the write
|
||||
// guards must not turn this into a 500; treat the resource as unlocked.
|
||||
const down = new Error('Connection is closed.');
|
||||
const redis = {
|
||||
eval: vi.fn().mockRejectedValue(down),
|
||||
get: vi.fn().mockRejectedValue(down),
|
||||
set: vi.fn().mockRejectedValue(down),
|
||||
};
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
|
||||
expect(await svc.acquire('document', 'doc-1')).toEqual({
|
||||
expiresAt: null,
|
||||
holderId: null,
|
||||
lockedByOther: false,
|
||||
});
|
||||
expect(await svc.getActiveHolder('document', 'doc-1')).toBeUndefined();
|
||||
expect(await svc.getBlockingHolder('document', 'doc-1')).toBeNull();
|
||||
await expect(svc.release('document', 'doc-1')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import debug from 'debug';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
|
||||
|
||||
const log = debug('lobe-server:edit-lock');
|
||||
|
||||
/** Lease lifetime in seconds; clients heartbeat well within this to keep it alive. */
|
||||
export const EDIT_LOCK_TTL_SECONDS = 30;
|
||||
|
||||
/** Editable resource families that can take a collaborative edit lock. */
|
||||
export type EditLockResourceType = 'agent' | 'chatGroup' | 'document' | 'task';
|
||||
|
||||
export interface EditLockResult {
|
||||
/** Lease expiry of the active lock, if the caller now holds it. */
|
||||
expiresAt: Date | null;
|
||||
/** The user id currently holding the lock, or null when unlocked. */
|
||||
holderId: string | null;
|
||||
/** True when another user holds the lock (caller is locked out). */
|
||||
lockedByOther: boolean;
|
||||
}
|
||||
|
||||
const UNLOCKED: EditLockResult = { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
|
||||
const lockKey = (type: EditLockResourceType, id: string) => `editlock:${type}:${id}`;
|
||||
|
||||
// Release only if the caller still holds the lock (compare-and-delete), so a
|
||||
// stale releaser can't drop a lease another member has since taken over.
|
||||
const RELEASE_SCRIPT = `
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('del', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
/**
|
||||
* Redis-backed collaborative edit lock, keyed by (resourceType, resourceId).
|
||||
*
|
||||
* Intentionally a thin, table-agnostic lease: there is no DB schema, so it
|
||||
* applies uniformly to any editable resource (documents, briefs, …) and can be
|
||||
* removed wholesale once real-time co-editing lands — the keys simply expire.
|
||||
*
|
||||
* The lock is advisory: when Redis is unavailable every method degrades to
|
||||
* "unlocked" so the lock infrastructure can never block editing or saving.
|
||||
*/
|
||||
export class EditLockService {
|
||||
private userId: string;
|
||||
private explicitRedis: Redis | null | undefined;
|
||||
private lazyRedis: Redis | null = null;
|
||||
private lazyResolved = false;
|
||||
|
||||
constructor(userId: string, redis?: Redis | null) {
|
||||
this.userId = userId;
|
||||
this.explicitRedis = redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Redis client, resolved lazily on first use. Resolving eagerly in the
|
||||
* constructor would read server-only env (`getAgentRuntimeRedisClient`) the
|
||||
* moment any owning service is built — which throws in client/test contexts
|
||||
* that construct the service but never take a lock.
|
||||
*/
|
||||
private get redis(): Redis | null {
|
||||
if (this.explicitRedis !== undefined) return this.explicitRedis;
|
||||
if (!this.lazyResolved) {
|
||||
this.lazyRedis = getAgentRuntimeRedisClient();
|
||||
this.lazyResolved = true;
|
||||
}
|
||||
return this.lazyRedis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the lock when it is free (or already mine), refreshing the lease;
|
||||
* otherwise report whoever currently holds it. Doubles as the heartbeat.
|
||||
*/
|
||||
async acquire(type: EditLockResourceType, id: string): Promise<EditLockResult> {
|
||||
const redis = this.redis;
|
||||
if (!redis) return UNLOCKED;
|
||||
const key = lockKey(type, id);
|
||||
|
||||
try {
|
||||
// Claim only when the key is absent (NX). The TTL gives automatic expiry, so
|
||||
// a hard-closed tab frees the lock without any cleanup job.
|
||||
const claimed = await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX');
|
||||
if (claimed) return this.held();
|
||||
|
||||
const holder = await redis.get(key);
|
||||
if (holder === this.userId) {
|
||||
// Already mine — refresh the lease (heartbeat).
|
||||
await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS);
|
||||
return this.held();
|
||||
}
|
||||
if (holder) return { expiresAt: null, holderId: holder, lockedByOther: true };
|
||||
|
||||
// Freed between the NX and the GET — try once more.
|
||||
const reclaimed = await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX');
|
||||
return reclaimed ? this.held() : UNLOCKED;
|
||||
} catch (error) {
|
||||
// Fail-open: a Redis outage (configured but unreachable) must never block
|
||||
// editing — report unlocked rather than surfacing the command rejection.
|
||||
log('acquire failed for %s:%s %O', type, id, error);
|
||||
return UNLOCKED;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current holder of the lock, or undefined when unlocked / Redis is down. */
|
||||
async getActiveHolder(type: EditLockResourceType, id: string): Promise<string | undefined> {
|
||||
const redis = this.redis;
|
||||
if (!redis) return undefined;
|
||||
try {
|
||||
const holder = await redis.get(lockKey(type, id));
|
||||
return holder ?? undefined;
|
||||
} catch (error) {
|
||||
// Fail-open: a Redis outage must not turn the write guards into 500s.
|
||||
log('getActiveHolder failed for %s:%s %O', type, id, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The holder when someone *other* than the caller holds the lock, else null.
|
||||
* Used by write guards; returns null when Redis is down (fail-open).
|
||||
*/
|
||||
async getBlockingHolder(type: EditLockResourceType, id: string): Promise<string | null> {
|
||||
const holder = await this.getActiveHolder(type, id);
|
||||
return holder && holder !== this.userId ? holder : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the lock, but only if the caller still holds it (compare-and-delete).
|
||||
* Returns true only when the caller's lock was actually deleted — false when
|
||||
* the lease had already expired or another member has since taken it over, so
|
||||
* callers can avoid broadcasting a bogus "unlocked" event.
|
||||
*/
|
||||
async release(type: EditLockResourceType, id: string): Promise<boolean> {
|
||||
if (!this.redis) return false;
|
||||
try {
|
||||
const deleted = await this.redis.eval(RELEASE_SCRIPT, 1, lockKey(type, id), this.userId);
|
||||
return deleted === 1;
|
||||
} catch (error) {
|
||||
log('release failed for %s:%s %O', type, id, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private held(): EditLockResult {
|
||||
return {
|
||||
expiresAt: new Date(Date.now() + EDIT_LOCK_TTL_SECONDS * 1000),
|
||||
holderId: this.userId,
|
||||
lockedByOther: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+374
@@ -0,0 +1,374 @@
|
||||
// @vitest-environment node
|
||||
import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
__resetOperationStatesForTesting,
|
||||
HeterogeneousPersistenceHandler,
|
||||
} from '../HeterogeneousPersistenceHandler';
|
||||
|
||||
/**
|
||||
* Regression for the SERVER-ONLY "大量无意义的 SubAgent" bug.
|
||||
*
|
||||
* Root cause: `HeterogeneousPersistenceHandler` keeps per-operation state in a
|
||||
* module-level `operationStates` map. On Vercel serverless, consecutive ingest
|
||||
* batches for one operation can land on DIFFERENT (cold) replicas, so that map
|
||||
* is empty on the next batch. `loadOrCreateState` rehydrates the MAIN-agent
|
||||
* state from DB (accumulatedContent, toolState, toolMsgIdByCallId,
|
||||
* currentAssistantMessageId) — but initializes `subagentState` with an empty
|
||||
* `createSubagentRunsState()` and NEVER reconstructs the in-flight subagent
|
||||
* runs from DB.
|
||||
*
|
||||
* Consequence: when a subagent run spans multiple batches, the first subagent
|
||||
* event seen by each fresh replica hits the `!existing` branch of `ensureRun`
|
||||
* and creates a BRAND-NEW thread for a `parentToolCallId` that already has one.
|
||||
* The duplicates get the generic "Subagent" title because spawnMetadata only
|
||||
* rides the first subagent event per parent (adapter `announcedSpawns`).
|
||||
*
|
||||
* The desktop client never hits this — it has a single long-lived
|
||||
* `subagentState` closure for the whole run.
|
||||
*
|
||||
* This test simulates a cold replica between batches via
|
||||
* `__resetOperationStatesForTesting()` (the in-memory map is dropped while the
|
||||
* mock DB — `threads` / `messages` — persists, exactly like a fresh Lambda).
|
||||
*/
|
||||
|
||||
interface FakeMessage {
|
||||
agentId: string | null;
|
||||
content: string;
|
||||
id: string;
|
||||
metadata?: any;
|
||||
model?: string;
|
||||
parentId?: string | null;
|
||||
plugin?: any;
|
||||
reasoning?: any;
|
||||
role: 'user' | 'assistant' | 'tool' | 'task' | 'system';
|
||||
threadId?: string | null;
|
||||
tool_call_id?: string;
|
||||
tools?: any[];
|
||||
topicId: string | null;
|
||||
}
|
||||
|
||||
interface FakeThread {
|
||||
id: string;
|
||||
metadata?: any;
|
||||
sourceMessageId?: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
topicId: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const createHarness = (params: {
|
||||
assistantMessageId: string;
|
||||
operationId: string;
|
||||
topicId: string;
|
||||
}) => {
|
||||
let nextMsgIdSeq = 0;
|
||||
const messages = new Map<string, FakeMessage>();
|
||||
const threads = new Map<string, FakeThread>();
|
||||
|
||||
messages.set(params.assistantMessageId, {
|
||||
agentId: null,
|
||||
content: '',
|
||||
id: params.assistantMessageId,
|
||||
role: 'assistant',
|
||||
topicId: params.topicId,
|
||||
});
|
||||
|
||||
const messageModel = {
|
||||
create: vi.fn(async (input: Partial<FakeMessage>, id?: string) => {
|
||||
nextMsgIdSeq += 1;
|
||||
const msgId = id ?? `msg_${nextMsgIdSeq}`;
|
||||
const msg: FakeMessage = {
|
||||
agentId: input.agentId ?? null,
|
||||
content: input.content ?? '',
|
||||
id: msgId,
|
||||
metadata: input.metadata,
|
||||
model: input.model,
|
||||
parentId: input.parentId ?? null,
|
||||
plugin: input.plugin,
|
||||
provider: undefined,
|
||||
reasoning: input.reasoning,
|
||||
role: input.role!,
|
||||
threadId: input.threadId ?? null,
|
||||
tool_call_id: input.tool_call_id,
|
||||
topicId: input.topicId ?? null,
|
||||
} as FakeMessage;
|
||||
messages.set(msgId, msg);
|
||||
return msg;
|
||||
}),
|
||||
update: vi.fn(async (id: string, patch: Partial<FakeMessage>) => {
|
||||
const existing = messages.get(id);
|
||||
if (!existing) return { success: false };
|
||||
messages.set(id, { ...existing, ...patch });
|
||||
return { success: true };
|
||||
}),
|
||||
updateToolMessage: vi.fn(async (id: string, patch: any) => {
|
||||
const existing = messages.get(id);
|
||||
if (!existing) return { success: false };
|
||||
messages.set(id, { ...existing, content: patch.content ?? existing.content });
|
||||
return { success: true };
|
||||
}),
|
||||
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
|
||||
query: vi.fn(async (params: { threadId?: string; topicId?: string }) => {
|
||||
if (params?.threadId) {
|
||||
return [...messages.values()].filter((m) => m.threadId === params.threadId);
|
||||
}
|
||||
return [...messages.values()].filter((m) => !m.threadId && m.topicId === params?.topicId);
|
||||
}),
|
||||
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
|
||||
const match = [...messages.values()].findLast(
|
||||
(m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId,
|
||||
);
|
||||
return match?.id;
|
||||
}),
|
||||
listMessagePluginsByTopic: vi.fn(async (_topicId: string) => {
|
||||
// Mirror the real query: every persisted tool row's (toolCallId → id).
|
||||
return [...messages.values()]
|
||||
.filter((m) => m.role === 'tool' && m.tool_call_id)
|
||||
.map((m) => ({ id: m.id, toolCallId: m.tool_call_id! }));
|
||||
}),
|
||||
};
|
||||
|
||||
const threadModel = {
|
||||
create: vi.fn(async (input: Partial<FakeThread>) => {
|
||||
const thread: FakeThread = {
|
||||
id: input.id!,
|
||||
metadata: input.metadata,
|
||||
sourceMessageId: input.sourceMessageId,
|
||||
status: input.status ?? 'active',
|
||||
title: input.title ?? '',
|
||||
topicId: input.topicId ?? params.topicId,
|
||||
type: input.type ?? 'isolation',
|
||||
};
|
||||
threads.set(thread.id, thread);
|
||||
return thread;
|
||||
}),
|
||||
findById: vi.fn(async (id: string) => threads.get(id) ?? null),
|
||||
queryByTopicId: vi.fn(async (topicId: string) =>
|
||||
[...threads.values()].filter((t) => t.topicId === topicId),
|
||||
),
|
||||
update: vi.fn(async (id: string, patch: Partial<FakeThread>) => {
|
||||
const existing = threads.get(id);
|
||||
if (!existing) return;
|
||||
threads.set(id, { ...existing, ...patch });
|
||||
}),
|
||||
};
|
||||
|
||||
const topicModel = {
|
||||
findById: vi.fn(async (id: string) => {
|
||||
if (id !== params.topicId) return null;
|
||||
return {
|
||||
agentId: null,
|
||||
id,
|
||||
metadata: {
|
||||
runningOperation: {
|
||||
assistantMessageId: params.assistantMessageId,
|
||||
operationId: params.operationId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
updateMetadata: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const handler = new HeterogeneousPersistenceHandler({
|
||||
messageModel: messageModel as any,
|
||||
threadModel: threadModel as any,
|
||||
topicModel: topicModel as any,
|
||||
});
|
||||
|
||||
return { handler, messages, threadModel, threads };
|
||||
};
|
||||
|
||||
const buildEvent = (
|
||||
type: AgentStreamEvent['type'],
|
||||
stepIndex: number,
|
||||
data: Record<string, unknown>,
|
||||
): AgentStreamEvent => ({
|
||||
data,
|
||||
operationId: 'op-1',
|
||||
stepIndex,
|
||||
timestamp: 1_700_000_000_000 + stepIndex,
|
||||
type,
|
||||
});
|
||||
|
||||
const innerTool = (id: string) => ({
|
||||
apiName: 'Bash',
|
||||
arguments: '{}',
|
||||
id,
|
||||
identifier: 'bash',
|
||||
type: 'default',
|
||||
});
|
||||
|
||||
describe('HeterogeneousPersistenceHandler — subagent run survives a cold replica', () => {
|
||||
beforeEach(() => __resetOperationStatesForTesting());
|
||||
afterEach(() => __resetOperationStatesForTesting());
|
||||
|
||||
it('does NOT spawn a duplicate thread when a later batch of the SAME subagent run lands on a fresh replica', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
const PARENT = 'tc-spawn-1';
|
||||
|
||||
// ── Batch 1 (replica A): first subagent turn. Carries spawnMetadata, so the
|
||||
// thread is created with a real title. ──
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 0, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: {
|
||||
parentToolCallId: PARENT,
|
||||
spawnMetadata: {
|
||||
description: 'Explore session/agent topic data model',
|
||||
prompt: 'investigate',
|
||||
subagentType: 'Explore',
|
||||
},
|
||||
subagentMessageId: 'sub-msg-1',
|
||||
},
|
||||
toolsCalling: [innerTool('inner-1')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
expect(h.threads.size).toBe(1);
|
||||
|
||||
// ── Cold replica: the warm in-memory operation state is gone, but the DB
|
||||
// (threads + messages) persists. ──
|
||||
__resetOperationStatesForTesting();
|
||||
|
||||
// ── Batch 2 (replica B): the SAME subagent run continues with a new turn.
|
||||
// Mirroring the adapter, this later event carries NO spawnMetadata. ──
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 1, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: {
|
||||
parentToolCallId: PARENT,
|
||||
subagentMessageId: 'sub-msg-2',
|
||||
},
|
||||
toolsCalling: [innerTool('inner-2')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// The continuation must attach to the EXISTING thread, not fork a new one.
|
||||
expect(h.threads.size).toBe(1);
|
||||
// And we must never produce a generic-titled "Subagent" duplicate.
|
||||
expect([...h.threads.values()].some((t) => t.title === 'Subagent')).toBe(false);
|
||||
});
|
||||
|
||||
// P1: a tools_calling batch reprocessed on a cold replica (BatchIngester
|
||||
// retry, or a turn split across a cold boundary so the cumulative array is
|
||||
// re-seen) must NOT mint a second tool message for an inner tool the run
|
||||
// already persisted. Rehydration restores `lifetimeToolCallIds`, and the
|
||||
// reducer de-dupes against it.
|
||||
it('does NOT re-create an already-persisted inner tool row after a cold replica', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
const PARENT = 'tc-spawn-1';
|
||||
|
||||
// Batch 1: turn sub-msg-1 persists inner-1.
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 0, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: {
|
||||
parentToolCallId: PARENT,
|
||||
spawnMetadata: { prompt: 'go', subagentType: 'Explore' },
|
||||
subagentMessageId: 'sub-msg-1',
|
||||
},
|
||||
toolsCalling: [innerTool('inner-1')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
__resetOperationStatesForTesting(); // cold replica
|
||||
|
||||
// Batch 2 (replica B): the SAME turn's cumulative array is re-seen (inner-1
|
||||
// again) plus a new inner-2.
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 1, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: { parentToolCallId: PARENT, subagentMessageId: 'sub-msg-1' },
|
||||
toolsCalling: [innerTool('inner-1'), innerTool('inner-2')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
const toolRows = (callId: string) =>
|
||||
[...h.messages.values()].filter((m) => m.role === 'tool' && m.tool_call_id === callId);
|
||||
// inner-1 persisted exactly once (no duplicate row), inner-2 once.
|
||||
expect(toolRows('inner-1')).toHaveLength(1);
|
||||
expect(toolRows('inner-2')).toHaveLength(1);
|
||||
expect(h.threads.size).toBe(1);
|
||||
});
|
||||
|
||||
// P2: a stale `Processing` isolation thread left by a PRIOR operation on the
|
||||
// same topic must not be rehydrated into — or finalized by — the current
|
||||
// operation. The rehydration is scoped by `metadata.operationId`.
|
||||
it('ignores a stale Processing thread from a different operation on the same topic', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-2',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// Seed a thread (+ its in-thread assistant) left Processing by op-1.
|
||||
h.threads.set('thd-stale', {
|
||||
id: 'thd-stale',
|
||||
metadata: { operationId: 'op-1', sourceToolCallId: 'tc-old' },
|
||||
sourceMessageId: 'asst-old',
|
||||
status: 'processing',
|
||||
title: 'Old Subagent',
|
||||
topicId: 'topic-1',
|
||||
type: 'isolation',
|
||||
});
|
||||
h.messages.set('stale-asst', {
|
||||
agentId: null,
|
||||
content: '',
|
||||
id: 'stale-asst',
|
||||
parentId: 'asst-old',
|
||||
role: 'assistant',
|
||||
threadId: 'thd-stale',
|
||||
topicId: 'topic-1',
|
||||
} as any);
|
||||
|
||||
// op-2 runs and terminates. The terminal orphan-drain would finalize every
|
||||
// run in the reducer state — so if the stale thread were merged in, it would
|
||||
// be flipped to Active here.
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 0, { chunkType: 'text', content: 'working' }),
|
||||
buildEvent('agent_runtime_end', 1, {}),
|
||||
],
|
||||
operationId: 'op-2',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// The unrelated thread is untouched: still Processing, never updated.
|
||||
expect(h.threads.get('thd-stale')!.status).toBe('processing');
|
||||
expect(h.threadModel.update).not.toHaveBeenCalledWith('thd-stale', expect.anything());
|
||||
});
|
||||
});
|
||||
+87
-2
@@ -486,9 +486,9 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
if (id === 'asst-1') order.push('update-asst');
|
||||
return origUpdate(id, patch);
|
||||
});
|
||||
h.messageModel.create.mockImplementation(async (input: any) => {
|
||||
h.messageModel.create.mockImplementation(async (input: any, id?: string) => {
|
||||
order.push(input.role === 'tool' ? 'create-tool' : 'create-other');
|
||||
return origCreate(input);
|
||||
return origCreate(input, id);
|
||||
});
|
||||
|
||||
const tool = {
|
||||
@@ -767,6 +767,91 @@ describe('HeterogeneousPersistenceHandler', () => {
|
||||
expect(step2Asst!.parentId).toBe('tool-row-only');
|
||||
});
|
||||
|
||||
it('chains off the latest tool row when parallel tools are only partially backfilled', async () => {
|
||||
// Regression for main-chain breaks with parallel/multi tool calls:
|
||||
// tool A is visible in assistant.tools[].result_msg_id, while tool B's
|
||||
// row exists but Phase 3 has not backfilled assistant.tools[] yet. The
|
||||
// step anchor must be tool B, not the earlier resolved tool A.
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-init',
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
const metaState: FakeTopicMetadata = {
|
||||
runningOperation: { assistantMessageId: 'asst-init', operationId: 'op-1' },
|
||||
};
|
||||
h.topicModel.findById.mockImplementation(async (id: string) => {
|
||||
if (id !== 'topic-1') return null;
|
||||
return { agentId: null, id, metadata: { ...metaState } };
|
||||
});
|
||||
h.topicModel.updateMetadata.mockImplementation(async (_id: string, patch: any) => {
|
||||
Object.assign(metaState, patch);
|
||||
});
|
||||
|
||||
await h.handler.ingest({
|
||||
events: [buildEvent('stream_start', 1, { newStep: true })],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
const step1Asst = [...h.messages.values()].find(
|
||||
(m) => m.role === 'assistant' && m.id !== 'asst-init',
|
||||
)!;
|
||||
|
||||
h.messages.set('tool-a-backfilled', {
|
||||
agentId: null,
|
||||
content: 'tool A result',
|
||||
id: 'tool-a-backfilled',
|
||||
parentId: step1Asst.id,
|
||||
role: 'tool',
|
||||
threadId: null,
|
||||
tool_call_id: 'tc-a',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
h.messages.set('tool-b-row-only', {
|
||||
agentId: null,
|
||||
content: 'tool B result',
|
||||
id: 'tool-b-row-only',
|
||||
parentId: step1Asst.id,
|
||||
role: 'tool',
|
||||
threadId: null,
|
||||
tool_call_id: 'tc-b',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
h.messages.set(step1Asst.id, {
|
||||
...h.messages.get(step1Asst.id)!,
|
||||
tools: [
|
||||
{
|
||||
apiName: 'Read',
|
||||
arguments: '{}',
|
||||
id: 'tc-a',
|
||||
identifier: 'read',
|
||||
result_msg_id: 'tool-a-backfilled',
|
||||
type: 'default',
|
||||
},
|
||||
{
|
||||
apiName: 'Bash',
|
||||
arguments: '{}',
|
||||
id: 'tc-b',
|
||||
identifier: 'bash',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await h.handler.ingest({
|
||||
events: [buildEvent('stream_start', 2, { newStep: true })],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
const step2Asst = [...h.messages.values()].find(
|
||||
(m) => m.role === 'assistant' && m.id !== 'asst-init' && m.id !== step1Asst.id,
|
||||
);
|
||||
expect(step2Asst).toBeDefined();
|
||||
expect(step2Asst!.parentId).toBe('tool-b-row-only');
|
||||
});
|
||||
|
||||
it('ignores subagent tool rows (threadId set) when resolving the step anchor', async () => {
|
||||
// A subagent tool row lives on its own thread and must never anchor the
|
||||
// main-agent wire. If the only `role:'tool'` child carries a threadId,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { publishResourceEvent, resourceChannelId } from '../index';
|
||||
|
||||
describe('resourceEvents', () => {
|
||||
it('formats a stable channel id per resource', () => {
|
||||
expect(resourceChannelId({ id: 'doc-1', type: 'document' })).toBe('resource:document:doc-1');
|
||||
});
|
||||
|
||||
it('publish is best-effort and never throws (no Redis → in-memory)', async () => {
|
||||
await expect(
|
||||
publishResourceEvent(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
{ actorId: 'u1', type: 'doc.updated' },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
publishResourceEvent(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
{ actorId: 'u1', data: { holderId: null }, type: 'lock.changed' },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import debug from 'debug';
|
||||
|
||||
// Import the transport pieces from their concrete modules rather than the
|
||||
// `@/server/modules/AgentRuntime` barrel: the barrel re-exports RuntimeExecutors,
|
||||
// which eagerly constructs the ModelRuntime ApiKeyManager at module load and
|
||||
// throws in client/test contexts. These leaf modules pull no ModelRuntime.
|
||||
import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager';
|
||||
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
|
||||
import { StreamEventManager } from '@/server/modules/AgentRuntime/StreamEventManager';
|
||||
import type { IStreamEventManager } from '@/server/modules/AgentRuntime/types';
|
||||
|
||||
import type { ReceivedResourceEvent, ResourceEvent, ResourceRef } from './types';
|
||||
|
||||
export type { ReceivedResourceEvent, ResourceEvent, ResourceRef, ResourceType } from './types';
|
||||
|
||||
const log = debug('lobe-server:resource-events');
|
||||
|
||||
/** Redis Stream / in-memory channel key for a resource. */
|
||||
export const resourceChannelId = (ref: ResourceRef): string => `resource:${ref.type}:${ref.id}`;
|
||||
|
||||
/**
|
||||
* Select the underlying transport. We deliberately bypass
|
||||
* `createStreamEventManager()` — its `GatewayStreamNotifier` wrapper POSTs every
|
||||
* published event to the agent gateway, which must not see resource events.
|
||||
* Evaluated per call so it picks up Redis becoming (un)available.
|
||||
*/
|
||||
const getManager = (): IStreamEventManager =>
|
||||
getAgentRuntimeRedisClient() !== null ? new StreamEventManager() : inMemoryStreamEventManager;
|
||||
|
||||
/**
|
||||
* Realtime event fan-out for editable resources, keyed by (resourceType, id).
|
||||
*
|
||||
* A thin, table-agnostic wrapper over the existing Redis-Streams transport so
|
||||
* presence and (eventually) real-time co-editing can reuse the same channel.
|
||||
* The lease/lock is advisory and this channel is best-effort: publishing never
|
||||
* throws, and with no Redis the in-memory manager keeps single-instance dev
|
||||
* working while clients fall back to their polling heartbeat.
|
||||
*/
|
||||
export const publishResourceEvent = async (
|
||||
ref: ResourceRef,
|
||||
event: ResourceEvent,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await getManager().publishStreamEvent(resourceChannelId(ref), {
|
||||
// The agent StreamEvent shape (stepIndex + closed `type` union) is an
|
||||
// implementation detail of the transport; cast at this single boundary.
|
||||
data: { actorId: event.actorId, ...event.data },
|
||||
stepIndex: 0,
|
||||
type: event.type,
|
||||
} as unknown as Parameters<IStreamEventManager['publishStreamEvent']>[1]);
|
||||
} catch (error) {
|
||||
// Best-effort: a transport hiccup must never break the caller's save/lock op.
|
||||
log('publishResourceEvent failed for %s:%s %O', ref.type, ref.id, error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to a resource's events until `signal` aborts. Only events published
|
||||
* after subscription are delivered (no history replay).
|
||||
*/
|
||||
export const subscribeResourceEvents = async (
|
||||
ref: ResourceRef,
|
||||
onEvent: (event: ReceivedResourceEvent) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> => {
|
||||
await getManager().subscribeStreamEvents(
|
||||
resourceChannelId(ref),
|
||||
'$',
|
||||
(events) => {
|
||||
for (const e of events) {
|
||||
const { actorId, ...rest } = (e.data ?? {}) as Record<string, unknown>;
|
||||
onEvent({
|
||||
actorId: typeof actorId === 'string' ? actorId : '',
|
||||
data: rest,
|
||||
timestamp: e.timestamp,
|
||||
type: e.type as unknown as ResourceEvent['type'],
|
||||
});
|
||||
}
|
||||
},
|
||||
signal,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Editable resource families that can broadcast realtime events. */
|
||||
export type ResourceType = 'agent' | 'chatGroup' | 'document' | 'task';
|
||||
|
||||
export interface ResourceRef {
|
||||
id: string;
|
||||
type: ResourceType;
|
||||
}
|
||||
|
||||
export type ResourceEventType = 'doc.updated' | 'lock.changed';
|
||||
|
||||
export interface ResourceEvent {
|
||||
/** User id that triggered the event; lets subscribers ignore self-originated events. */
|
||||
actorId: string;
|
||||
/** Event-specific payload (e.g. `{ holderId }` for `lock.changed`). */
|
||||
data?: Record<string, unknown>;
|
||||
type: ResourceEventType;
|
||||
}
|
||||
|
||||
export interface ReceivedResourceEvent extends ResourceEvent {
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -706,6 +706,7 @@ export class TaskService {
|
||||
activities: activities.length > 0 ? activities : undefined,
|
||||
topicCount: topics.length > 0 ? topics.length : undefined,
|
||||
workspace: workspaceFolders.length > 0 ? workspaceFolders : undefined,
|
||||
workspaceId: task.workspaceId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ describe('localSystemRuntime', () => {
|
||||
it('should call deviceGateway.executeToolCall with correct arguments when a proxy function is invoked', async () => {
|
||||
const context: ToolExecutionContext = {
|
||||
activeDeviceId: 'device-1',
|
||||
operationId: 'op-1',
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
};
|
||||
@@ -78,7 +79,7 @@ describe('localSystemRuntime', () => {
|
||||
const result = await proxy[apiName](args);
|
||||
|
||||
expect(mockExecuteToolCall).toHaveBeenCalledWith(
|
||||
{ deviceId: 'device-1', userId: 'user-1' },
|
||||
{ deviceId: 'device-1', operationId: 'op-1', userId: 'user-1' },
|
||||
{
|
||||
apiName,
|
||||
arguments: JSON.stringify(args),
|
||||
|
||||
@@ -43,9 +43,9 @@ export const agentManagementRuntime: ServerRuntimeRegistration = {
|
||||
): Promise<ToolExecutionResult> => {
|
||||
const { agentId, instruction, taskTitle, timeout } = params;
|
||||
|
||||
// Server runtime always uses the task path because there is no
|
||||
// client-side `registerAfterCompletion` callback available to execute
|
||||
// synchronous agent calls.
|
||||
// Server runtime always uses the legacy async invocation path because
|
||||
// there is no client-side `registerAfterCompletion` callback available
|
||||
// to execute synchronous agent calls.
|
||||
return {
|
||||
content: `🚀 Triggered async task to call agent "${agentId}"${taskTitle ? `: ${taskTitle}` : ''}`,
|
||||
state: {
|
||||
|
||||
@@ -10,6 +10,7 @@ interface LobeDeliveryCheckerRuntimeContext {
|
||||
operationId?: string;
|
||||
serverDB: LobeChatDatabase;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
const buildError = (content: string, code: string): BuiltinServerRuntimeOutput => ({
|
||||
@@ -28,11 +29,13 @@ class LobeDeliveryCheckerExecutionRuntime {
|
||||
private operationId?: string;
|
||||
private db: LobeChatDatabase;
|
||||
private userId: string;
|
||||
private workspaceId?: string;
|
||||
|
||||
constructor(context: LobeDeliveryCheckerRuntimeContext) {
|
||||
this.operationId = context.operationId;
|
||||
this.db = context.serverDB;
|
||||
this.userId = context.userId;
|
||||
this.workspaceId = context.workspaceId;
|
||||
}
|
||||
|
||||
generateVerifyPlan = async (params: {
|
||||
@@ -64,7 +67,7 @@ class LobeDeliveryCheckerExecutionRuntime {
|
||||
// criteria + a rubric, snapshot it onto this operation, and confirm it. The
|
||||
// tool call is human-reviewed (humanIntervention); this runs post-approval.
|
||||
const { VerifyPlanGeneratorService } = await import('@/server/services/verify');
|
||||
const planGenerator = new VerifyPlanGeneratorService(this.db, this.userId);
|
||||
const planGenerator = new VerifyPlanGeneratorService(this.db, this.userId, this.workspaceId);
|
||||
const { items, rubricId } = await planGenerator.createPlanFromCriteria({
|
||||
criteria,
|
||||
operationId: this.operationId,
|
||||
@@ -110,6 +113,7 @@ export const lobeDeliveryCheckerRuntime: ServerRuntimeRegistration = {
|
||||
operationId: context.operationId,
|
||||
serverDB: context.serverDB,
|
||||
userId: context.userId,
|
||||
workspaceId: context.workspaceId,
|
||||
});
|
||||
},
|
||||
identifier: LobeDeliveryCheckerIdentifier,
|
||||
|
||||
@@ -18,7 +18,11 @@ export const localSystemRuntime: ServerRuntimeRegistration = {
|
||||
for (const api of LocalSystemManifest.api) {
|
||||
proxy[api.name] = async (args: any) => {
|
||||
return deviceGateway.executeToolCall(
|
||||
{ deviceId: context.activeDeviceId!, userId: context.userId! },
|
||||
{
|
||||
deviceId: context.activeDeviceId!,
|
||||
operationId: context.operationId,
|
||||
userId: context.userId!,
|
||||
},
|
||||
{
|
||||
apiName: api.name,
|
||||
arguments: JSON.stringify(args),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { builtinSkills } from '@lobechat/builtin-skills';
|
||||
import { LocalSystemApiName, LocalSystemIdentifier } from '@lobechat/builtin-tool-local-system';
|
||||
// Note: only `readFile` is wired through deviceGateway. Directory enumeration is
|
||||
// left to the model via `local-system.listFiles` so we don't double-fetch.
|
||||
// left to the model via `local-system.globFiles` so we don't double-fetch.
|
||||
import {
|
||||
type CommandResult,
|
||||
type ExecScriptActivatedSkill,
|
||||
|
||||
@@ -15,6 +15,7 @@ interface VerifyResultRuntimeContext {
|
||||
operationId?: string;
|
||||
serverDB: LobeChatDatabase;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,11 +28,13 @@ class VerifyResultExecutionRuntime {
|
||||
private operationId?: string;
|
||||
private db: LobeChatDatabase;
|
||||
private userId: string;
|
||||
private workspaceId?: string;
|
||||
|
||||
constructor(context: VerifyResultRuntimeContext) {
|
||||
this.operationId = context.operationId;
|
||||
this.db = context.serverDB;
|
||||
this.userId = context.userId;
|
||||
this.workspaceId = context.workspaceId;
|
||||
}
|
||||
|
||||
submitVerifyResult = async (params: SubmitVerifyResultParams) => {
|
||||
@@ -47,11 +50,13 @@ class VerifyResultExecutionRuntime {
|
||||
}
|
||||
|
||||
// The verifier runs as a sub-agent; the row to update belongs to the parent run.
|
||||
const op = await new AgentOperationModel(this.db, this.userId).findById(this.operationId);
|
||||
const op = await new AgentOperationModel(this.db, this.userId, this.workspaceId).findById(
|
||||
this.operationId,
|
||||
);
|
||||
const targetOperationId = op?.parentOperationId ?? this.operationId;
|
||||
|
||||
const status = params.verdict === 'passed' ? 'passed' : 'failed';
|
||||
await new VerifyCheckResultModel(this.db, this.userId).updateByCheckItem(
|
||||
await new VerifyCheckResultModel(this.db, this.userId, this.workspaceId).updateByCheckItem(
|
||||
targetOperationId,
|
||||
params.checkItemId,
|
||||
{
|
||||
@@ -66,10 +71,12 @@ class VerifyResultExecutionRuntime {
|
||||
verdict: params.verdict,
|
||||
},
|
||||
);
|
||||
await new VerifyStatusService(this.db, this.userId).recompute(targetOperationId);
|
||||
await new VerifyStatusService(this.db, this.userId, this.workspaceId).recompute(
|
||||
targetOperationId,
|
||||
);
|
||||
// This may be the last check to resolve — kick auto-repair if the run failed
|
||||
// with auto_repair checks (no-op until everything has a terminal result).
|
||||
await maybeAutoRepair(this.db, this.userId, targetOperationId);
|
||||
await maybeAutoRepair(this.db, this.userId, targetOperationId, this.workspaceId);
|
||||
|
||||
log(
|
||||
'submitted verdict %s for check %s (op %s)',
|
||||
@@ -94,6 +101,7 @@ export const verifyResultRuntime: ServerRuntimeRegistration = {
|
||||
operationId: context.operationId,
|
||||
serverDB: context.serverDB,
|
||||
userId: context.userId,
|
||||
workspaceId: context.workspaceId,
|
||||
});
|
||||
},
|
||||
identifier: VerifyToolIdentifier,
|
||||
|
||||
@@ -61,9 +61,9 @@ export interface ToolExecutionContext {
|
||||
/** Current page document ID for page-scoped conversations */
|
||||
documentId?: string | null;
|
||||
/**
|
||||
* Spawn a sub-agent as an independent async operation. Injected by the agent
|
||||
* runtime (forwarded from `RuntimeExecutorContext.execSubAgent`) so the
|
||||
* `callSubAgent` server tool can fork a child op without a circular import.
|
||||
* Legacy agent invocation callback forwarded from RuntimeExecutorContext.
|
||||
* Kept for tool runtimes that still dispatch through exec_sub_agent style
|
||||
* flows; `lobe-agent.callSubAgent` uses the per-call `subAgent` runner below.
|
||||
*/
|
||||
execSubAgent?: (params: ExecSubAgentParams) => Promise<unknown>;
|
||||
/** Per-call execution timeout resolved by the agent runtime. */
|
||||
|
||||
@@ -52,18 +52,20 @@ export const createVerifierAgentRunner = (params: {
|
||||
provider?: string | null;
|
||||
topicId?: string | null;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): VerifierAgentRunner | undefined => {
|
||||
const { db, deliverable, model, provider, topicId, userId } = params;
|
||||
const { db, deliverable, model, provider, topicId, userId, workspaceId } = params;
|
||||
if (!topicId) return undefined;
|
||||
|
||||
return async ({ checkItem, goal, operationId }) => {
|
||||
// The detailed instruction is the criterion's rule body, stored in a document.
|
||||
const instruction = checkItem.documentId
|
||||
? ((await new DocumentModel(db, userId).findById(checkItem.documentId))?.content ?? undefined)
|
||||
? ((await new DocumentModel(db, userId, workspaceId).findById(checkItem.documentId))
|
||||
?.content ?? undefined)
|
||||
: undefined;
|
||||
|
||||
// Materialize the builtin verify agent (idempotent) to get an id for the thread.
|
||||
const verifyAgent = await new AgentModel(db, userId).getBuiltinAgent(
|
||||
const verifyAgent = await new AgentModel(db, userId, workspaceId).getBuiltinAgent(
|
||||
BUILTIN_AGENT_SLUGS.verifyAgent,
|
||||
);
|
||||
if (!verifyAgent) {
|
||||
@@ -71,7 +73,7 @@ export const createVerifierAgentRunner = (params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const thread = await new ThreadModel(db, userId).create({
|
||||
const thread = await new ThreadModel(db, userId, workspaceId).create({
|
||||
agentId: verifyAgent.id,
|
||||
title: `Verify: ${checkItem.title}`,
|
||||
topicId,
|
||||
@@ -85,7 +87,7 @@ export const createVerifierAgentRunner = (params: {
|
||||
// Dynamic import breaks the static cycle: aiAgent → agentRuntime completion
|
||||
// → verify lifecycle → this runner → aiAgent.
|
||||
const { AiAgentService } = await import('@/server/services/aiAgent');
|
||||
const result = await new AiAgentService(db, userId).execAgent({
|
||||
const result = await new AiAgentService(db, userId, { workspaceId }).execAgent({
|
||||
appContext: { threadId: thread.id, topicId },
|
||||
autoStart: true,
|
||||
// Inherit the parent run's model/provider so the verifier uses a provider
|
||||
|
||||
@@ -80,13 +80,13 @@ export class VerifyExecutorService {
|
||||
private readonly statusService: VerifyStatusService;
|
||||
private readonly documentModel: DocumentModel;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
this.operationModel = new AgentOperationModel(db, userId);
|
||||
this.resultModel = new VerifyCheckResultModel(db, userId);
|
||||
this.statusService = new VerifyStatusService(db, userId);
|
||||
this.documentModel = new DocumentModel(db, userId);
|
||||
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
|
||||
this.statusService = new VerifyStatusService(db, userId, workspaceId);
|
||||
this.documentModel = new DocumentModel(db, userId, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,8 +23,8 @@ export const computeFalseFlags = (
|
||||
export class VerifyFeedbackService {
|
||||
private readonly resultModel: VerifyCheckResultModel;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.resultModel = new VerifyCheckResultModel(db, userId);
|
||||
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
|
||||
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
|
||||
}
|
||||
|
||||
/** Record a user's decision on a result and precompute its FP/FN flags. */
|
||||
|
||||
@@ -33,9 +33,10 @@ export const runVerifyOnCompletion = async (
|
||||
db: LobeChatDatabase,
|
||||
userId: string,
|
||||
params: RunVerifyOnCompletionParams,
|
||||
workspaceId?: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const operationModel = new AgentOperationModel(db, userId);
|
||||
const operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
const state = await operationModel.getVerifyState(params.operationId);
|
||||
|
||||
// Opt-in gate: only runs with a confirmed plan that hasn't been verified yet.
|
||||
@@ -48,7 +49,7 @@ export const runVerifyOnCompletion = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const executor = new VerifyExecutorService(db, userId);
|
||||
const executor = new VerifyExecutorService(db, userId, workspaceId);
|
||||
await executor.execute({
|
||||
deliverable: params.deliverable,
|
||||
goal: params.goal,
|
||||
@@ -63,13 +64,14 @@ export const runVerifyOnCompletion = async (
|
||||
provider: op.provider,
|
||||
topicId: op.topicId,
|
||||
userId,
|
||||
workspaceId,
|
||||
}),
|
||||
});
|
||||
|
||||
// Auto-repair once verification has fully resolved. For runs with only inline
|
||||
// (LLM/program) checks, everything is resolved now; runs with async agent
|
||||
// checks no-op here and re-trigger from the verifier's writeback path.
|
||||
await maybeAutoRepair(db, userId, params.operationId);
|
||||
await maybeAutoRepair(db, userId, params.operationId, workspaceId);
|
||||
} catch (error) {
|
||||
log('runVerifyOnCompletion failed for op %s (non-fatal): %O', params.operationId, error);
|
||||
}
|
||||
|
||||
@@ -75,13 +75,13 @@ export class VerifyPlanGeneratorService {
|
||||
private readonly operationModel: AgentOperationModel;
|
||||
private readonly documentModel: DocumentModel;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
this.criterionModel = new VerifyCriterionModel(db, userId);
|
||||
this.rubricModel = new VerifyRubricModel(db, userId);
|
||||
this.operationModel = new AgentOperationModel(db, userId);
|
||||
this.documentModel = new DocumentModel(db, userId);
|
||||
this.criterionModel = new VerifyCriterionModel(db, userId, workspaceId);
|
||||
this.rubricModel = new VerifyRubricModel(db, userId, workspaceId);
|
||||
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
this.documentModel = new DocumentModel(db, userId, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,11 +22,12 @@ const resolveMaxRepairRounds = async (
|
||||
db: LobeChatDatabase,
|
||||
userId: string,
|
||||
plan: VerifyCheckItem[],
|
||||
workspaceId?: string,
|
||||
): Promise<number> => {
|
||||
const rubricId = plan.find((i) => i.sourceRubricId)?.sourceRubricId;
|
||||
if (!rubricId) return DEFAULT_MAX_REPAIR_ROUNDS;
|
||||
|
||||
const rubric = await new VerifyRubricModel(db, userId).findById(rubricId);
|
||||
const rubric = await new VerifyRubricModel(db, userId, workspaceId).findById(rubricId);
|
||||
return rubric?.config?.maxRepairRounds ?? DEFAULT_MAX_REPAIR_ROUNDS;
|
||||
};
|
||||
|
||||
@@ -79,12 +80,13 @@ export const createRepairRunner = (params: {
|
||||
provider?: string | null;
|
||||
topicId?: string | null;
|
||||
userId: string;
|
||||
workspaceId?: string;
|
||||
}): RepairSpawner | undefined => {
|
||||
const { agentId, db, maxRepairRounds, model, provider, topicId, userId } = params;
|
||||
const { agentId, db, maxRepairRounds, model, provider, topicId, userId, workspaceId } = params;
|
||||
if (!agentId || !topicId) return undefined;
|
||||
|
||||
return async ({ instruction, operationId, verifyMessageId }) => {
|
||||
const operationModel = new AgentOperationModel(db, userId);
|
||||
const operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
|
||||
const round = await countRepairRounds(operationModel, operationId);
|
||||
if (round >= maxRepairRounds) {
|
||||
@@ -98,7 +100,7 @@ export const createRepairRunner = (params: {
|
||||
// for the operation title / logs. `verifyMessageId` parents the new turn under
|
||||
// the verify card it responds to.
|
||||
const { AiAgentService } = await import('@/server/services/aiAgent');
|
||||
const result = await new AiAgentService(db, userId).execAgent({
|
||||
const result = await new AiAgentService(db, userId, { workspaceId }).execAgent({
|
||||
agentId,
|
||||
appContext: { topicId },
|
||||
autoStart: true,
|
||||
@@ -138,13 +140,16 @@ export const maybeAutoRepair = async (
|
||||
db: LobeChatDatabase,
|
||||
userId: string,
|
||||
operationId: string,
|
||||
workspaceId?: string,
|
||||
): Promise<void> => {
|
||||
const operationModel = new AgentOperationModel(db, userId);
|
||||
const operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
const state = await operationModel.getVerifyState(operationId);
|
||||
const plan = (state?.verifyPlan ?? []) as VerifyCheckItem[];
|
||||
if (plan.length === 0) return;
|
||||
|
||||
const results = await new VerifyCheckResultModel(db, userId).listByOperation(operationId);
|
||||
const results = await new VerifyCheckResultModel(db, userId, workspaceId).listByOperation(
|
||||
operationId,
|
||||
);
|
||||
const byItem = new Map(results.map((r) => [r.checkItemId, r]));
|
||||
|
||||
// Wait until every required check has a terminal result (don't repair early).
|
||||
@@ -160,13 +165,14 @@ export const maybeAutoRepair = async (
|
||||
const spawner = createRepairRunner({
|
||||
agentId: op?.agentId,
|
||||
db,
|
||||
maxRepairRounds: await resolveMaxRepairRounds(db, userId, plan),
|
||||
maxRepairRounds: await resolveMaxRepairRounds(db, userId, plan, workspaceId),
|
||||
model: op?.model,
|
||||
provider: op?.provider,
|
||||
topicId: op?.topicId,
|
||||
userId,
|
||||
workspaceId,
|
||||
});
|
||||
await new VerifyRepairService(db, userId).triggerAutoRepair(operationId, spawner);
|
||||
await new VerifyRepairService(db, userId, workspaceId).triggerAutoRepair(operationId, spawner);
|
||||
};
|
||||
|
||||
const isFailed = (r: VerifyCheckResultItem | undefined): boolean =>
|
||||
@@ -191,11 +197,11 @@ export class VerifyRepairService {
|
||||
private readonly resultModel: VerifyCheckResultModel;
|
||||
private readonly statusService: VerifyStatusService;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.messageModel = new MessageModel(db, userId);
|
||||
this.operationModel = new AgentOperationModel(db, userId);
|
||||
this.resultModel = new VerifyCheckResultModel(db, userId);
|
||||
this.statusService = new VerifyStatusService(db, userId);
|
||||
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
|
||||
this.messageModel = new MessageModel(db, userId, workspaceId);
|
||||
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
|
||||
this.statusService = new VerifyStatusService(db, userId, workspaceId);
|
||||
}
|
||||
|
||||
/** Collect the auto-repairable failures for a run. */
|
||||
|
||||
@@ -17,9 +17,9 @@ export class VerifyStatusService {
|
||||
private readonly operationModel: AgentOperationModel;
|
||||
private readonly resultModel: VerifyCheckResultModel;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.operationModel = new AgentOperationModel(db, userId);
|
||||
this.resultModel = new VerifyCheckResultModel(db, userId);
|
||||
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
|
||||
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
|
||||
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="shortcut icon" href="/favicon-32x32.ico" />
|
||||
<!--SEO_META-->
|
||||
<style>
|
||||
html body {
|
||||
background: #f8f8f8;
|
||||
}
|
||||
html[data-theme='dark'] body {
|
||||
background-color: #000;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
function supportsImportMaps() {
|
||||
return (
|
||||
typeof HTMLScriptElement !== 'undefined' &&
|
||||
typeof HTMLScriptElement.supports === 'function' &&
|
||||
HTMLScriptElement.supports('importmap')
|
||||
);
|
||||
}
|
||||
|
||||
function supportsCascadeLayers() {
|
||||
var el = document.createElement('div');
|
||||
el.className = '__layer_test__';
|
||||
el.style.position = 'absolute';
|
||||
el.style.left = '-99999px';
|
||||
el.style.top = '-99999px';
|
||||
|
||||
var style = document.createElement('style');
|
||||
style.textContent =
|
||||
'@layer a, b;' +
|
||||
'@layer a { .__layer_test__ { color: rgb(1, 2, 3); } }' +
|
||||
'@layer b { .__layer_test__ { color: rgb(4, 5, 6); } }';
|
||||
|
||||
document.documentElement.append(style);
|
||||
document.documentElement.append(el);
|
||||
|
||||
var color = getComputedStyle(el).color;
|
||||
|
||||
el.remove();
|
||||
style.remove();
|
||||
|
||||
return color === 'rgb(4, 5, 6)';
|
||||
}
|
||||
|
||||
if (!(supportsImportMaps() && supportsCascadeLayers())) {
|
||||
window.location.href = '/not-compatible.html';
|
||||
return;
|
||||
}
|
||||
|
||||
var theme = 'system';
|
||||
try {
|
||||
theme = localStorage.getItem('theme') || 'system';
|
||||
} catch (_) {}
|
||||
var systemTheme =
|
||||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
var resolvedTheme = theme === 'system' ? systemTheme : theme;
|
||||
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||
}
|
||||
|
||||
var hl = new URLSearchParams(location.search).get('hl');
|
||||
var m = document.cookie.match(/(?:^|;\s*)LOBE_LOCALE=([^;]*)/);
|
||||
var cookie = m ? decodeURIComponent(m[1]) : '';
|
||||
var locale = hl || cookie || navigator.language || 'en-US';
|
||||
if (locale === 'auto') locale = navigator.language || 'en-US';
|
||||
if (hl && !cookie) {
|
||||
document.cookie =
|
||||
'LOBE_LOCALE=' + encodeURIComponent(hl) + ';path=/;max-age=7776000;SameSite=Lax';
|
||||
}
|
||||
document.documentElement.lang = locale;
|
||||
var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
|
||||
document.documentElement.dir =
|
||||
rtl.indexOf(locale.split('-')[0].toLowerCase()) >= 0 ? 'rtl' : 'ltr';
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
window.__SERVER_CONFIG__ = undefined; /* SERVER_CONFIG */
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root" style="height: 100%"></div>
|
||||
|
||||
<!--ANALYTICS_SCRIPTS-->
|
||||
<script type="module" src="/src/spa/entry.auth.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -15,6 +15,8 @@
|
||||
"agentBuilder.installPlugin.retry": "إعادة المحاولة",
|
||||
"agentBuilder.title": "منشئ الوكلاء",
|
||||
"agentBuilder.welcome": "أخبرني بحالتك.\n\nكتابة، برمجة، أو تحليل بيانات — أي شيء يناسبك. أنت تملك الهدف والمعايير؛ سأقوم بتقسيمها إلى وكلاء تعاونيين قابلين للتنفيذ.",
|
||||
"agentConfigError.retry": "إعادة المحاولة",
|
||||
"agentConfigError.title": "فشل في تحميل إعدادات الوكيل",
|
||||
"agentDefaultMessage": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي.\n\nهل ترغب في أن أتناسب مع سير عملك بشكل أفضل؟ انتقل إلى [إعدادات الوكيل]({{url}}) واملأ ملف تعريف الوكيل (يمكنك تعديله في أي وقت).",
|
||||
"agentDefaultMessageWithSystemRole": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي — أنت المتحكم.",
|
||||
"agentDefaultMessageWithoutEdit": "مرحبًا، أنا **{{name}}**. جملة واحدة تكفي — أنت المتحكم.",
|
||||
@@ -252,6 +254,10 @@
|
||||
"input.costEstimate.tooltip": "تم التقدير بناءً على السياق الحالي، الأدوات، وتسعير النموذج. قد تختلف التكلفة الفعلية.",
|
||||
"input.disclaimer": "قد يخطئ الوكلاء. استخدم حكمك الخاص للمعلومات الحساسة.",
|
||||
"input.errorMsg": "فشل الإرسال: {{errorMsg}}. أعد المحاولة أو أرسل لاحقًا.",
|
||||
"input.inputCompletionError.desc": "توقفت اقتراحات الإدخال بعد حدوث خطأ. حاول مرة أخرى، أو قم بتعديل نموذج الاقتراح في الإعدادات.",
|
||||
"input.inputCompletionError.retry": "إعادة المحاولة",
|
||||
"input.inputCompletionError.settings": "الإعدادات",
|
||||
"input.inputCompletionError.title": "توقفت اقتراحات الإدخال",
|
||||
"input.more": "المزيد",
|
||||
"input.send": "إرسال",
|
||||
"input.sendWithCmdEnter": "اضغط <key/> للإرسال",
|
||||
@@ -364,6 +370,14 @@
|
||||
"noMatchingAgents": "لم يتم العثور على أعضاء مطابقين",
|
||||
"noMembersYet": "لا تحتوي هذه المجموعة على أي أعضاء بعد. انقر على زر + لدعوة وكلاء.",
|
||||
"noSelectedAgents": "لم يتم تحديد أي أعضاء بعد",
|
||||
"opStatusTray.cost": "التكلفة",
|
||||
"opStatusTray.status.compressing": "ضغط السياق",
|
||||
"opStatusTray.status.generating": "جاري التوليد",
|
||||
"opStatusTray.status.reasoning": "التفكير",
|
||||
"opStatusTray.status.searching": "جاري البحث",
|
||||
"opStatusTray.status.toolCalling": "استدعاء الأدوات",
|
||||
"opStatusTray.steps": "الخطوات",
|
||||
"opStatusTray.tokens": "الرموز",
|
||||
"openInNewWindow": "فتح في نافذة جديدة",
|
||||
"operation.contextCompression": "السياق طويل جدًا، يتم ضغط السجل...",
|
||||
"operation.execAgentRuntime": "جارٍ تحضير الرد",
|
||||
@@ -915,6 +929,7 @@
|
||||
"workflow.toolDisplayName.addPreferenceMemory": "الذاكرة المحفوظة",
|
||||
"workflow.toolDisplayName.calculate": "محسوب",
|
||||
"workflow.toolDisplayName.callAgent": "تم استدعاء وكيل",
|
||||
"workflow.toolDisplayName.callMcpTool": "تم استدعاء أداة MCP",
|
||||
"workflow.toolDisplayName.callSubAgent": "تم إرسال وكيل فرعي",
|
||||
"workflow.toolDisplayName.clearTodos": "تم مسح المهام",
|
||||
"workflow.toolDisplayName.copyDocument": "تم نسخ مستند",
|
||||
@@ -1005,7 +1020,9 @@
|
||||
"workingPanel.localFile.closeRight": "إغلاق إلى اليمين",
|
||||
"workingPanel.localFile.error": "تعذر تحميل هذا الملف",
|
||||
"workingPanel.localFile.preview.raw": "خام",
|
||||
"workingPanel.localFile.preview.reload": "إعادة تحميل المعاينة",
|
||||
"workingPanel.localFile.preview.render": "معاينة",
|
||||
"workingPanel.localFile.preview.source": "المصدر",
|
||||
"workingPanel.localFile.truncated": "تم تقليص معاينة الملف إلى {{limit}} حرفًا",
|
||||
"workingPanel.progress": "Progress",
|
||||
"workingPanel.progress.allCompleted": "All tasks completed",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "تبديل إلى فرع جديد…",
|
||||
"workingDirectory.createBranchTitle": "إنشاء فرع جديد",
|
||||
"workingDirectory.current": "دليل العمل الحالي",
|
||||
"workingDirectory.deleteBranchAction": "حذف الفرع",
|
||||
"workingDirectory.deleteBranchConfirm": "هل تريد حذف الفرع “{{name}}”؟ سيؤدي ذلك إلى إزالته نهائيًا، بما في ذلك أي عمليات دمج غير مكتملة.",
|
||||
"workingDirectory.deleteBranchTitle": "حذف الفرع",
|
||||
"workingDirectory.deleteFailed": "فشل الحذف",
|
||||
"workingDirectory.detachedHead": "رأس منفصل عند {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "تمت الإضافة {{added}} · تم التعديل {{modified}} · تم الحذف {{deleted}}",
|
||||
"workingDirectory.filesAdded": "تمت الإضافة",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "حديث",
|
||||
"workingDirectory.refreshGitStatus": "تحديث حالة الفرع وطلبات السحب",
|
||||
"workingDirectory.removeRecent": "إزالة من الحديث",
|
||||
"workingDirectory.renameBranchAction": "إعادة تسمية الفرع",
|
||||
"workingDirectory.renameBranchTitle": "إعادة تسمية الفرع",
|
||||
"workingDirectory.renameFailed": "فشل إعادة التسمية",
|
||||
"workingDirectory.selectFolder": "اختر مجلدًا",
|
||||
"workingDirectory.title": "دليل العمل",
|
||||
"workingDirectory.topicDescription": "تجاوز الإعداد الافتراضي للوكيل لهذه المحادثة فقط",
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
"pageEditor.deleteSuccess": "تم حذف الصفحة بنجاح",
|
||||
"pageEditor.duplicateError": "فشل في تكرار الصفحة",
|
||||
"pageEditor.duplicateSuccess": "تم تكرار الصفحة بنجاح",
|
||||
"pageEditor.editMode.checking": "جارٍ التحقق من توفر التعديل…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} يقوم بتعديل هذا المستند",
|
||||
"pageEditor.editMode.lockedBySomeone": "شخص آخر يقوم بتعديل هذا المستند",
|
||||
"pageEditor.editedAt": "آخر تعديل في {{time}}",
|
||||
"pageEditor.editedBy": "آخر تعديل بواسطة {{name}}",
|
||||
"pageEditor.editorPlaceholder": "اضغط \"/\" للوصول إلى الذكاء الاصطناعي والأوامر",
|
||||
@@ -131,6 +134,8 @@
|
||||
"pageEditor.history.versionCount_one": "نسخة واحدة {{count}}",
|
||||
"pageEditor.history.versionCount_other": "{{count}} نسخ",
|
||||
"pageEditor.linkCopied": "تم نسخ الرابط",
|
||||
"pageEditor.lock.editingByOther": "{{name}} يقوم بتعديل هذه الصفحة. لا يمكن حفظ تغييراتك الآن.",
|
||||
"pageEditor.lock.editingBySomeone": "شخص آخر يقوم بتعديل هذه الصفحة. لا يمكن حفظ تغييراتك الآن.",
|
||||
"pageEditor.menu.copyLink": "نسخ الرابط",
|
||||
"pageEditor.menu.export": "تصدير",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken32k.hint": "لـ GLM-5 و GLM-4.7؛ يتحكم في ميزانية الرموز للتفكير (الحد الأقصى 32k).",
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningBudgetToken80k.hint": "لسلسلة Qwen3؛ يتحكم في ميزانية الرموز للتفكير (الحد الأقصى 80k).",
|
||||
"providerModels.item.modelConfig.extendParams.options.reasoningEffort.hint": "لنماذج OpenAI وغيرها من النماذج القادرة على الاستدلال؛ يتحكم في جهد الاستدلال.",
|
||||
"providerModels.item.modelConfig.extendParams.options.ring2_6ReasoningEffort.hint": "لسلسلة Ring 2.6؛ يتحكم في شدة التفكير.",
|
||||
"providerModels.item.modelConfig.extendParams.options.step3_5ReasoningEffort.hint": "بالنسبة لسلسلة Step 3.5؛ يتحكم في شدة التفكير.",
|
||||
"providerModels.item.modelConfig.extendParams.options.textVerbosity.hint": "لسلسلة GPT-5+؛ يتحكم في تفصيل النص الناتج.",
|
||||
"providerModels.item.modelConfig.extendParams.options.thinking.hint": "لبعض نماذج Doubao؛ يسمح للنموذج بتحديد ما إذا كان يجب التفكير بعمق.",
|
||||
@@ -306,6 +307,8 @@
|
||||
"providerModels.list.enabledActions.sort": "ترتيب النماذج المخصصة",
|
||||
"providerModels.list.enabledEmpty": "لا توجد نماذج مفعلة. يرجى تفعيل النماذج المفضلة من القائمة أدناه~",
|
||||
"providerModels.list.fetcher.clear": "مسح النماذج المسحوبة",
|
||||
"providerModels.list.fetcher.error": "فشل في جلب النماذج: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "خطأ غير معروف",
|
||||
"providerModels.list.fetcher.fetch": "جلب النماذج",
|
||||
"providerModels.list.fetcher.fetching": "جارٍ جلب قائمة النماذج...",
|
||||
"providerModels.list.fetcher.latestTime": "آخر تحديث: {{time}}",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"QuotaLimitReached": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى زيادة حصة المفتاح أو إعادة المحاولة لاحقًا.",
|
||||
"RateLimitExceeded": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى إعادة المحاولة لاحقًا أو زيادة حصة المفتاح.",
|
||||
"StateStorePersistError": "تسبب مشكلة مؤقتة في تخزين حالة المحادثة في تعطيل هذه العملية. يرجى إعادة المحاولة؛ إذا استمرت المشكلة، يرجى الاتصال بالدعم.",
|
||||
"StateStoreReadError": "تعذر استئناف هذه العملية لأن حالة الجلسة غير متوفرة. يرجى إعادة فتح المحادثة للمتابعة؛ وإذا استمرت المشكلة، يرجى الاتصال بالدعم.",
|
||||
"StreamChunkError": "حدث خطأ أثناء تحليل جزء الرسالة من الطلب المتدفق. يرجى التحقق مما إذا كانت واجهة API الحالية تتوافق مع المواصفات القياسية، أو الاتصال بمزود API للحصول على المساعدة.",
|
||||
"UpstreamGatewayError": "عاد البوابة أو الوكيل المصدر بخطأ. يرجى إعادة المحاولة قريبًا؛ إذا استمرت المشكلة، تحقق من إعدادات الوكيل / نقطة النهاية.",
|
||||
"UpstreamHttpError": "عاد المزود بخطأ HTTP دون تفاصيل إضافية. يرجى إعادة المحاولة، أو التحقق من الطلب وإعدادات النموذج.",
|
||||
|
||||
+12
-28
@@ -27,15 +27,15 @@
|
||||
"DeepSeek-OCR.description": "يعد DeepSeek-OCR نموذج رؤية-لغة من DeepSeek AI يركز على التعرف البصري على الحروف و\"الضغط السياقي البصري\". يستكشف ضغط السياق المستخرج من الصور، ويعالج المستندات بكفاءة، ويحوّلها إلى نص منظم (مثل Markdown). يقدّم دقة عالية في التعرف على النص داخل الصور، مما يجعله مناسباً لرقمنة المستندات واستخراج النصوص والمعالجة الهيكلية.",
|
||||
"DeepSeek-R1-Distill-Llama-70B.description": "تم تقطير DeepSeek R1، النموذج الأكبر والأذكى في مجموعة DeepSeek، إلى بنية Llama 70B. تُظهر المعايير والتقييمات البشرية أنه أذكى من Llama 70B الأساسي، خاصة في مهام الرياضيات ودقة الحقائق.",
|
||||
"DeepSeek-R1-Distill-Qwen-1.5B.description": "نموذج مقطر من DeepSeek-R1 يعتمد على Qwen2.5-Math-1.5B. يعمل التعلم المعزز وبيانات البداية الباردة على تحسين أداء الاستدلال، مما يضع معايير جديدة للمهام المتعددة في النماذج المفتوحة.",
|
||||
"DeepSeek-R1-Distill-Qwen-14B.description": "نماذج DeepSeek-R1-Distill مدربة بدقة من نماذج مفتوحة المصدر باستخدام بيانات عينة تم إنشاؤها بواسطة DeepSeek-R1.",
|
||||
"DeepSeek-R1-Distill-Qwen-32B.description": "نماذج DeepSeek-R1-Distill مدربة بدقة من نماذج مفتوحة المصدر باستخدام بيانات عينة تم إنشاؤها بواسطة DeepSeek-R1.",
|
||||
"DeepSeek-R1-Distill-Qwen-14B.description": "نموذج DeepSeek-R1 المقطر يعتمد على Qwen2.5-14B. يعزز التعلم المعزز وبيانات البداية الباردة أداء الاستدلال، مما يضع معايير جديدة للمهام المتعددة للنماذج المفتوحة.",
|
||||
"DeepSeek-R1-Distill-Qwen-32B.description": "سلسلة DeepSeek-R1 تحسن أداء الاستدلال باستخدام التعلم المعزز وبيانات البداية الباردة، مما يضع معايير جديدة للمهام المتعددة للنماذج المفتوحة ويتفوق على OpenAI o1-mini.",
|
||||
"DeepSeek-R1-Distill-Qwen-7B.description": "نموذج مقطر من DeepSeek-R1 يعتمد على Qwen2.5-Math-7B. يعمل التعلم المعزز وبيانات البداية الباردة على تحسين أداء الاستدلال، مما يضع معايير جديدة للمهام المتعددة في النماذج المفتوحة.",
|
||||
"DeepSeek-R1.description": "يطبق DeepSeek-R1 التعلم المعزز واسع النطاق في مرحلة ما بعد التدريب، مما يعزز قدرات الاستدلال بشكل كبير باستخدام القليل من البيانات الموسومة. يضاهي نموذج OpenAI o1 في مهام الرياضيات، البرمجة، والاستدلال اللغوي.",
|
||||
"DeepSeek-R1.description": "نموذج لغة كبير عالي الكفاءة وحديث، يتميز بالقوة في الاستدلال والرياضيات والبرمجة.",
|
||||
"DeepSeek-V3-1.description": "DeepSeek V3.1 هو نموذج استدلال من الجيل التالي يتميز بتحسينات في الاستدلال المعقد وسلسلة التفكير، مناسب لمهام التحليل العميق.",
|
||||
"DeepSeek-V3-Fast.description": "المزود: sophnet. DeepSeek V3 Fast هو الإصدار عالي السرعة من DeepSeek V3 0324، بدقة كاملة (غير مضغوطة) مع أداء أقوى في البرمجة والرياضيات واستجابات أسرع.",
|
||||
"DeepSeek-V3.1-Think.description": "وضع التفكير في DeepSeek-V3.1: نموذج استدلال هجين جديد يدعم أوضاع التفكير وغير التفكير، أكثر كفاءة من DeepSeek-R1-0528. التحسينات بعد التدريب تعزز بشكل كبير استخدام الأدوات وأداء المهام التي تتطلب وكلاء.",
|
||||
"DeepSeek-V3.2.description": "يقدم deepseek-v3.2 آلية انتباه متفرّق تهدف إلى تحسين كفاءة التدريب والاستدلال عند معالجة النصوص الطويلة، مع كلفة أقل مقارنة بـ deepseek-v3.1.",
|
||||
"DeepSeek-V3.description": "DeepSeek-V3 هو نموذج MoE تم تطويره بواسطة DeepSeek. يتفوق على نماذج مفتوحة أخرى مثل Qwen2.5-72B وLlama-3.1-405B في العديد من المعايير، ويتنافس مع النماذج المغلقة الرائدة مثل GPT-4o وClaude 3.5 Sonnet.",
|
||||
"DeepSeek-V3.description": "النشر المفتوح من ByteDance Volcengine هو الأكثر استقرارًا حاليًا؛ موصى به. تم ترقيته تلقائيًا إلى الإصدار الأحدث (250324).",
|
||||
"Doubao-lite-128k.description": "يوفر Doubao-lite استجابات فائقة السرعة وقيمة أفضل، مع خيارات مرنة عبر السيناريوهات. يدعم سياق 128K للاستدلال والتدريب الدقيق.",
|
||||
"Doubao-lite-32k.description": "يوفر Doubao-lite استجابات فائقة السرعة وقيمة أفضل، مع خيارات مرنة عبر السيناريوهات. يدعم سياق 32K للاستدلال والتدريب الدقيق.",
|
||||
"Doubao-lite-4k.description": "يوفر Doubao-lite استجابات فائقة السرعة وقيمة أفضل، مع خيارات مرنة عبر السيناريوهات. يدعم سياق 4K للاستدلال والتدريب الدقيق.",
|
||||
@@ -83,13 +83,12 @@
|
||||
"Kimi-K2.5.description": "Kimi K2.5 هو أقوى نموذج من سلسلة Kimi، ويقدم أداءً متقدماً مفتوح المصدر في مهام الوكلاء والبرمجة وفهم الرؤية. يدعم الإدخال متعدد الوسائط ووضعَي التفكير وغير التفكير.",
|
||||
"Kolors.description": "Kolors هو نموذج تحويل نص إلى صورة طوره فريق Kolors في Kuaishou. مدرب على مليارات المعاملات، يتميز بجودة بصرية عالية، فهم دلالي قوي للغة الصينية، وقدرات متميزة في عرض النصوص.",
|
||||
"Kwai-Kolors/Kolors.description": "Kolors هو نموذج تحويل نص إلى صورة واسع النطاق من فريق Kolors في Kuaishou. مدرب على مليارات أزواج النصوص والصور، يتفوق في الجودة البصرية، الدقة الدلالية المعقدة، وعرض النصوص الصينية/الإنجليزية، مع فهم وتوليد قويين للمحتوى الصيني.",
|
||||
"Ling-2.5-1T.description": "كنموذج رئيسي جديد في سلسلة Ling، يقدم Ling-2.5-1T ترقيات شاملة في بنية النموذج وكفاءة الرموز ومواءمة التفضيلات، بهدف رفع جودة الذكاء الاصطناعي المتاح إلى مستوى جديد.",
|
||||
"Ling-2.6-1T.description": "أحدث نموذج لغة كبير رئيسي، يدعم نافذة سياق تصل إلى 1 مليون رمز، مما يتيح سير عمل متكامل من الاستدلال المنطقي إلى تنفيذ المهام.",
|
||||
"Ling-2.6-flash.description": "Ling-2.6-flash هو الجيل الأحدث من النماذج عالية الأداء في سلسلة Ling. يعتمد على بنية Mixture-of-Experts (MoE)، مع إجمالي عدد معلمات يبلغ 100 مليار و6.1 مليار معلمة مفعلة لكل رمز، مما يحقق توازنًا مثاليًا بين أداء الاستدلال وتكلفة الحوسبة.",
|
||||
"Llama-3.2-11B-Vision-Instruct.description": "استدلال بصري قوي على الصور عالية الدقة، مناسب لتطبيقات الفهم البصري.",
|
||||
"Llama-3.2-90B-Vision-Instruct\t.description": "استدلال بصري متقدم لتطبيقات الفهم البصري المعتمدة على الوكلاء.",
|
||||
"Llama-3.2-90B-Vision-Instruct.description": "استدلال متقدم للصور لتطبيقات الوكلاء ذات الفهم البصري.",
|
||||
"LongCat-2.0-Preview.description": "الميزات الأساسية لـ LongCat-2.0-Preview هي كما يلي: مصمم لسيناريوهات تطوير الوكلاء، مع دعم أصلي لاستخدام الأدوات، التفكير متعدد الخطوات، ومهام السياق الطويل؛ يتفوق في توليد الأكواد، سير العمل الآلي، وتنفيذ التعليمات المعقدة؛ متكامل بعمق مع أدوات الإنتاجية مثل Claude Code، OpenClaw، OpenCode، وKilo Code.",
|
||||
"LongCat-Flash-Chat.description": "تم ترقية نموذج LongCat-Flash-Chat إلى إصدار جديد. يتضمن هذا التحديث تحسينات في قدرات النموذج فقط؛ يظل اسم النموذج وطريقة استدعاء API دون تغيير. بناءً على ميزاته المميزة مثل \"الكفاءة القصوى\" و\"الاستجابة السريعة للغاية\"، يعزز الإصدار الجديد فهم السياق وأداء البرمجة الواقعية: قدرات البرمجة المحسنة بشكل كبير: تم تحسين النموذج بشكل عميق لسيناريوهات المطورين، مما يوفر تحسينات كبيرة في مهام إنشاء الأكواد وتصحيح الأخطاء وشرحها. يُشجع المطورون بشدة على تقييم هذه التحسينات ومقارنتها. دعم سياق طويل للغاية 256K: تضاعف نافذة السياق من الجيل السابق (128K) إلى 256K، مما يتيح معالجة فعالة للوثائق الضخمة والمهام ذات التسلسل الطويل. تحسين شامل للأداء متعدد اللغات: يوفر دعمًا قويًا لتسع لغات، بما في ذلك الإسبانية والفرنسية والعربية والبرتغالية والروسية والإندونيسية. قدرات وكيل أكثر قوة: يظهر النموذج كفاءة أكبر في استدعاء الأدوات المعقدة وتنفيذ المهام متعددة الخطوات.",
|
||||
"LongCat-Flash-Lite.description": "تم إصدار نموذج LongCat-Flash-Lite رسميًا. يعتمد على بنية فعالة من نوع Mixture-of-Experts (MoE)، مع إجمالي 68.5 مليار معلمة وحوالي 3 مليارات معلمة مفعلة. من خلال استخدام جدول تضمين N-gram، يحقق استخدامًا فعالًا للغاية للمعلمات، وتم تحسينه بشكل عميق لكفاءة الاستنتاج وسيناريوهات التطبيقات المحددة. مقارنةً بالنماذج ذات الحجم المماثل، فإن ميزاته الأساسية هي كما يلي: كفاءة استنتاج ممتازة: من خلال الاستفادة من جدول تضمين N-gram لتخفيف عنق الزجاجة في الإدخال والإخراج في بنية MoE، جنبًا إلى جنب مع آليات التخزين المؤقت المخصصة وتحسينات على مستوى النواة، يقلل بشكل كبير من زمن الاستنتاج ويحسن الكفاءة العامة. أداء قوي في الوكيل والبرمجة: يظهر قدرات تنافسية عالية في استدعاء الأدوات ومهام تطوير البرمجيات، مما يوفر أداءً استثنائيًا بالنسبة لحجم النموذج.",
|
||||
"LongCat-Flash-Thinking-2601.description": "تم إصدار نموذج LongCat-Flash-Thinking-2601 رسميًا. كنموذج استنتاج مطور يعتمد على بنية Mixture-of-Experts (MoE)، يتميز بإجمالي 560 مليار معلمة. مع الحفاظ على تنافسية قوية عبر معايير الاستنتاج التقليدية، يعزز بشكل منهجي قدرات الاستنتاج على مستوى الوكيل من خلال التعلم المعزز متعدد البيئات واسع النطاق. مقارنةً بنموذج LongCat-Flash-Thinking، فإن الترقيات الرئيسية هي كما يلي: قوة استثنائية في البيئات المليئة بالضوضاء: من خلال تدريب منهجي بأسلوب المناهج يستهدف الضوضاء وعدم اليقين في البيئات الواقعية، يظهر النموذج أداءً ممتازًا في استدعاء أدوات الوكيل، البحث القائم على الوكيل، والاستنتاج المدمج بالأدوات، مع تحسين كبير في التعميم. قدرات وكيل قوية: من خلال إنشاء رسم بياني يعتمد على أكثر من 60 أداة، وتوسيع التدريب عبر بيئات متعددة واستكشاف واسع النطاق، يحسن النموذج بشكل ملحوظ قدرته على التعميم إلى سيناريوهات واقعية معقدة وخارج التوزيع. وضع التفكير العميق المتقدم: يوسع نطاق الاستنتاج عبر الاستنتاج المتوازي ويعمق القدرة التحليلية من خلال آليات التلخيص والتجريد المدفوعة بالتغذية الراجعة، مما يعالج المشكلات الصعبة للغاية بشكل فعال.",
|
||||
"LongCat-Flash-Thinking.description": "لضمان حصولك على أداء تفكير من الدرجة الأولى، قامت منصة LongCat API بتوحيد وترقية الطلبات إلى نموذج LongCat-Flash-Thinking. سيتم توجيه جميع الطلبات الحالية باستخدام `model=LongCat-Flash-Thinking` تلقائيًا إلى الإصدار الأحدث، LongCat-Flash-Thinking-2601، دون الحاجة إلى تغييرات في الكود.",
|
||||
"M2-her.description": "نموذج حوار نصي مصمم لتقمص الأدوار والمحادثات متعددة الأدوار، مع تخصيص الشخصيات والتعبير العاطفي.",
|
||||
"Meta-Llama-3-3-70B-Instruct.description": "Llama 3.3 70B هو نموذج Transformer متعدد الاستخدامات لمهام المحادثة والتوليد.",
|
||||
"Meta-Llama-3.1-405B-Instruct.description": "نموذج Llama 3.1 مضبوط على التعليمات، محسن للمحادثة متعددة اللغات، ويؤدي بقوة في معايير الصناعة الشائعة بين النماذج المفتوحة والمغلقة.",
|
||||
@@ -187,27 +186,10 @@
|
||||
"Qwen2.5-Coder-14B-Instruct.description": "Qwen2.5-Coder-14B-Instruct هو نموذج تعليمات برمجة مدرب مسبقًا على نطاق واسع يتمتع بفهم وتوليد قوي للشيفرة. يتعامل بكفاءة مع مجموعة واسعة من مهام البرمجة، ومثالي للبرمجة الذكية، وتوليد السكربتات التلقائي، والأسئلة والأجوبة البرمجية.",
|
||||
"Qwen2.5-Coder-32B-Instruct.description": "نموذج لغوي متقدم لتوليد الشيفرة، والاستدلال، وإصلاح الأخطاء عبر لغات البرمجة الرئيسية.",
|
||||
"Qwen3-235B-A22B-Instruct-2507-FP8.description": "Qwen3 235B A22B Instruct 2507 مُحسَّن للاستدلال المتقدم واتباع التعليمات، ويستخدم بنية MoE للحفاظ على كفاءة الاستدلال على نطاق واسع.",
|
||||
"Qwen3-235B.description": "Qwen3-235B-A22B هو نموذج MoE يُقدِّم وضع استدلال هجين، يتيح للمستخدمين التبديل بسلاسة بين التفكير وعدم التفكير. يدعم الفهم والاستدلال عبر 119 لغة ولهجة، ويتمتع بقدرات قوية على استدعاء الأدوات، ويتنافس مع نماذج رائدة مثل DeepSeek R1 وOpenAI o1 وo3-mini وGrok 3 وGoogle Gemini 2.5 Pro في اختبارات القدرات العامة، والبرمجة والرياضيات، والقدرات متعددة اللغات، واستدلال المعرفة.",
|
||||
"Qwen3-32B.description": "Qwen3-32B هو نموذج كثيف يُقدِّم وضع استدلال هجين، يتيح للمستخدمين التبديل بين التفكير وعدم التفكير. بفضل تحسينات في البنية، وبيانات أكثر، وتدريب أفضل، يقدم أداءً مماثلًا لـ Qwen2.5-72B.",
|
||||
"Qwen3.5-Plus.description": "يدعم Qwen3.5 Plus إدخال النصوص والصور والفيديو. أداؤه في المهام النصية البحتة مماثل لـ Qwen3 Max، مع أداء أفضل وتكلفة أقل. وقد تحسّنت قدراته متعددة الوسائط بشكل ملحوظ مقارنة بسلسلة Qwen3 VL.",
|
||||
"Ring-2.5-1T.description": "بالمقارنة مع Ring-1T الذي تم إصداره سابقًا، يحقق Ring-2.5-1T تحسينات كبيرة عبر ثلاثة أبعاد رئيسية: كفاءة التوليد، عمق الاستدلال، وقدرة تنفيذ المهام طويلة الأمد: **كفاءة التوليد**: من خلال الاستفادة من نسبة عالية من آليات الانتباه الخطي، يقلل Ring-2.5-1T من عبء الوصول إلى الذاكرة بأكثر من 10×. عند معالجة تسلسلات تتجاوز 32 ألف رمز، يوفر إنتاجية توليد أعلى بأكثر من 3×، مما يجعله مناسبًا بشكل خاص للاستدلال العميق وتنفيذ المهام طويلة الأمد. **الاستدلال العميق**: بناءً على RLVR، يتم تقديم آلية مكافأة كثيفة لتوفير تغذية راجعة حول دقة عملية الاستدلال. يتيح ذلك لـ Ring-2.5-1T تحقيق أداء بمستوى الميدالية الذهبية في كل من IMO 2025 وCMO 2025 (تقييم ذاتي). **تنفيذ المهام طويلة الأمد**: من خلال تدريب واسع النطاق قائم على التعلم المعزز غير المتزامن بالكامل، يعزز النموذج بشكل كبير قدرته على تنفيذ المهام المعقدة بشكل مستقل على مدى فترات طويلة. يتيح ذلك لـ Ring-2.5-1T التكامل بسلاسة مع أطر برمجة الوكلاء مثل Claude Code ومساعدي الذكاء الاصطناعي الشخصيين OpenClaw.",
|
||||
"Ring-2.6-1T.description": "Ring-2.6-1T هو نموذج استدلال بمقياس تريليون معلمة يقوم بتفعيل حوالي 63 مليار معلمة لكل استدلال. مصمم لسير عمل الوكلاء، يركز على قدرات الوكلاء، واستخدام الأدوات، وتنفيذ المهام طويلة الأمد، محققًا أداءً رائدًا في معايير مثل PinchBench وClawEval وTAU2-Bench وGAIA2-search. تم تحسين النموذج عبر جودة التنفيذ، والكمون، والتكلفة، مما يجعله مناسبًا لوكلاء البرمجة المتقدمة، وخطوط الاستدلال المعقدة، والأنظمة المستقلة واسعة النطاق.",
|
||||
"S2V-01.description": "النموذج الأساسي لتحويل المرجع إلى فيديو من سلسلة 01.",
|
||||
"SenseChat-128K.description": "الإصدار الرابع الأساسي مع سياق 128 ألف رمز، قوي في فهم وتوليد النصوص الطويلة.",
|
||||
"SenseChat-32K.description": "الإصدار الرابع الأساسي مع سياق 32 ألف رمز، مرن لمجموعة متنوعة من السيناريوهات.",
|
||||
"SenseChat-5-1202.description": "أحدث إصدار مبني على V5.5، مع تحسينات كبيرة في الأساسيات الصينية/الإنجليزية، والدردشة، ومعرفة العلوم والتكنولوجيا، والمعرفة الإنسانية، والكتابة، والرياضيات/المنطق، والتحكم في الطول.",
|
||||
"SenseChat-5-Cantonese.description": "مصمم ليتماشى مع عادات الحوار في هونغ كونغ، واللغة العامية، والمعرفة المحلية؛ يتفوق على GPT-4 في فهم الكانتونية ويضاهي GPT-4 Turbo في المعرفة، والاستدلال، والرياضيات، والبرمجة.",
|
||||
"SenseChat-5-beta.description": "يتفوق في بعض الجوانب على SenseChat-5-1202.",
|
||||
"SenseChat-5.description": "أحدث إصدار V5.5 مع سياق 128 ألف رمز؛ تحسينات كبيرة في الاستدلال الرياضي، والدردشة باللغة الإنجليزية، واتباع التعليمات، وفهم النصوص الطويلة، ويقارن بـ GPT-4o.",
|
||||
"SenseChat-Character-Pro.description": "نموذج دردشة متقدم للشخصيات مع سياق 32 ألف رمز، وقدرات محسنة، ودعم للغتين الصينية والإنجليزية.",
|
||||
"SenseChat-Character.description": "نموذج دردشة قياسي للشخصيات مع سياق 8 آلاف رمز وسرعة استجابة عالية.",
|
||||
"SenseChat-Turbo-1202.description": "أحدث نموذج خفيف الوزن يصل إلى أكثر من 90% من قدرات النموذج الكامل بتكلفة تنفيذ أقل بكثير.",
|
||||
"SenseChat-Turbo.description": "مناسب لأسئلة وأجوبة سريعة وسيناريوهات تحسين النماذج.",
|
||||
"SenseChat-Vision.description": "أحدث إصدار V5.5 مع إدخال متعدد الصور وتحسينات شاملة في التعرف على السمات، والعلاقات المكانية، واكتشاف الأحداث/الحركات، وفهم المشاهد، والتعرف على المشاعر، والاستدلال المنطقي، وفهم/توليد النصوص.",
|
||||
"SenseChat.description": "الإصدار الرابع الأساسي مع سياق 4 آلاف رمز وقدرات عامة قوية.",
|
||||
"SenseNova-V6-5-Pro.description": "مع تحديثات شاملة في البيانات متعددة الوسائط واللغوية والاستدلالية، إلى جانب تحسين استراتيجية التدريب، يُظهر النموذج الجديد تحسنًا كبيرًا في الاستدلال متعدد الوسائط واتباع التعليمات العامة، ويدعم نافذة سياق تصل إلى 128 ألف رمز، ويتفوق في مهام التعرف على النصوص (OCR) والتعرف على الملكية الفكرية في السياحة الثقافية.",
|
||||
"SenseNova-V6-5-Turbo.description": "مع تحديثات شاملة في البيانات متعددة الوسائط واللغوية والاستدلالية، إلى جانب تحسين استراتيجية التدريب، يُظهر النموذج الجديد تحسنًا كبيرًا في الاستدلال متعدد الوسائط واتباع التعليمات العامة، ويدعم نافذة سياق تصل إلى 128 ألف رمز، ويتفوق في مهام التعرف على النصوص (OCR) والتعرف على الملكية الفكرية في السياحة الثقافية.",
|
||||
"SenseNova-V6-Pro.description": "يوحد بشكل أصيل بين الصورة والنص والفيديو، متجاوزًا الحواجز التقليدية بين الوسائط المتعددة؛ ويحتل المراتب الأولى في OpenCompass وSuperCLUE.",
|
||||
"SenseNova-V6-Reasoner.description": "يجمع بين الرؤية واللغة في استدلال عميق، ويدعم التفكير البطيء وسلسلة التفكير الكاملة.",
|
||||
"SenseNova-V6-Turbo.description": "يوحد بشكل أصيل بين الصورة والنص والفيديو، متجاوزًا الحواجز التقليدية بين الوسائط المتعددة. يتفوق في القدرات الأساسية للوسائط المتعددة واللغة، ويحتل مرتبة متقدمة في العديد من التقييمات.",
|
||||
"Skylark2-lite-8k.description": "الجيل الثاني من نموذج Skylark. يتميز Skylark2-lite بسرعة استجابة عالية في السيناريوهات الحساسة للتكلفة والتي لا تتطلب دقة عالية، مع نافذة سياق تصل إلى 8 آلاف رمز.",
|
||||
"Skylark2-pro-32k.description": "الجيل الثاني من نموذج Skylark. يوفر Skylark2-pro دقة أعلى في توليد النصوص المعقدة مثل كتابة المحتوى الاحترافي، وتأليف الروايات، والترجمة عالية الجودة، مع نافذة سياق تصل إلى 32 ألف رمز.",
|
||||
"Skylark2-pro-4k.description": "الجيل الثاني من نموذج Skylark. يوفر Skylark2-pro دقة أعلى في توليد النصوص المعقدة مثل كتابة المحتوى الاحترافي، وتأليف الروايات، والترجمة عالية الجودة، مع نافذة سياق تصل إلى 4 آلاف رمز.",
|
||||
@@ -1197,6 +1179,8 @@
|
||||
"r1-1776.description": "R1-1776 هو إصدار ما بعد التدريب من DeepSeek R1 مصمم لتقديم معلومات واقعية غير خاضعة للرقابة أو التحيز.",
|
||||
"seedance-1-5-pro-251215.description": "Seedance 1.5 Pro من ByteDance يدعم تحويل النص إلى فيديو، تحويل الصورة إلى فيديو (الإطار الأول، الإطار الأول + الأخير)، وتوليد الصوت المتزامن مع المرئيات.",
|
||||
"seedream-5-0-260128.description": "ByteDance-Seedream-5.0-lite من BytePlus يتميز بتوليد معزز بالاسترجاع من الويب للحصول على معلومات في الوقت الفعلي، تفسير محسّن للمطالبات المعقدة، وتحسين اتساق المراجع لإنشاء مرئي احترافي.",
|
||||
"sensenova-6.7-flash-lite.description": "نموذج وكيل متعدد الوسائط خفيف الوزن مصمم لسير العمل الواقعي، يدعم المحادثات النصية وفهم الصور. خفيف الوزن وفعال، يوازن بين الأداء والتكلفة وقابلية النشر. بنية متعددة الوسائط أصلية مع دعم لفهم الصور، بما في ذلك التعرف الضوئي على الحروف (OCR) وتفسير الرسوم البيانية. معزز لسيناريوهات المكتب والإنتاجية، مع دعم مستقر للمهام المعقدة طويلة السلسلة. تحسين كفاءة الرموز، مما يتيح تحكمًا أفضل في التكلفة لأعباء العمل المعقدة. طول السياق يصل إلى 256 ألف رمز (المدخلات القصوى: 252 ألف، المخرجات القصوى: 64 ألف).",
|
||||
"sensenova-u1-fast.description": "نسخة مسرعة تعتمد على SenseNova U1، تم تحسينها خصيصًا لإنشاء الرسوم المعلوماتية.",
|
||||
"solar-mini-ja.description": "Solar Mini (Ja) يوسع Solar Mini مع تركيز على اللغة اليابانية مع الحفاظ على الأداء القوي والكفاءة في الإنجليزية والكورية.",
|
||||
"solar-mini.description": "Solar Mini هو نموذج لغة مدمج يتفوق على GPT-3.5، يتميز بقدرات متعددة اللغات قوية تدعم الإنجليزية والكورية، ويقدم حلاً فعالاً بصمة صغيرة.",
|
||||
"solar-pro.description": "Solar Pro هو نموذج لغة عالي الذكاء من Upstage، يركز على اتباع التعليمات باستخدام وحدة معالجة رسومات واحدة، مع درجات IFEval تتجاوز 80. حالياً يدعم اللغة الإنجليزية؛ وكان من المقرر إصدار النسخة الكاملة في نوفمبر 2024 مع دعم لغات موسع وسياق أطول.",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"storage_overage_cap_reached_title": "تم الوصول إلى الحد الأقصى للدفع حسب الاستخدام للتخزين",
|
||||
"video_generation_completed": "الفيديو الخاص بك \"{{prompt}}\" جاهز.",
|
||||
"video_generation_completed_title": "اكتملت عملية إنشاء الفيديو",
|
||||
"workspace_member_invited": "{{inviterLabel}} دعاك للانضمام إلى مساحة العمل \"{{workspaceName}}\" ك{{role}}.",
|
||||
"workspace_member_invited_title": "دعوة للانضمام إلى {{workspaceName}}",
|
||||
"workspace_member_joined": "{{memberLabel}} انضم إلى مساحة العمل \"{{workspaceName}}\" كـ {{role}}.",
|
||||
"workspace_member_joined_member": "{{memberLabel}} انضم إلى مساحة العمل \"{{workspaceName}}\" كعضو.",
|
||||
"workspace_member_joined_member_title": "عضو جديد انضم إلى {{workspaceName}}",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"يعمل",
|
||||
"يُصمم",
|
||||
"يفكر",
|
||||
"يحسب",
|
||||
"يُخمّر",
|
||||
"يُركّب",
|
||||
"يُحلل",
|
||||
"يُهندس",
|
||||
"يؤلف",
|
||||
"يُنسق",
|
||||
"يرسم",
|
||||
"يُبدع",
|
||||
"يتأمل",
|
||||
"يصنع",
|
||||
"يُشعل",
|
||||
"يُغلي ببطء",
|
||||
"يُدور",
|
||||
"يُسيطر",
|
||||
"يُلمع",
|
||||
"يُجهز الإجابة",
|
||||
"يخبز",
|
||||
"يُوجه",
|
||||
"يُدمج",
|
||||
"يُفك الشيفرة",
|
||||
"يُصنع",
|
||||
"يُوائم",
|
||||
"يُرتجل",
|
||||
"يستنتج",
|
||||
"يُجرب",
|
||||
"يتعرج"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,34 @@
|
||||
{
|
||||
"arguments.moreParams": "إجمالي {{count}} من المعاملات",
|
||||
"arguments.title": "المعلمات",
|
||||
"builtins.codex.apiName.collab_tool_call": "تنسيق الوكلاء الفرعيين",
|
||||
"builtins.codex.apiName.command_execution": "تشغيل الأمر",
|
||||
"builtins.codex.apiName.file_change": "تعديل الملفات",
|
||||
"builtins.codex.apiName.mcp_tool_call": "استدعاء أداة MCP",
|
||||
"builtins.codex.apiName.todo_list": "تحديث المهام",
|
||||
"builtins.codex.apiName.web_search": "البحث على الويب",
|
||||
"builtins.codex.collabTool.agentCount_one": "{{count}} وكيل فرعي",
|
||||
"builtins.codex.collabTool.agentCount_other": "{{count}} وكلاء فرعيون",
|
||||
"builtins.codex.collabTool.agentLabel": "وكيل فرعي {{index}}",
|
||||
"builtins.codex.collabTool.agents": "الوكلاء الفرعيون",
|
||||
"builtins.codex.collabTool.closeAgent": "إغلاق الوكيل الفرعي",
|
||||
"builtins.codex.collabTool.instruction": "تعليمات",
|
||||
"builtins.codex.collabTool.sendInput": "إرسال رسالة إلى الوكيل الفرعي",
|
||||
"builtins.codex.collabTool.spawnAgent": "إنشاء وكيل فرعي",
|
||||
"builtins.codex.collabTool.wait": "انتظر الوكلاء الفرعيين",
|
||||
"builtins.codex.commandExecution.grep": "بحث",
|
||||
"builtins.codex.commandExecution.noResults": "لا توجد نتائج",
|
||||
"builtins.codex.commandExecution.readFile": "قراءة الملف",
|
||||
"builtins.codex.fileChange.editedFiles_one": "تم تعديل {{count}} ملف",
|
||||
"builtins.codex.fileChange.editedFiles_other": "تم تعديل {{count}} ملفات",
|
||||
"builtins.codex.fileChange.editing": "تعديل الملفات",
|
||||
"builtins.codex.fileChange.noChanges": "لا توجد تغييرات في الملفات",
|
||||
"builtins.codex.fileChange.unknownFile": "ملف غير معروف",
|
||||
"builtins.codex.mcpTool.error": "خطأ",
|
||||
"builtins.codex.mcpTool.input": "إدخال",
|
||||
"builtins.codex.mcpTool.result": "النتيجة",
|
||||
"builtins.codex.mcpTool.unknownTool": "أداة MCP",
|
||||
"builtins.codex.webSearch.query": "استعلام",
|
||||
"builtins.lobe-activator.apiName.activateTools": "تفعيل الأدوات",
|
||||
"builtins.lobe-activator.inspector.activateTools.notFoundCount": "{{count}} غير موجود",
|
||||
"builtins.lobe-agent-builder.apiName.getAvailableModels": "الحصول على النماذج المتاحة",
|
||||
@@ -429,6 +457,15 @@
|
||||
"dev.mcp.auth.desc": "اختر طريقة المصادقة لخادم MCP",
|
||||
"dev.mcp.auth.label": "نوع المصادقة",
|
||||
"dev.mcp.auth.none": "بدون مصادقة",
|
||||
"dev.mcp.auth.oauth": "أووث",
|
||||
"dev.mcp.auth.oauth.authorize": "التفويض والاتصال",
|
||||
"dev.mcp.auth.oauth.clientId.desc": "اتركه فارغًا لتسجيل عميل تلقائيًا (تسجيل عميل ديناميكي)",
|
||||
"dev.mcp.auth.oauth.clientId.label": "معرف عميل أووث",
|
||||
"dev.mcp.auth.oauth.clientId.placeholder": "اختياري",
|
||||
"dev.mcp.auth.oauth.clientSecret.desc": "مطلوب فقط لعملاء أووث السريين",
|
||||
"dev.mcp.auth.oauth.clientSecret.label": "سر عميل أووث",
|
||||
"dev.mcp.auth.oauth.clientSecret.placeholder": "اختياري",
|
||||
"dev.mcp.auth.oauth.redirectHint": "عنوان URI لإعادة التوجيه لتسجيله مع تطبيق أووث الخاص بك:",
|
||||
"dev.mcp.auth.placeholder": "اختر نوع المصادقة",
|
||||
"dev.mcp.auth.token.desc": "أدخل مفتاح API أو رمز Bearer",
|
||||
"dev.mcp.auth.token.label": "مفتاح API",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"ai360.description": "360 AI هي منصة نماذج وخدمات من شركة 360، تقدم نماذج معالجة اللغة الطبيعية مثل 360GPT2 Pro و360GPT Pro و360GPT Turbo. تجمع هذه النماذج بين المعلمات واسعة النطاق والقدرات متعددة الوسائط لتوليد النصوص، وفهم المعاني، والدردشة، والبرمجة، مع تسعير مرن لتلبية احتياجات متنوعة.",
|
||||
"aihubmix.description": "يوفر AiHubMix الوصول إلى نماذج ذكاء اصطناعي متعددة من خلال واجهة برمجة تطبيقات موحدة.",
|
||||
"akashchat.description": "أكاش هو سوق موارد سحابية غير مركزي يتميز بأسعار تنافسية مقارنة بمزودي الخدمات السحابية التقليديين.",
|
||||
"antgroup.description": "Ant Ling هو سلسلة النماذج الأساسية لمبادرة الذكاء العام الاصطناعي (AGI) التابعة لمجموعة Ant Group، مكرسة لبناء وفتح قدرات النماذج الأساسية المتقدمة. نحن نؤمن بأن تطوير الذكاء يجب أن يتجه نحو الانفتاح والمشاركة والقابلية للتوسع—بدءًا من خطوات صغيرة وعملية لدفع التطور المستمر ونشر الذكاء العام الاصطناعي في العالم الحقيقي.",
|
||||
"anthropic.description": "تقوم Anthropic بتطوير نماذج لغوية متقدمة مثل Claude 3.5 Sonnet وClaude 3 Sonnet وClaude 3 Opus وClaude 3 Haiku، وتوازن بين الذكاء والسرعة والتكلفة لتناسب مختلف حالات الاستخدام من المؤسسات إلى الاستجابات السريعة.",
|
||||
"azure.description": "تقدم Azure نماذج ذكاء اصطناعي متقدمة، بما في ذلك سلسلة GPT-3.5 وGPT-4، لمعالجة أنواع بيانات متنوعة ومهام معقدة مع التركيز على الأمان والموثوقية والاستدامة.",
|
||||
"azureai.description": "توفر Azure نماذج ذكاء اصطناعي متقدمة، بما في ذلك سلسلة GPT-3.5 وGPT-4، لمعالجة أنواع بيانات متنوعة ومهام معقدة مع التركيز على الأمان والموثوقية والاستدامة.",
|
||||
|
||||
+39
-1
@@ -280,7 +280,33 @@
|
||||
"defaultAgent.title": "إعدادات الوكيل الافتراضي",
|
||||
"devices.actions.edit": "تعديل",
|
||||
"devices.actions.remove": "إزالة",
|
||||
"devices.capabilities.commands.desc": "قم بتنفيذ أوامر الطرفية بأمان في بيئتك.",
|
||||
"devices.capabilities.commands.title": "تشغيل الأوامر",
|
||||
"devices.capabilities.files.desc": "اسمح للوكلاء بالوصول المباشر إلى الملفات على جهاز الكمبيوتر الخاص بك وتنظيمها.",
|
||||
"devices.capabilities.files.title": "قراءة وكتابة الملفات المحلية",
|
||||
"devices.capabilities.title": "ما يمكنك فعله بمجرد الاتصال",
|
||||
"devices.capabilities.tools.desc": "قم بتوصيل الأدوات المحلية لتوسيع ما يمكن للوكلاء القيام به.",
|
||||
"devices.capabilities.tools.title": "استدعاء أدوات النظام",
|
||||
"devices.channel.connected": "متصل {{time}}",
|
||||
"devices.connectWizard.button": "اتصال الجهاز",
|
||||
"devices.connectWizard.cli.connectDesc": "ابدأ تشغيل الخلفية للحفاظ على الجهاز متصلاً ومستعدًا للعمليات عن بُعد.",
|
||||
"devices.connectWizard.cli.connectTitle": "ابدأ تشغيل الخلفية",
|
||||
"devices.connectWizard.cli.installDesc": "قم بتثبيت LobeHub CLI عالميًا باستخدام مدير الحزم المفضل لديك لتمكين الاتصال وإدارة الجهاز.",
|
||||
"devices.connectWizard.cli.installTitle": "تثبيت CLI",
|
||||
"devices.connectWizard.cli.loginDesc": "أكمل تفويض OAuth في متصفحك لربط CLI بحسابك.",
|
||||
"devices.connectWizard.cli.loginTitle": "تسجيل الدخول",
|
||||
"devices.connectWizard.desktop.downloadLink": "تحميل تطبيق LobeHub Desktop",
|
||||
"devices.connectWizard.desktop.step1": "قم بتنزيل تطبيق سطح المكتب",
|
||||
"devices.connectWizard.desktop.step1Desc": "قم بزيارة صفحة تنزيلات LobeHub واحصل على التطبيق لنظام التشغيل الخاص بك.",
|
||||
"devices.connectWizard.desktop.step2": "سجل الدخول وافتح بوابة الجهاز",
|
||||
"devices.connectWizard.desktop.step2Desc": "بعد تسجيل الدخول، انقر على أيقونة بوابة الجهاز في الزاوية العلوية اليمنى وتأكد من تشغيلها.",
|
||||
"devices.connectWizard.desktop.step3": "يظهر جهازك تلقائيًا",
|
||||
"devices.connectWizard.desktop.step3Desc": "يسجل تطبيق سطح المكتب نفسه كجهاز عند التشغيل — ستراه في القائمة بمجرد الاتصال.",
|
||||
"devices.connectWizard.footer": "يتم تسجيل بيانات تعريف الجهاز فقط — لا يتم الوصول إلى بياناتك أبدًا.",
|
||||
"devices.connectWizard.method.cli": "عبر CLI",
|
||||
"devices.connectWizard.method.desktop": "عبر سطح المكتب",
|
||||
"devices.connectWizard.subtitle": "اختر كيفية توصيل جهاز الكمبيوتر الخاص بك بـ LobeHub.",
|
||||
"devices.connectWizard.title": "اتصال الجهاز",
|
||||
"devices.currentBadge": "هذا الجهاز",
|
||||
"devices.detail.addDir": "إضافة دليل",
|
||||
"devices.detail.connections": "الاتصالات",
|
||||
@@ -294,7 +320,13 @@
|
||||
"devices.edit.friendlyNamePlaceholder": "اسم للتعرف على هذا الجهاز",
|
||||
"devices.edit.save": "حفظ",
|
||||
"devices.edit.title": "تعديل الجهاز",
|
||||
"devices.empty": "لا توجد أجهزة حتى الآن. قم بتوصيل جهاز باستخدام `lh connect` أو عن طريق تسجيل الدخول إلى تطبيق سطح المكتب.",
|
||||
"devices.empty.desc": "بمجرد الاتصال، يمكن لوكلاء LobeHub قراءة/كتابة الملفات، تشغيل الأوامر، واستدعاء أدوات النظام مباشرة على جهاز الكمبيوتر الخاص بك.",
|
||||
"devices.empty.methodCli.desc": "قم بتثبيت CLI في الطرفية الخاصة بك — مثالي للخوادم أو الأجهزة بدون واجهة.",
|
||||
"devices.empty.methodCli.title": "الاتصال عبر CLI",
|
||||
"devices.empty.methodDesktop.badge": "موصى به",
|
||||
"devices.empty.methodDesktop.desc": "قم بتنزيل تطبيق سطح المكتب، سجل الدخول، وسيتم توصيل جهازك تلقائيًا.",
|
||||
"devices.empty.methodDesktop.title": "الاتصال عبر سطح المكتب",
|
||||
"devices.empty.title": "قم بتوصيل جهازك الأول",
|
||||
"devices.fallbackBadge": "هوية غير مستقرة",
|
||||
"devices.fallbackTooltip": "لم يتمكن هذا الجهاز من التعرف عليه بواسطة معرف الجهاز، لذا قد يؤدي إعادة تثبيت التطبيق إلى إنشاء إدخال مكرر.",
|
||||
"devices.lastSeen": "آخر نشاط {{time}}",
|
||||
@@ -522,6 +554,7 @@
|
||||
"notification.item.image_generation_completed": "اكتمل إنشاء الصورة",
|
||||
"notification.item.storage_overage_cap_reached": "تم الوصول إلى الحد الأقصى للدفع حسب الاستخدام للتخزين",
|
||||
"notification.item.video_generation_completed": "اكتمل إنشاء الفيديو",
|
||||
"notification.item.workspace_member_invited": "دعوة إلى مساحة العمل",
|
||||
"notification.item.workspace_member_joined": "انضم عضو جديد",
|
||||
"notification.item.workspace_member_removed": "تمت إزالته من مساحة العمل",
|
||||
"notification.item.workspace_payment_failed": "فشل تجديد الدفع",
|
||||
@@ -1153,6 +1186,9 @@
|
||||
"tools.klavis.notEnabled": "خدمة Klavis غير مفعلة",
|
||||
"tools.klavis.oauthRequired": "يرجى إكمال التحقق من OAuth في النافذة الجديدة",
|
||||
"tools.klavis.pendingAuth": "في انتظار التحقق",
|
||||
"tools.klavis.remove": "إزالة",
|
||||
"tools.klavis.removeConfirm.desc": "سيتم إزالة {{name}} نهائيًا من خدماتك المتصلة. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"tools.klavis.removeConfirm.title": "إزالة {{name}}؟",
|
||||
"tools.klavis.serverCreated": "تم إنشاء الخادم بنجاح",
|
||||
"tools.klavis.serverCreatedFailed": "فشل في إنشاء الخادم",
|
||||
"tools.klavis.serverRemoved": "تمت إزالة الخادم",
|
||||
@@ -1766,6 +1802,7 @@
|
||||
"workspace.members.invite.errors.alreadyMember": "{{email}} هو بالفعل عضو في هذه مساحة العمل.",
|
||||
"workspace.members.invite.failed": "فشل في إرسال الدعوة",
|
||||
"workspace.members.invite.limitReached": "يمكن أن تحتوي هذه مساحة العمل على ما يصل إلى {{limit}} أعضاء. قم بإزالة عضو قبل دعوة المزيد.",
|
||||
"workspace.members.invite.modal.billIncrease": "ستزيد فاتورتك بمقدار ${{amount}}/شهريًا.",
|
||||
"workspace.members.invite.modal.cancel": "إلغاء",
|
||||
"workspace.members.invite.modal.confirm": "تأكيد",
|
||||
"workspace.members.invite.modal.description_one": "فريقك يتوسع! بالتأكيد، ستدعو عضو فريق جديد واحد إلى هذه مساحة العمل.",
|
||||
@@ -1873,6 +1910,7 @@
|
||||
"workspace.upgradeModal.chargeDisclosure": "عند النقر على الترقية، سيتم فرض رسوم ${{fee}}، بالإضافة إلى أي ضرائب ورسوم قابلة للتطبيق، فورًا ثم كل شهر، حتى تقوم بالإلغاء. يتم تسوية رسوم المقاعد والاستخدام حسب الطلب في نهاية الشهر؛ إذا تجاوز استخدامك حد الفوترة خلال دورة، قد يتم فرض رسوم على طريقة الدفع الخاصة بك قبل انتهاء الدورة.",
|
||||
"workspace.upgradeModal.continueCta": "متابعة",
|
||||
"workspace.upgradeModal.createTeam": "إنشاء مساحة العمل",
|
||||
"workspace.upgradeModal.formDescription": "راجع التفاصيل أدناه وقم بتأكيد الترقية.",
|
||||
"workspace.upgradeModal.formSubtitle": "يتم فرض رسوم المنصة فقط اليوم — يتم تسوية رسوم المقاعد في نهاية الشهر.",
|
||||
"workspace.upgradeModal.formTitle": "ترقية {{name}} إلى Pro",
|
||||
"workspace.upgradeModal.heading": "ترقية مساحة العمل إلى Pro",
|
||||
|
||||
@@ -135,6 +135,12 @@
|
||||
"management.view.card": "بطاقة",
|
||||
"management.view.list": "قائمة",
|
||||
"newTopic": "موضوع جديد",
|
||||
"projectStatus.failed_one": "{{count}} موضوع فشل",
|
||||
"projectStatus.failed_other": "{{count}} مواضيع فشلت",
|
||||
"projectStatus.loading_one": "{{count}} موضوع قيد التحميل",
|
||||
"projectStatus.loading_other": "{{count}} مواضيع قيد التحميل",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} موضوع ينتظر الإدخال",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} مواضيع تنتظر الإدخال",
|
||||
"renameModal.description": "يُفضَّل أن يكون قصيرًا وسهل التعرّف.",
|
||||
"renameModal.title": "إعادة تسمية الموضوع",
|
||||
"searchPlaceholder": "ابحث في المواضيع...",
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"agentBuilder.installPlugin.retry": "Опитай отново",
|
||||
"agentBuilder.title": "Създател на Агенти",
|
||||
"agentBuilder.welcome": "Разкажете ми за вашия случай на употреба.\n\nПисане, програмиране или анализ на данни — всичко е възможно. Вие определяте целта и стандартите; аз ще ги разделя на съвместими, изпълними Агенти.",
|
||||
"agentConfigError.retry": "Опитай отново",
|
||||
"agentConfigError.title": "Неуспешно зареждане на настройките на агента",
|
||||
"agentDefaultMessage": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно.\n\nИскате да се адаптирам по-добре към вашия работен процес? Отидете в [Настройки на Агента]({{url}}) и попълнете Профила на Агента (можете да го редактирате по всяко време).",
|
||||
"agentDefaultMessageWithSystemRole": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно — вие контролирате.",
|
||||
"agentDefaultMessageWithoutEdit": "Здравей, аз съм **{{name}}**. Едно изречение е достатъчно — вие контролирате.",
|
||||
@@ -252,6 +254,10 @@
|
||||
"input.costEstimate.tooltip": "Оценено въз основа на текущия контекст, инструменти и ценообразуване на модела. Реалната цена може да варира.",
|
||||
"input.disclaimer": "Агентите могат да допускат грешки. Използвайте собствена преценка за важна информация.",
|
||||
"input.errorMsg": "Изпращането не бе успешно: {{errorMsg}}. Опитайте отново или по-късно.",
|
||||
"input.inputCompletionError.desc": "Предложенията за въвеждане спряха след грешка. Опитайте отново или коригирайте модела за предложения в Настройки.",
|
||||
"input.inputCompletionError.retry": "Опитай отново",
|
||||
"input.inputCompletionError.settings": "Настройки",
|
||||
"input.inputCompletionError.title": "Предложенията за въвеждане са паузирани",
|
||||
"input.more": "Още",
|
||||
"input.send": "Изпрати",
|
||||
"input.sendWithCmdEnter": "Натиснете <key/>, за да изпратите",
|
||||
@@ -364,6 +370,14 @@
|
||||
"noMatchingAgents": "Няма съвпадащи членове",
|
||||
"noMembersYet": "Тази група все още няма членове. Натиснете бутона +, за да поканите агенти.",
|
||||
"noSelectedAgents": "Все още няма избрани членове",
|
||||
"opStatusTray.cost": "разход",
|
||||
"opStatusTray.status.compressing": "Компресиране на контекста",
|
||||
"opStatusTray.status.generating": "Генериране",
|
||||
"opStatusTray.status.reasoning": "Разсъждаване",
|
||||
"opStatusTray.status.searching": "Търсене",
|
||||
"opStatusTray.status.toolCalling": "Извикване на инструменти",
|
||||
"opStatusTray.steps": "стъпки",
|
||||
"opStatusTray.tokens": "токени",
|
||||
"openInNewWindow": "Отвори в нов прозорец",
|
||||
"operation.contextCompression": "Контекстът е твърде дълъг, компресиране на историята...",
|
||||
"operation.execAgentRuntime": "Подготвяне на отговор",
|
||||
@@ -915,6 +929,7 @@
|
||||
"workflow.toolDisplayName.addPreferenceMemory": "Запазена памет",
|
||||
"workflow.toolDisplayName.calculate": "Изчислено",
|
||||
"workflow.toolDisplayName.callAgent": "Извикан агент",
|
||||
"workflow.toolDisplayName.callMcpTool": "Извикан MCP инструмент",
|
||||
"workflow.toolDisplayName.callSubAgent": "Изпратен под-агент",
|
||||
"workflow.toolDisplayName.clearTodos": "Изчистени задачи",
|
||||
"workflow.toolDisplayName.copyDocument": "Копиран документ",
|
||||
@@ -1005,7 +1020,9 @@
|
||||
"workingPanel.localFile.closeRight": "Затвори надясно",
|
||||
"workingPanel.localFile.error": "Не може да се зареди този файл",
|
||||
"workingPanel.localFile.preview.raw": "Суров",
|
||||
"workingPanel.localFile.preview.reload": "Презареди визуализацията",
|
||||
"workingPanel.localFile.preview.render": "Преглед",
|
||||
"workingPanel.localFile.preview.source": "Източник",
|
||||
"workingPanel.localFile.truncated": "Прегледът на файла е съкратен до {{limit}} символа",
|
||||
"workingPanel.progress": "Progress",
|
||||
"workingPanel.progress.allCompleted": "All tasks completed",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "Превключване към нов клон…",
|
||||
"workingDirectory.createBranchTitle": "Създаване на нов клон",
|
||||
"workingDirectory.current": "Текуща работна директория",
|
||||
"workingDirectory.deleteBranchAction": "Изтриване на клон",
|
||||
"workingDirectory.deleteBranchConfirm": "Да изтрия ли клона „{{name}}“? Това ще го премахне окончателно, включително всички несляти комити.",
|
||||
"workingDirectory.deleteBranchTitle": "Изтриване на клон",
|
||||
"workingDirectory.deleteFailed": "Неуспешно изтриване",
|
||||
"workingDirectory.detachedHead": "Отделен HEAD на {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Добавени {{added}} · Модифицирани {{modified}} · Изтрити {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Добавени",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "Скорошни",
|
||||
"workingDirectory.refreshGitStatus": "Обновяване на статус на клона и PR",
|
||||
"workingDirectory.removeRecent": "Премахване от скорошни",
|
||||
"workingDirectory.renameBranchAction": "Преименуване на клон",
|
||||
"workingDirectory.renameBranchTitle": "Преименуване на клон",
|
||||
"workingDirectory.renameFailed": "Неуспешно преименуване",
|
||||
"workingDirectory.selectFolder": "Изберете папка",
|
||||
"workingDirectory.title": "Работна директория",
|
||||
"workingDirectory.topicDescription": "Замяна на настройката по подразбиране на агента само за този разговор",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user