Compare commits

..

1 Commits

Author SHA1 Message Date
Innei b0cb91740e 🐛 fix(desktop): prevent onboarding auth loop 2026-06-15 18:51:45 +08:00
1251 changed files with 13370 additions and 36281 deletions
+6 -10
View File
@@ -111,7 +111,7 @@ First check the repo root for `.env`:
Do not start the standalone e2e server as the product under test.
Use `scripts/init-dev-env.sh`. It follows the e2e setup pattern — Postgres,
Redis, migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
skill and starts the repo's dev server (`pnpm run dev:next` / `bun run dev`),
not `e2e/scripts/setup.ts --start`. The script hard-blocks when root `.env`
exists, so it cannot accidentally override a user's local config. When `.env`
@@ -132,19 +132,19 @@ fi
Bootstrap flow when no `.env` exists:
```bash
# From repo root. Managed Postgres/Redis flow requires Docker Desktop.
# From repo root. Managed DB flow requires Docker Desktop.
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
If using an existing Postgres instead of the managed Docker DB, set
`DATABASE_URL` and `REDIS_URL`, then skip `setup-db`:
`DATABASE_URL` and skip `setup-db`:
```bash
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
For backend-only checks, `dev-next` is available, but Web smoke needs the
@@ -170,9 +170,6 @@ Default script env:
- `APP_URL=http://localhost:3010`
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres`
- `DATABASE_DRIVER=node`
- `AGENT_RUNTIME_MODE=queue` so backend-only agent runtime checks use the
same queued execution path as production
- `REDIS_URL=redis://localhost:6380` for queue-mode agent runtime state
- `FEATURE_FLAGS=-agent_self_iteration` so local smoke does not require QStash
- Local QStash defaults (`QSTASH_URL`, `QSTASH_TOKEN`, signing keys) are exported;
run `init-dev-env.sh qstash` in a separate terminal when the path under test
@@ -180,7 +177,6 @@ Default script env:
- `KEY_VAULTS_SECRET`, `AUTH_SECRET`, auth verification off
- S3 mock vars
- Managed DB container: `lobehub-agent-testing-postgres`
- Managed Redis container: `lobehub-agent-testing-redis`
`seed-user` creates `agent-testing@lobehub.com` / `TestPassword123!` with
onboarding already completed, plus a local API key in
@@ -112,14 +112,9 @@ secret: don't paste it into shared logs, PRs, or commit it anywhere.
1. `$SCRIPT status --surface web` — green? Start testing. Do not ask for a Cookie header.
2. Not green and using the seeded local env → `$SCRIPT web-seed`.
3. If repo-root `.env` exists and `web-seed` fails, do **not** seed or modify the current DB; treat it as an existing local environment and use Cookie injection.
4. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
5. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:`**Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
6. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
`ENABLE_MOCK_DEV_USER` is not Web auth. It only affects server-side API context
and does not satisfy Better Auth or stop the SPA from redirecting to `/signin`.
Do not use it as a substitute for `status --surface web` or Cookie injection.
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
@@ -48,15 +48,14 @@ curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
```bash
# Start backend only.
# With root .env: use the existing local config.
# Agent runtime queue mode is required to mirror production async execution.
AGENT_RUNTIME_MODE=queue pnpm run dev:next
pnpm run dev:next
# Without root .env: use the self-contained agent-testing env.
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
# Full-stack SPA + backend. Required for Web smoke.
# With root .env:
AGENT_RUNTIME_MODE=queue bun run dev
bun run dev
# Without root .env:
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
@@ -92,8 +91,6 @@ in doubt.
| `ECONNREFUSED` | Server not running — start it |
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
| Stale data / old behavior | Server needs a restart to pick up code changes |
| Agent call runs inline | Set `AGENT_RUNTIME_MODE=queue`, make sure `REDIS_URL` is configured, then restart the server |
| Queue mode needs Redis | Run `init-dev-env.sh setup-db`, or provide `REDIS_URL=redis://...` for an existing Redis |
| QStash workflow failures | Start `init-dev-env.sh qstash` and make sure dev server inherited the script's `QSTASH_*` env |
Marketplace/community endpoints are not part of the local agent-testing auth
@@ -12,16 +12,16 @@
# Usage:
# init-dev-env.sh env # print shell exports
# init-dev-env.sh write [file] # write a source-able env file
# init-dev-env.sh setup-db # start local Postgres/Redis and run migrations
# init-dev-env.sh setup-db # start local Postgres and run migrations
# init-dev-env.sh migrate # run DB migrations against the configured DB
# init-dev-env.sh seed-user # seed the baseline test user + CLI API key
# init-dev-env.sh qstash # run local Upstash QStash dev server
# init-dev-env.sh dev-next # exec `pnpm run dev:next` with this env
# init-dev-env.sh dev # exec `bun run dev` with this env
# init-dev-env.sh clean-db # remove the managed Postgres/Redis containers
# init-dev-env.sh clean-db # remove the managed Postgres container
#
# Overrides:
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres REDIS_PORT=6380 REDIS_CONTAINER=lobehub-agent-testing-redis QSTASH_DEV_PORT=8080
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres QSTASH_DEV_PORT=8080
set -euo pipefail
@@ -32,9 +32,6 @@ SERVER_PORT="${SERVER_PORT:-3010}"
DB_PORT="${DB_PORT:-5433}"
DB_CONTAINER="${DB_CONTAINER:-lobehub-agent-testing-postgres}"
DATABASE_URL="${DATABASE_URL:-postgresql://postgres:postgres@localhost:${DB_PORT}/postgres}"
REDIS_PORT="${REDIS_PORT:-6380}"
REDIS_CONTAINER="${REDIS_CONTAINER:-lobehub-agent-testing-redis}"
REDIS_URL="${REDIS_URL:-redis://localhost:${REDIS_PORT}}"
ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-dev.env"
CLI_ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-cli.env"
AGENT_TESTING_API_KEY="${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}"
@@ -57,7 +54,6 @@ guard_no_root_env() {
}
apply_env() {
export AGENT_RUNTIME_MODE="${AGENT_RUNTIME_MODE:-queue}"
export APP_URL="${APP_URL:-http://localhost:${SERVER_PORT}}"
export AUTH_EMAIL_VERIFICATION="${AUTH_EMAIL_VERIFICATION:-0}"
export AUTH_SECRET="${AUTH_SECRET:-agent-testing-local-auth-secret-32chars}"
@@ -73,7 +69,6 @@ apply_env() {
export QSTASH_NEXT_SIGNING_KEY="${QSTASH_NEXT_SIGNING_KEY:-$QSTASH_LOCAL_NEXT_SIGNING_KEY}"
export QSTASH_TOKEN="${QSTASH_TOKEN:-$QSTASH_LOCAL_TOKEN}"
export QSTASH_URL="${QSTASH_URL:-http://127.0.0.1:${QSTASH_DEV_PORT}}"
export REDIS_URL
export S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-agent-testing-access-key}"
export S3_BUCKET="${S3_BUCKET:-agent-testing-bucket}"
export S3_ENDPOINT="${S3_ENDPOINT:-https://agent-testing-s3.localhost}"
@@ -83,7 +78,6 @@ apply_env() {
env_keys() {
printf '%s\n' \
APP_URL \
AGENT_RUNTIME_MODE \
AUTH_EMAIL_VERIFICATION \
AUTH_SECRET \
DATABASE_DRIVER \
@@ -98,7 +92,6 @@ env_keys() {
QSTASH_NEXT_SIGNING_KEY \
QSTASH_TOKEN \
QSTASH_URL \
REDIS_URL \
S3_ACCESS_KEY_ID \
S3_BUCKET \
S3_ENDPOINT \
@@ -144,15 +137,6 @@ wait_for_db() {
printf '\n'
}
wait_for_redis() {
printf ' waiting for Redis'
until docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; do
printf '.'
sleep 1
done
printf '\n'
}
start_db() {
require_docker
@@ -173,25 +157,6 @@ start_db() {
wait_for_db
}
start_redis() {
require_docker
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
ok "Redis container already running: $REDIS_CONTAINER"
elif docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker start "$REDIS_CONTAINER" > /dev/null
ok "started existing Redis container: $REDIS_CONTAINER"
else
docker run -d \
--name "$REDIS_CONTAINER" \
-p "${REDIS_PORT}:6379" \
redis:7-alpine > /dev/null
ok "created Redis container: $REDIS_CONTAINER"
fi
wait_for_redis
}
migrate_db() {
apply_env
cd "$REPO_ROOT"
@@ -362,11 +327,9 @@ cmd_status() {
apply_env
echo "agent-testing local dev env:"
note "APP_URL=$APP_URL"
note "AGENT_RUNTIME_MODE=$AGENT_RUNTIME_MODE"
note "DATABASE_URL=$DATABASE_URL"
note "PORT=$PORT"
note "QSTASH_URL=$QSTASH_URL"
note "REDIS_URL=$REDIS_URL"
if command -v docker > /dev/null 2>&1; then
ok "docker CLI available"
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
@@ -374,11 +337,6 @@ cmd_status() {
else
note "managed Postgres is not running: $DB_CONTAINER"
fi
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
ok "managed Redis running: $REDIS_CONTAINER"
else
note "managed Redis is not running: $REDIS_CONTAINER"
fi
else
bad "docker CLI is not available"
fi
@@ -415,15 +373,6 @@ cmd_clean_db() {
else
note "Postgres container not found: $DB_CONTAINER"
fi
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker stop "$REDIS_CONTAINER" > /dev/null
fi
if docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker rm "$REDIS_CONTAINER" > /dev/null
ok "removed Redis container: $REDIS_CONTAINER"
else
note "Redis container not found: $REDIS_CONTAINER"
fi
}
usage() {
@@ -442,7 +391,6 @@ case "$COMMAND" in
write) shift; write_env "${1:-}" ;;
setup-db)
start_db
start_redis
migrate_db
;;
migrate) migrate_db ;;
@@ -81,7 +81,6 @@ 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"
ROOT_ENV_FILE="$REPO_ROOT/.env"
CLI_HOME_NAME="${LOBEHUB_CLI_HOME:-.lobehub-dev}"
CLI_HOME="$HOME/${CLI_HOME_NAME#/}"
CLI_CREDENTIALS_FILE="$CLI_HOME/credentials.json"
@@ -482,13 +481,8 @@ PY
if [[ ! "$code" =~ ^[23] ]]; then
bad "seed user sign-in failed at $SERVER_URL/api/auth/sign-in/email (http_code='$code')"
if [[ -f "$ROOT_ENV_FILE" ]]; then
note "root .env exists; do not seed or modify this DB for Web auth."
note "Use Chrome Cookie injection instead: $0 open-chrome, then pbpaste | $0 web"
else
note "make sure the seed user exists:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
fi
note "make sure the seed user exists:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
return 1
fi
@@ -523,7 +517,6 @@ cmd_web_verify() {
bad "failed to open $SERVER_URL in agent-browser session '$SESSION'"
return 1
fi
agent-browser --session "$SESSION" wait --load networkidle > /dev/null 2>&1 || true
local url
url=$(agent-browser --session "$SESSION" get url 2> /dev/null || true)
if [[ -z "$url" ]]; then
+1 -5
View File
@@ -38,7 +38,7 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
## Default Debug Order
1. Prove whether the raw CLI output is correct before touching UI code. The app records every real session — read the most recent one via `cat .heerogeneous-tracing/.last-live-trace` rather than hand-rolling a `claude -p` repro (see references/debug-workflow\.md §2).
1. Prove whether the raw CLI output is correct before touching UI code.
2. If raw output is correct, compare it with adapter output. In dev, `executeHeterogeneousAgent` exposes `window.__HETERO_AGENT_TRACE`.
3. If adapted events look correct, inspect `persistToolBatch`, `persistToolResult`, step transitions, and subagent routing.
4. Turn the repro into a focused test before fixing.
@@ -77,10 +77,6 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
look for `tool_result for unknown toolCallId` and missing `result_msg_id` backfill.
- Subagent tools show up in the main bubble:
check for subagent chunks reaching the main gateway handler.
- Wrong terminal-error guide (e.g. "usage limit reached" shown for a network drop):
a classifier is branching on a structured field whose mere presence isn't its meaning.
Grep the field across all event states in a real trace before trusting it — see
references/debug-workflow\.md §8 (CC `rate_limit_info` rides on `status: "allowed"` too).
## References
@@ -3,13 +3,12 @@
## Contents
1. Pipeline map
2. Capture raw CLI traces first (incl. in-app live traces)
2. Capture raw CLI traces first
3. Compare raw and adapted events
4. Check step boundaries before persistence
5. Check tool persistence invariants
6. Focused tests
7. Repro-to-fix workflow
8. Verify a structured-field classifier against a real trace
## 1. Pipeline Map
@@ -28,54 +27,6 @@ Start at the leftmost broken layer. Do not jump straight to UI rendering unless
## 2. Capture Raw CLI Traces First
### In-app live traces (the faithful capture — prefer this)
The running app already records every CLI session it spawns. This is the most
faithful trace you can get, because it captures the **exact** spawn args, env
keys, cwd, `--resume`/`--mcp-config` flags, model, and stdin that the app used —
things a hand-rolled `claude -p` / `codex exec` repro will not reproduce. Reach
for this before reproducing manually. The recorder lives in
`apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
(`createCliTraceSession`, `shouldTraceCliOutput`, `resolveTraceRootDir`).
When it records:
- **Dev build** (`!app.isPackaged`): always.
- **Packaged build**: only when the user flips the Help-menu developer toggle
(`heteroTracingEnabled`). Off by default so normal runs aren't polluted.
- Never under `NODE_ENV=test`.
Where it writes:
- Toggle **off** (plain dev run): `<cwd>/.heerogeneous-tracing/` — i.e. inside
the repo you're running against. (Yes, the dir name is misspelled
`heerogeneous`; it is the real path.)
- Toggle **on**: `<appStoragePath>/heteroAgent/tracing/` — keeps traces out of
the user's project. This is the only path packaged builds ever use.
Layout per session — `.../<agentType>/<YYYYMMDD-HHMMSS>-<sessionId>/`:
- `meta.json` — spawn `args`, `command`, `cwd`, `envKeys`, `model`,
`resumeSessionId`/`agentSessionId`, attachment summaries. **Read this first**
to know exactly how the CLI was invoked.
- `stdin.txt` — the stream-json request fed to the CLI.
- `stdout.jsonl` — the raw provider NDJSON (the trace you actually read).
- `stderr.log` — CLI stderr.
- `exit.json``{ code, signal, finishedAt }`.
`.heerogeneous-tracing/.last-live-trace` always points at the most recent
session dir, so the fast path to "what just happened" is:
```bash
dir=$(cat .heerogeneous-tracing/.last-live-trace)
cat "$dir/meta.json" # how the CLI was spawned
wc -l "$dir/stdout.jsonl" # raw event count
```
Reproduce the same session yourself by reusing the recorded `meta.json` `args`
together with `stdin.txt` (the args already include `--resume <sessionId>`),
instead of guessing flags.
### Codex raw JSONL
Use a read-only prompt and save traces under the repo-local scratch directory `.heerogeneous-tracing/`.
@@ -293,55 +244,3 @@ When the bug comes from a real trace, distill it into the closest existing test
6. Only then do an Electron smoke test with the `agent-testing` skill if UI confirmation is still needed.
Do not start with a broad Electron repro if a raw trace or adapter test can prove the fault zone faster.
## 8. Verify A Structured-Field Classifier Against A Real Trace
Whenever the adapter **branches on a structured field** from the raw stream —
`status`, `usage`, `rateLimitType`, `stop_reason`, `parent_tool_use_id`,
`subtype`, etc. — do not trust your mental model of the wire format. The field
you key on almost always also appears on **benign / non-target** events, and a
classifier that ignores the surrounding state will misfire on those.
The procedure (recurring — run it every time):
1. Pull the most recent real session: `dir=$(cat .heerogeneous-tracing/.last-live-trace)`.
2. Grep the field across **every** event state, not just the failing one, and
count by co-occurring state. Example:
```bash
# Which event statuses carry a rate_limit_info block?
grep -o '"status":"[a-z]*"' "$dir/stdout.jsonl" | sort | uniq -c
grep -c 'rate_limit_info' "$dir/stdout.jsonl"
```
3. If the field rides on states you did not account for, the classifier needs an
extra gate. Add the trace as a fixture/assertion to the adapter test so the
regression can't come back.
### Worked example: CC usage-limit vs. transient throttle (`fix/cc-rate-limit-quota-misclassify`)
- **Symptom:** an unrelated terminal failure (e.g. an `ECONNRESET` network drop)
rendered a bogus "usage limit reached, resets at X" guide.
- **What the trace showed:** Anthropic stamps a `rate_limit_info` block —
carrying `resetsAt` and `rateLimitType` (e.g. `seven_day`) — onto events even
when the request **goes through** (`status: "allowed"`). In real traces those
reset-window fields appear on \~all `rate_limit_info` blocks, the vast majority
of which are `allowed`, not `rejected`. So the window is rolling-window
_metadata for an allowed call_, NOT evidence the limit was hit.
- **The bug:** `isUserQuotaRateLimit` keyed only on the presence of a reset
window (`info.resetsAt != null || info.rateLimitType != null`). A later
terminal error inherited the last allowed event's window → false positive.
- **The fix:** require `status === 'rejected'` **and** a concrete reset window.
A bare `rejected` with no window is the transient server throttle → leave it
to the overloaded (retry) classifier. Status codes (429 / 529) and message
text are deliberately not consulted — only this structured signal decides the
guide.
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts` →
`isUserQuotaRateLimit`
- regression assertions in
`packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
The general lesson: a field's **presence** is not its **meaning**. Confirm which
event states a discriminator field co-occurs with in a real recorded trace
before branching on it.
+2 -2
View File
@@ -18,8 +18,8 @@ Periodic review of the project-local skill set under `.agents/skills/`. The goal
Build a fresh census of all SKILL.md files. Do NOT trust any prior cached list.
```bash
find -L .agents/skills -name SKILL.md | wc -l # total count, including symlinked skills
find -L .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length, including symlinked skills
find .agents/skills -name SKILL.md | wc -l # total count
find .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length
```
Group by domain in a mental table (DB / state / UI / agent / testing / workflow / docs / etc.). Note new arrivals since last audit (`git log --since="1 week ago" -- .agents/skills/`).
+64 -206
View File
@@ -10,32 +10,35 @@ How LobeHub products should feel, and concrete rules to get there. Use this when
**building or reviewing** any user-facing flow. For component/styling choices see
**react**, for wording see **microcopy**, for imperative modal wiring see **modal**.
## Design values
## Design values (设计价值观)
LobeHub follows four product design values — **Natural・Meaningful・Certainty・
Growth**. Read them before designing:
LobeHub follows four product design values — **自然 Natural・意义感 Meaningful・
确定性 Certainty・生长性 Growth**. Read them before designing:
**[references/design-values.md](references/design-values.md)** (definitions +
conflict priority).
> The checklists below are the execution layer. Each item is tagged with the
> value(s) it serves; for what those values mean, see the file above.
## How this is organized
## 1. Flow & momentum (操作链路)・自然・意义感
The checklists are grouped by **interaction type** — the kind of thing the user
is doing. Jump to the module that matches the surface you're building (reading a
list, editing content, running an action, …); each module collects the rules
specific to that interaction. The same surface often spans several modules (an
editable list is Read + Edit + Act) — walk each that applies.
Every action chain must **push the user forward**, never dead-end or block the flow.
---
- [ ] **Forward momentum** — after any operation, lead the user to the next step,
don't just stop. _(意义感)_
- [ ] **Success state = primary "go to result", secondary "dismiss"** — the strong
button is the forward action (take me to the result); "Done" is the weak/
secondary button. ✅ After moving topics: primary = "Go to «target»", secondary
\= "Done". _(意义感・自然)_
- [ ] **Bulk ⇄ single-item parity** — an action on a multi-select toolbar must also
be reachable on a single item (its context menu), and vice versa. _(确定性)_
- [ ] **Confirm → in-progress → done, in one surface** — bulk/irreversible/async
ops use a modal state machine: a confirm step stating exactly what happens →
an in-progress view with **dismissal locked** → a done (or error) view in the
same modal. Never fire-and-forget with only a toast; never leave a dead
spinner. _(确定性・意义感)_
## 1. Read — viewing data & lists
Any surface that **displays** records, lists, or detail. Covers the states a data
view can be in, behavior at scale, and keeping the user's place visible.
### 1.1 Data states: empty / loading / error・Meaningful・Certainty
## 2. States: empty /loading/error (状态设计)・意义感・确定性
Every data surface has **four** states — design all of them, not just "has data".
@@ -43,152 +46,64 @@ Every data surface has **four** states — design all of them, not just "has dat
this is, why it's empty, and gives a clear next action (CTA + value props).
✅ Devices: an empty "Connect your first device" page with primary/secondary
connect paths and "what you can do once connected" cards — ❌ not a bare title
over skeleton rows or a blank body. _(Meaningful)_
over skeleton rows or a blank body. _(意义感)_
- [ ] **Distinguish the empty variants** — "no data yet" (onboarding CTA) vs
"no match for filters" (clear-filters affordance) are different screens. _(Certainty)_
- [ ] **Always-rendered chrome still needs a body empty state.** When a surface
keeps its toolbar / header mounted even with no data (so a create / `+`
affordance stays reachable), the **body** below it must still render an empty
placeholder — persistent chrome is not an excuse to leave the content area
blank. ✅ The agent **Documents** tab keeps its new-folder / new-doc toolbar
and renders an `Empty` below it when there are no documents — ❌ not a toolbar
over dead space. _(Meaningful)_
"no match for filters" (clear-filters affordance) are different screens. _(确定性)_
- [ ] **Loading state** designed (skeleton / NeuralNetworkLoading), not a flash of
blank or layout shift. _(Natural)_
- [ ] **Error state** designed — surface the reason and a retry/back path. _(Meaningful)_
blank or layout shift. _(自然)_
- [ ] **Error state** designed — surface the reason and a retry/back path. _(意义感)_
### 1.2 Lists at scale・Certainty・Natural
## 3. Buttons & focus (按钮与焦点)・确定性
- [ ] **One primary button per surface.** The single primary CTA tells the user the
core action; everything else is secondary/tertiary. Never a pile of primary
buttons competing for attention. _(确定性)_
## 4. Lists at scale (列表与规模)・确定性・自然
A list/data page must be designed for its **whole range of sizes**, not just the
demo data.
- [ ] **Walk the scale: 1 / 2 / 5 / 20 / 100 / 1k10k rows.** Pick the right
mechanism per range — plain render → load-more / pagination → virtual scroll;
add batch-select / bulk actions once counts get large. _(Certainty)_
- [ ] **Co-design empty / loading / error with the data state** (see §1.1). A list
isn't done until all four render well. _(Natural)_
add batch-select / bulk actions once counts get large. _(确定性)_
- [ ] **Co-design empty / loading / error with the data state** (see §2). A list
isn't done until all four render well. _(自然)_
### 1.3 Selection visibility in scrolled lists・Certainty・Natural
A capped / scrollable / virtualized list mounts at `scrollTop = 0`. If the
active item sits below the fold, the user lands on a valid selection that is
**off-screen** — and reads it as "nothing is selected" or a broken page. Any
list that can open with a pre-selected item must **scroll that item into view**.
This is an easy case to miss: it only shows up once the list is long enough and
the selection is restored rather than freshly clicked.
- [ ] **Scroll the active item into view on mount / restore.** When the selection
is restored from a URL query, deep link, or persisted state (not a fresh
click), bring it into view — the container starts at the top otherwise. ✅
The nested thread list is capped to \~9 rows; a thread restored from
`?thread=` below the fold is scrolled into view on mount. _(Certainty)_
- [ ] **Hardest when the selection has no other anchor.** If the parent/container
row isn't highlighted while a child is active (no breadcrumb, no header
echo), an off-screen active row means **zero** visible feedback — design
for exactly this case. _(Meaningful)_
- [ ] **Use `block: 'nearest'` (or equivalent).** Only scroll when the row is
actually off-screen; an already-visible selection must not jump. _(Natural)_
- [ ] **Re-run once async rows mount.** The active id is usually known before the
list finishes loading; key the scroll off a list-ready signal (e.g. row
count), not only off the id, so a restored selection still lands when the
data arrives. _(Certainty)_
- [ ] **Mirror it across duplicated list variants** so the behavior can't regress
in just one (e.g. parallel agent / group lists). _(Certainty)_
### 1.4 Option visibility in pickers・Certainty・Meaningful
## 5. Option visibility (选项可见性)・确定性・意义感
- [ ] **Pickers list every valid target.** Watch for options dropped by backend
list queries (pagination, `virtual` flags, scope filters) and add them back.
✅ The default "LobeAI" (inbox) agent is `virtual` and excluded from the
sidebar list, so the move picker re-adds it. An empty picker must mean
"genuinely none", never "we filtered out the only option". _(Meaningful)_
"genuinely none", never "we filtered out the only option". _(意义感)_
### 1.5 Default view reflects entry intent & data state・Certainty・Meaningful
## 6. Loading visuals (Loading 视觉)・自然
A surface with multiple tabs / views / panels has a **landing** selection. Don't
hardcode it to "the first tab" — derive it from **(a) how the user got here** (the
intent their navigation carried) and **(b) which views actually have data**. A
static default that lands the user on an empty tab while a sibling holds exactly
what they came for reads as broken. This pairs with §1.1: the empty state is the
fallback _within_ a view; this rule is about not landing on that empty view in the
first place when a better one exists.
**Never use antd `Spin`** — it doesn't match the product's loading visual. Use a
project loader:
- [ ] **Open on the tab the entry implies.** When navigation carries intent — the
user clicked a Skill, a file, a record of a specific type — land on the view
that shows it, not the static first tab. ✅ Opening a document page by clicking
a **skill** lands the right panel on the **Skills** tab; opening a plain
document lands on **Documents**. _(Meaningful)_
- [ ] **Fall back to a populated view when the default would be empty.** If the
default tab has no data but a sibling does, default to the populated one so
the surface opens on content. ✅ An agent with only skills (no documents)
opens the panel on **Skills** instead of an empty **Documents** tab. _(Certainty)_
- [ ] **Decide from resolved state, not mid-load.** Compute the default once the
data has loaded — choosing off an empty _in-flight_ list flips the tab as data
arrives. Hold the static default while loading, switch on resolved-empty. _(Certainty)_
- [ ] **A manual choice wins and sticks.** Once the user picks a tab, stop
auto-selecting — track "user-picked" separately (e.g. a nullable `pickedTab`
that overrides the derived default) so later data changes don't yank them off
their choice. _(Natural)_
| Need | Component |
| --------------------------- | ----------------------------------------------------------------------------- |
| Default loading (in-flight) | `NeuralNetworkLoading` from `@/components/NeuralNetworkLoading` (`size` prop) |
| Inline dots | `DotsLoading` / `BubblesLoading` from `@/components` |
| Branded full-page | `Loading` from `@/components/Loading/BrandTextLoading` |
| List / card placeholder | a skeleton (e.g. `SkeletonList`) |
---
When in doubt, reach for `NeuralNetworkLoading` — it's the default in-flight
indicator (e.g. modal "in progress" states).
## 2. Edit — entering & changing content
## 7. Discoverability & growth (可发现性与生长)・生长性
Any surface where the user **types or edits**. Input is expensive effort; the
overriding rule is **never lose it**.
The product should grow with the user — deeper power shows up as needs deepen.
### 2.1 Protect in-progress edits・Certainty・Meaningful
- [ ] **Progressive disclosure** — keep the novice path clean; reveal advanced
capabilities as the user gets there, don't dump everything at once. _(生长性・自然)_
- [ ] **Surface related actions at the moment of need** — make the next capability
discoverable in context (e.g. after the first item exists, offer what to do
with it), not buried in a far-off menu. _(生长性・意义感)_
Typed / edited content is real user effort; losing it is one of the most
infuriating outcomes a product can produce. Whenever an editor holds unsaved
input, assume the exit can be **accidental** — a misclick, a refresh, a crash, a
navigation, a failed save — and build a safety net: back the draft up locally and
recover it.
- [ ] **Back up the draft locally as the user types.** Persist to
localStorage / IndexedDB / store so a refresh, crash, accidental close, or
navigation doesn't vaporize the content. _(Certainty)_
- [ ] **Restore on return.** Coming back to the same editing context auto-restores
(or offers to restore) the unsaved draft, rather than showing a blank field. _(Meaningful)_
- [ ] **Guard destructive exits.** Closing / navigating / switching items away
from a dirty editor warns or auto-saves — never silently discards. _(Certainty)_
- [ ] **Survive a failed save.** If the save errors, keep the user's content in
the field / draft and let them retry; never clear the input on failure. _(Meaningful)_
- [ ] **Scope the draft to its target** (per topic / message / item id) so drafts
don't bleed across entities or resurrect on the wrong item. _(Certainty)_
---
## 3. Act — operations, flows & buttons
Any surface where the user **performs an action** — a single op, a bulk op, or a
multi-step flow. Covers momentum, focus, and full entity lifecycle.
### 3.1 Flow & momentum・Natural・Meaningful
Every action chain must **push the user forward**, never dead-end or block the flow.
- [ ] **Forward momentum** — after any operation, lead the user to the next step,
don't just stop. _(Meaningful)_
- [ ] **Success state = primary "go to result", secondary "dismiss"** — the strong
button is the forward action (take me to the result); "Done" is the weak/
secondary button. ✅ After moving topics: primary = "Go to «target»", secondary
\= "Done". _(Meaningful・Natural)_
- [ ] **Bulk ⇄ single-item parity** — an action on a multi-select toolbar must also
be reachable on a single item (its context menu), and vice versa. _(Certainty)_
- [ ] **Confirm → in-progress → done, in one surface** — bulk/irreversible/async
ops use a modal state machine: a confirm step stating exactly what happens →
an in-progress view with **dismissal locked** → a done (or error) view in the
same modal. Never fire-and-forget with only a toast; never leave a dead
spinner. _(Certainty・Meaningful)_
### 3.2 One primary button per surface・Certainty
- [ ] **One primary button per surface.** The single primary CTA tells the user the
core action; everything else is secondary/tertiary. Never a pile of primary
buttons competing for attention. _(Certainty)_
### 3.3 Entity lifecycle completeness・Meaningful・Certainty
## 8. Entity lifecycle completeness (实体生命周期完整性)・意义感・确定性
The recurring trap: a feature ships only the **display** of a list, but edit /
delete / management are never built — so the user can add something and then be
@@ -207,38 +122,16 @@ it explicitly _before_ building. Worked example, the tools/connectors list:
| User-custom (custom connector) | create | edit | delete |
- [ ] **No display-only features.** For every listed entity, enumerate CRUD +
lifecycle ops and build the ones that apply. _(Meaningful)_
lifecycle ops and build the ones that apply. _(意义感)_
- [ ] **Operation set per source/ownership class** — built-in may be read-only;
anything the user _installed_ must be removable; anything the user _created_
must be editable **and** deletable. _(Certainty)_
must be editable **and** deletable. _(确定性)_
- [ ] **Each item exposes its allowed ops** (hover action / context menu / detail
page), and there's a clear entry point to add/create where applicable. _(Natural)_
page), and there's a clear entry point to add/create where applicable. _(自然)_
- [ ] **An intentionally-absent op is a documented decision, not an oversight**
(e.g. official tools can't be deleted — by design). _(Certainty)_
(e.g. official tools can't be deleted — by design). _(确定性)_
---
## 4. Feedback — loading & system response
How the product **answers back** while and after the user acts — loading visuals
and proactive guardrails.
### 4.1 Loading visuals・Natural
**Never use antd `Spin`** — it doesn't match the product's loading visual. Use a
project loader:
| Need | Component |
| --------------------------- | ----------------------------------------------------------------------------- |
| Default loading (in-flight) | `NeuralNetworkLoading` from `@/components/NeuralNetworkLoading` (`size` prop) |
| Inline dots | `DotsLoading` / `BubblesLoading` from `@/components` |
| Branded full-page | `Loading` from `@/components/Loading/BrandTextLoading` |
| List / card placeholder | a skeleton (e.g. `SkeletonList`) |
When in doubt, reach for `NeuralNetworkLoading` — it's the default in-flight
indicator (e.g. modal "in progress" states).
### 4.2 Capability-gated features・Certainty・Meaningful
## 9. Capability-gated features・Certainty・Meaningful
A feature can be fully built and still produce a broken result when the selected
model — or its still-loading config — **can't deliver the capability the feature
@@ -262,54 +155,19 @@ depends on a capability the current config may lack, the product owes a
- [ ] **State the problem and the remedy.** The copy says what's wrong _and_ what
the user should do about it. _(Meaningful)_
---
## 5. Grow — discoverability & progressive disclosure
How the product **deepens** as the user's needs deepen.
### 5.1 Progressive disclosure・Growth
The product should grow with the user — deeper power shows up as needs deepen.
- [ ] **Progressive disclosure** — keep the novice path clean; reveal advanced
capabilities as the user gets there, don't dump everything at once. _(Growth・Natural)_
- [ ] **Surface related actions at the moment of need** — make the next capability
discoverable in context (e.g. after the first item exists, offer what to do
with it), not buried in a far-off menu. _(Growth・Meaningful)_
---
## Quick review checklist
**Read — viewing data & lists**
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA. Always-rendered chrome (toolbar/header) still gets a body empty state.
- [ ] List designed across 1 → 10k rows (virtual scroll / pagination / batch as needed).
- [ ] Capped/scrollable/virtualized list scrolls the restored active item into view on mount (`block: 'nearest'`, re-run after async rows mount).
- [ ] Pickers show all valid targets (default/inbox included); empty = truly none.
- [ ] Multi-tab/view surface lands on the tab the entry intent implies (and falls back to a populated view, decided from resolved state); a manual pick sticks.
**Edit — entering & changing content**
- [ ] Editors back up in-progress input locally and recover it after refresh/crash/failed-save; destructive exits warn, never silently discard.
**Act — operations, flows & buttons**
- [ ] Action leads the user forward; success offers a primary "go to result".
- [ ] Bulk action has a single-item entry (and vice versa).
- [ ] Async/bulk/irreversible action: confirm → in-progress (locked) → done/error.
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA.
- [ ] Exactly one primary button per surface.
- [ ] Listed entities have their full lifecycle (not display-only); ops match source (built-in / installed / custom).
**Feedback — loading & system response**
- [ ] List designed across 1 → 10k rows (virtual scroll / pagination / batch as needed).
- [ ] Pickers show all valid targets (default/inbox included); empty = truly none.
- [ ] No antd `Spin`; use `NeuralNetworkLoading` / project loaders.
- [ ] Capability-gated feature warns (soft, reactive, load-gated) when the model can't deliver it; copy gives the remedy.
**Grow — discoverability & progressive disclosure**
- [ ] Advanced capability is progressively disclosed / discoverable at the moment of need.
- [ ] Listed entities have their full lifecycle (not display-only); ops match source (built-in / installed / custom).
- [ ] Capability-gated feature warns (soft, reactive, load-gated) when the model can't deliver it; copy gives the remedy.
## Related skills
+1 -1
View File
@@ -6,7 +6,7 @@ const prComment = async ({ github, context, releaseUrl, artifactsUrl, version, t
const COMMENT_IDENTIFIER = '<!-- DESKTOP-BUILD-COMMENT -->';
/**
* Generate comment body content
* 生成评论内容
*/
const generateCommentBody = async () => {
try {
-25
View File
@@ -2,31 +2,6 @@
# Changelog
## [Version 2.2.6](https://github.com/lobehub/lobe-chat/compare/v2.2.6-canary.8...v2.2.6)
<sup>Released on **2026-06-17**</sup>
#### ✨ Features
- **agent**: improve connector, document, and fleet workflows.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **agent**: improve connector, document, and fleet workflows, closes [#15936](https://github.com/lobehub/lobe-chat/issues/15936) ([3f82033](https://github.com/lobehub/lobe-chat/commit/3f82033))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.2.1](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr15228.13999...v2.2.1)
<sup>Released on **2026-05-29**</sup>
-37
View File
@@ -1,7 +1,4 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
@@ -80,40 +77,6 @@ describe('lh file - E2E', () => {
});
});
// ── upload (local file) ───────────────────────────────
describe('upload', () => {
it('should upload a local file passed as a positional argument', () => {
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-${Date.now()}.txt`);
fs.writeFileSync(tmpFile, 'hello from lh e2e upload');
try {
const result = runJson<{ id: string }>(`file upload ${tmpFile} --json id`);
expect(result).toHaveProperty('id');
if (result.id) run(`file delete ${result.id} --yes`);
} finally {
fs.rmSync(tmpFile, { force: true });
}
});
it('should upload a local file passed via --file', () => {
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-f-${Date.now()}.txt`);
fs.writeFileSync(tmpFile, 'hello from lh e2e --file upload');
try {
const result = runJson<{ id: string }>(`file upload --file ${tmpFile} --json id`);
expect(result).toHaveProperty('id');
if (result.id) run(`file delete ${result.id} --yes`);
} finally {
fs.rmSync(tmpFile, { force: true });
}
});
it('should error when the local file does not exist', () => {
expect(() => run('file upload -f /no/such/lh-file.txt')).toThrow();
});
});
// ── recent ────────────────────────────────────────────
describe('recent', () => {
+1 -1
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.32" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.29" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.32",
"version": "0.0.29",
"type": "module",
"bin": {
"lh": "./dist/index.js",
-19
View File
@@ -440,25 +440,6 @@ describe('connect command', () => {
});
});
describe('disconnect (alias for connect stop)', () => {
it('should stop running daemon', async () => {
mockRunningPid = 12345;
const program = createProgram();
await program.parseAsync(['node', 'test', 'disconnect']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
});
it('should warn if no daemon is running', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'disconnect']);
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('No daemon'));
});
});
describe('connect status', () => {
it('should show no daemon running', async () => {
const program = createProgram();
+11 -18
View File
@@ -74,7 +74,17 @@ export function registerConnectCommand(program: Command) {
});
// Subcommands
connectCmd.command('stop').description('Stop the background daemon process').action(handleStop);
connectCmd
.command('stop')
.description('Stop the background daemon process')
.action(() => {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
});
connectCmd
.command('status')
@@ -138,27 +148,10 @@ export function registerConnectCommand(program: Command) {
}
handleDaemonStart({ ...options, daemon: true });
});
// Top-level alias for `connect stop`. Users who run `lh connect` naturally
// reach for `lh disconnect` to undo it; the nested `connect stop` is not
// discoverable enough on its own.
program
.command('disconnect')
.description('Disconnect from the device gateway (alias for `connect stop`)')
.action(handleStop);
}
// --- Internal helpers ---
function handleStop() {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
}
function handleDaemonStart(options: ConnectOptions) {
const existingPid = getRunningDaemonPid();
if (existingPid !== null) {
+3 -117
View File
@@ -1,7 +1,3 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -21,9 +17,6 @@ const { mockTrpcClient } = vi.hoisted(() => ({
removeFiles: { mutate: vi.fn() },
updateFile: { mutate: vi.fn() },
},
upload: {
createS3PreSignedUrl: { mutate: vi.fn() },
},
},
}));
@@ -45,11 +38,9 @@ describe('file command', () => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
for (const group of [mockTrpcClient.file, mockTrpcClient.upload]) {
for (const method of Object.values(group)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
for (const method of Object.values(mockTrpcClient.file)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
});
@@ -214,111 +205,6 @@ describe('file command', () => {
expect(mockTrpcClient.file.createFile.mutate).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
});
it('should upload a local file passed as a positional argument', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-${process.pid}.txt`);
fs.writeFileSync(tmpFile, 'hello world');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
mockTrpcClient.file.createFile.mutate.mockResolvedValue({
id: 'f-local',
url: 'files/x.txt',
});
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledWith(
'https://s3/presigned',
expect.objectContaining({ method: 'PUT' }),
);
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({
fileType: 'text/plain',
name: path.basename(tmpFile),
url: expect.stringContaining('.txt'),
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('File created'));
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should upload a local file passed via --file', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-f-${process.pid}.json`);
fs.writeFileSync(tmpFile, '{}');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-json' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', '--file', tmpFile]);
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileType: 'application/json' }),
);
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should skip the S3 upload when the local file hash already exists', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-dedup-${process.pid}.txt`);
fs.writeFileSync(tmpFile, 'dedup me');
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({
isExist: true,
url: 'files/2024-01-01/existing.txt',
});
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-dedup' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
// No pre-sign and no S3 PUT should happen
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
// The record reuses the existing url
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({ url: 'files/2024-01-01/existing.txt' }),
);
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should error when local file does not exist', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', '-f', '/no/such/file.txt']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('File not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should error when no source is provided', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Provide a local file path'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('edit', () => {
+7 -49
View File
@@ -4,7 +4,6 @@ import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
import { uploadLocalFile } from '../utils/uploadLocalFile';
export function registerFileCommand(program: Command) {
const file = program.command('file').description('Manage files');
@@ -114,20 +113,18 @@ export function registerFileCommand(program: Command) {
// ── upload ───────────────────────────────────────────
file
.command('upload [source]')
.description('Upload a file from a local path or a URL')
.option('-f, --file <path>', 'Local file path to upload')
.option('--hash <hash>', 'File hash for deduplication check (URL mode)')
.option('--name <name>', 'File name (URL mode)')
.option('--type <type>', 'File MIME type (URL mode)')
.option('--size <size>', 'File size in bytes (URL mode)')
.command('upload <url>')
.description('Upload a file by URL (checks hash first)')
.option('--hash <hash>', 'File hash for deduplication check')
.option('--name <name>', 'File name')
.option('--type <type>', 'File MIME type')
.option('--size <size>', 'File size in bytes')
.option('--parent-id <id>', 'Parent folder ID')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (
source: string | undefined,
url: string,
options: {
file?: string;
hash?: string;
json?: string | boolean;
name?: string;
@@ -136,47 +133,8 @@ export function registerFileCommand(program: Command) {
type?: string;
},
) => {
const isUrl = (value: string) =>
value.startsWith('http://') || value.startsWith('https://');
// Resolve the local file path: explicit --file, or a positional that is
// not a URL (e.g. `lh file upload ./games_list.txt`).
const localPath = options.file ?? (source && !isUrl(source) ? source : undefined);
const client = await getTrpcClient();
// ── Local file upload ──
if (localPath) {
let result;
try {
result = await uploadLocalFile(client, localPath, { parentId: options.parentId });
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return;
}
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
return;
}
const r = result as any;
console.log(`${pc.green('✓')} File created: ${pc.bold(r.id || '')}`);
if (r.url) console.log(` URL: ${pc.dim(r.url)}`);
return;
}
// ── URL upload ──
if (!source) {
log.error('Provide a local file path, --file <path>, or a URL to upload.');
process.exit(1);
return;
}
const url = source;
// Check hash first if provided
if (options.hash) {
const check = await client.file.checkFileHash.mutate({ hash: options.hash });
-140
View File
@@ -1,7 +1,3 @@
import { rm as fsRm, writeFile as fsWriteFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -10,9 +6,6 @@ import { registerGenerateCommand } from './generate';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
asr: {
transcribe: { mutate: vi.fn() },
},
generation: {
deleteGeneration: { mutate: vi.fn() },
getGenerationStatus: { query: vi.fn() },
@@ -42,15 +35,6 @@ const { writeFileSync: mockWriteFileSync } = vi.hoisted(() => ({
writeFileSync: vi.fn(),
}));
const { uploadLocalFile: mockUploadLocalFile } = vi.hoisted(() => ({
uploadLocalFile: vi.fn(),
}));
vi.mock('../utils/uploadLocalFile', async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, uploadLocalFile: mockUploadLocalFile };
});
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
vi.mock('node:fs', async (importOriginal) => {
@@ -385,130 +369,6 @@ describe('generate command', () => {
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should upload large local audio and transcribe by fileId', async () => {
// Real >3MB temp file so existsSync/statSync (unmocked) see it as large.
const bigPath = path.join(os.tmpdir(), `lh-asr-test-${process.pid}-${Date.now()}.mp3`);
await fsWriteFile(bigPath, Buffer.alloc(4 * 1024 * 1024));
mockUploadLocalFile.mockResolvedValue({ id: 'file_999' });
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'big result' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'asr', bigPath]);
expect(mockUploadLocalFile).toHaveBeenCalledWith(expect.anything(), bigPath);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileId: 'file_999', model: 'whisper-1', provider: 'openai' }),
);
// never inlines bytes for the large file
expect(mockTrpcClient.asr.transcribe.mutate.mock.calls[0][0]).not.toHaveProperty(
'audioBase64',
);
expect(stdoutSpy).toHaveBeenCalledWith('big result');
} finally {
await fsRm(bigPath, { force: true });
}
});
it('should download and transcribe an audio URL', async () => {
const fetchMock = vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers(),
ok: true,
});
vi.stubGlobal('fetch', fetchMock);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'hello world' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/audio/sample.mp3',
]);
expect(fetchMock).toHaveBeenCalledWith('https://example.com/audio/sample.mp3');
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({
audioBase64: Buffer.from('audio-bytes').toString('base64'),
fileName: 'sample.mp3',
model: 'whisper-1',
provider: 'openai',
}),
);
expect(stdoutSpy).toHaveBeenCalledWith('hello world');
expect(exitSpy).not.toHaveBeenCalled();
});
it('should derive an extension and mime type from Content-Type when the URL has none', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers({ 'content-type': 'audio/mpeg; charset=binary' }),
ok: true,
}),
);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'asr', 'https://example.com/download']);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({
fileName: 'download.mp3',
mimeType: 'audio/mpeg',
}),
);
});
it('should prefer the filename from Content-Disposition', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers({
'content-disposition': 'attachment; filename="recording.wav"',
}),
ok: true,
}),
);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/files/abc123?sig=xyz',
]);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileName: 'recording.wav' }),
);
});
it('should exit when audio URL download fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }),
);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/missing.mp3',
]);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to download audio'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('delete', () => {
+39 -167
View File
@@ -1,27 +1,16 @@
import { existsSync, statSync } from 'node:fs';
import { readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import { createReadStream, existsSync } from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import { getTrpcClient } from '../../api/client';
import { getAuthInfo } from '../../api/http';
import { log } from '../../utils/logger';
import { uploadLocalFile } from '../../utils/uploadLocalFile';
// Audio at or below this size is sent inline as base64; anything larger is
// uploaded first and transcribed by `fileId`. Kept in sync with the server-side
// inline cap in `apps/server/src/routers/lambda/asr.ts`.
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
export function registerAsrCommand(parent: Command) {
parent
.command('asr <audio-file>')
.description(
'Convert speech to text (automatic speech recognition). Accepts a local path or a URL',
)
.description('Convert speech to text (automatic speech recognition)')
.option('--model <model>', 'STT model', 'whisper-1')
.option('--provider <provider>', 'AI provider', 'openai')
.option('--language <lang>', 'Language code (e.g. en, zh)')
.option('--json', 'Output raw JSON')
.action(
@@ -31,175 +20,58 @@ export function registerAsrCommand(parent: Command) {
json?: boolean;
language?: string;
model: string;
provider: string;
},
) => {
const isUrl = audioFile.startsWith('http://') || audioFile.startsWith('https://');
if (!isUrl && !existsSync(audioFile)) {
if (!existsSync(audioFile)) {
log.error(`File not found: ${audioFile}`);
process.exit(1);
return;
}
// Resolve the input to a local file path (downloading URLs to a temp
// file) so large audio can reuse the shared upload flow.
let localPath: string;
let fileName: string;
let mimeType: string | undefined;
let size: number;
let tempPath: string | undefined;
try {
if (isUrl) {
const downloaded = await fetchAudioFromUrl(audioFile);
fileName = downloaded.name;
mimeType = downloaded.mimeType;
size = downloaded.bytes.byteLength;
tempPath = path.join(os.tmpdir(), `lh-asr-${process.pid}-${Date.now()}-${fileName}`);
await writeFile(tempPath, downloaded.bytes);
localPath = tempPath;
} else {
localPath = audioFile;
fileName = path.basename(audioFile);
size = statSync(audioFile).size;
}
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
const { serverUrl, headers } = await getAuthInfo();
const sttOptions: Record<string, any> = { model: options.model };
if (options.language) sttOptions.language = options.language;
const formData = new FormData();
const fileBuffer = await readFileAsBlob(audioFile);
formData.append('speech', fileBuffer, path.basename(audioFile));
formData.append('options', JSON.stringify(sttOptions));
// Remove Content-Type for multipart/form-data (let fetch set it with boundary)
const { 'Content-Type': _, ...formHeaders } = headers;
const res = await fetch(`${serverUrl}/webapi/stt/openai`, {
body: formData,
headers: formHeaders,
method: 'POST',
});
if (!res.ok) {
const errText = await res.text();
log.error(`ASR failed: ${res.status} ${errText}`);
process.exit(1);
return;
}
try {
const client = await getTrpcClient();
const result = await res.json();
let result: { text: string };
if (size > MAX_INLINE_AUDIO_BYTES) {
// Large audio: upload to storage, then transcribe by fileId so the
// bytes never travel inline through tRPC.
process.stderr.write(
`Audio is ${(size / 1024 / 1024).toFixed(1)}MB — uploading before transcription…\n`,
);
const record = (await uploadLocalFile(client, localPath)) as { id: string };
result = await client.asr.transcribe.mutate({
fileId: record.id,
language: options.language,
model: options.model,
provider: options.provider,
});
} else {
const bytes = await readFile(localPath);
result = await client.asr.transcribe.mutate({
audioBase64: Buffer.from(bytes).toString('base64'),
fileName,
language: options.language,
mimeType,
model: options.model,
provider: options.provider,
});
}
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
process.stdout.write(result.text);
process.stdout.write('\n');
}
} catch (error) {
log.error(`ASR failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
} finally {
if (tempPath) {
await rm(tempPath, { force: true }).catch(() => {});
}
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
const text = (result as any).text || JSON.stringify(result);
process.stdout.write(text);
process.stdout.write('\n');
}
},
);
}
// Common audio MIME types mapped to a file extension the transcription
// provider can recognize. Keep the extensions within the set OpenAI's
// /audio/transcriptions endpoint accepts.
const AUDIO_MIME_TO_EXT: Record<string, string> = {
'audio/aac': 'aac',
'audio/flac': 'flac',
'audio/m4a': 'm4a',
'audio/mp3': 'mp3',
'audio/mp4': 'm4a',
'audio/mpeg': 'mp3',
'audio/mpga': 'mp3',
'audio/ogg': 'ogg',
'audio/opus': 'ogg',
'audio/wav': 'wav',
'audio/wave': 'wav',
'audio/webm': 'webm',
'audio/x-m4a': 'm4a',
'audio/x-wav': 'wav',
};
async function fetchAudioFromUrl(
url: string,
): Promise<{ bytes: Uint8Array; mimeType?: string; name: string }> {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to download audio: ${res.status} ${res.statusText}`);
}
const bytes = new Uint8Array(await res.arrayBuffer());
// Strip any parameters from the Content-Type (e.g. `audio/mpeg; charset=...`).
const contentType = res.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
const mimeType = contentType?.startsWith('audio/') ? contentType : undefined;
// Prefer the name the server advertises, then the URL path, then a fallback.
const name =
fileNameFromContentDisposition(res.headers.get('content-disposition')) ||
basenameFromUrl(url) ||
'audio';
// Transcription providers infer the audio format from the file extension, so
// make sure the name carries one. Signed URLs and /download endpoints often
// have no extension in the path — in that case borrow it from the
// Content-Type when we recognize it.
const ext = contentType ? AUDIO_MIME_TO_EXT[contentType] : undefined;
const finalName = path.extname(name) || !ext ? name : `${name}.${ext}`;
return { bytes, mimeType, name: finalName };
}
// Extract a file name from a Content-Disposition header, handling both the
// plain `filename="x"` form and the RFC 5987 extended `filename*=UTF-8''x` form.
function fileNameFromContentDisposition(header: string | null): string | undefined {
if (!header) return undefined;
// Extended form takes precedence and may be percent-encoded.
const extended = /filename\*=\s*(?:UTF-8|ISO-8859-1)?''([^;]+)/i.exec(header);
if (extended?.[1]) {
try {
return path.basename(decodeURIComponent(extended[1].trim()));
} catch {
// Malformed encoding — fall through to the plain form.
}
}
const plain = /filename=\s*"?([^";]+)"?/i.exec(header);
const value = plain?.[1]?.trim();
return value ? path.basename(value) : undefined;
}
// Derive the (URL-decoded) last path segment of a URL, if any.
function basenameFromUrl(url: string): string | undefined {
let pathname: string;
try {
pathname = new URL(url).pathname;
} catch {
return undefined;
}
const base = path.basename(pathname);
if (!base) return undefined;
try {
return decodeURIComponent(base);
} catch {
return base;
async function readFileAsBlob(filePath: string): Promise<Blob> {
const chunks: Uint8Array[] = [];
const stream = createReadStream(filePath);
for await (const chunk of stream) {
chunks.push(chunk as Uint8Array);
}
return new Blob(chunks);
}
+74 -13
View File
@@ -1,12 +1,14 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { getAuthInfo } from '../api/http';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
import { uploadLocalFile } from '../utils/uploadLocalFile';
function formatFileType(fileType: string): string {
if (!fileType) return '';
@@ -322,22 +324,81 @@ export function registerKbCommand(program: Command) {
.description('Upload a file to a knowledge base')
.option('--parent <parentId>', 'Parent folder ID')
.action(async (knowledgeBaseId: string, filePath: string, options: { parent?: string }) => {
const client = await getTrpcClient();
let result;
try {
result = await uploadLocalFile(client, filePath, {
knowledgeBaseId,
parentId: options.parent,
});
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) {
log.error(`File not found: ${resolved}`);
process.exit(1);
return;
}
const stat = fs.statSync(resolved);
const fileName = path.basename(resolved);
const fileBuffer = fs.readFileSync(resolved);
// Compute SHA-256 hash
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// Detect MIME type from extension
const ext = path.extname(fileName).toLowerCase().slice(1);
const mimeMap: Record<string, string> = {
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
gif: 'image/gif',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
json: 'application/json',
md: 'text/markdown',
mp3: 'audio/mpeg',
mp4: 'video/mp4',
pdf: 'application/pdf',
png: 'image/png',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
svg: 'image/svg+xml',
txt: 'text/plain',
webp: 'image/webp',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
const fileType = mimeMap[ext] || 'application/octet-stream';
const client = await getTrpcClient();
const { serverUrl, headers } = await getAuthInfo();
// 1. Get presigned URL
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
const pathname = `files/${date}/${hash}.${ext}`;
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
// 2. Upload to S3
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
const uploadRes = await fetch(presignedUrl, {
body: fileBuffer,
headers: { 'Content-Type': fileType },
method: 'PUT',
});
if (!uploadRes.ok) {
log.error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
process.exit(1);
}
// 3. Create file record
const result = await client.file.createFile.mutate({
fileType,
hash,
knowledgeBaseId,
metadata: {
date,
dirname: '',
filename: fileName,
path: pathname,
},
name: fileName,
parentId: options.parent,
size: stat.size,
url: pathname,
});
console.log(
`${pc.green('✓')} Uploaded ${pc.bold(path.basename(filePath))}${pc.bold((result as any).id)}`,
`${pc.green('✓')} Uploaded ${pc.bold(fileName)}${pc.bold((result as any).id)}`,
);
});
}
+1 -31
View File
@@ -1,8 +1,7 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { clearCredentials } from '../auth/credentials';
import { stopDaemon } from '../daemon/manager';
import { log } from '../utils/logger';
import { registerLogoutCommand } from './logout';
@@ -10,10 +9,6 @@ vi.mock('../auth/credentials', () => ({
clearCredentials: vi.fn(),
}));
vi.mock('../daemon/manager', () => ({
stopDaemon: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
@@ -24,11 +19,6 @@ vi.mock('../utils/logger', () => ({
}));
describe('logout command', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(stopDaemon).mockReturnValue(false);
});
function createProgram() {
const program = new Command();
program.exitOverride();
@@ -54,24 +44,4 @@ describe('logout command', () => {
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Already logged out'));
});
it('should stop the connect daemon before clearing credentials', async () => {
vi.mocked(stopDaemon).mockReturnValue(true);
vi.mocked(clearCredentials).mockReturnValue(true);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Disconnected device daemon'));
});
it('should still attempt daemon teardown when no credentials exist', async () => {
vi.mocked(clearCredentials).mockReturnValue(false);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(stopDaemon).toHaveBeenCalled();
});
});
-9
View File
@@ -1,7 +1,6 @@
import type { Command } from 'commander';
import { clearCredentials } from '../auth/credentials';
import { stopDaemon } from '../daemon/manager';
import { log } from '../utils/logger';
export function registerLogoutCommand(program: Command) {
@@ -9,14 +8,6 @@ export function registerLogoutCommand(program: Command) {
.command('logout')
.description('Log out and remove stored credentials')
.action(() => {
// Tear down the connect daemon first — otherwise it keeps the device
// online on the gateway with the cached token even after credentials are
// gone, leaving the machine remotely driveable past "logout".
const stopped = stopDaemon();
if (stopped) {
log.info('Disconnected device daemon.');
}
const removed = clearCredentials();
if (removed) {
log.info('Logged out. Credentials removed.');
-58
View File
@@ -100,19 +100,6 @@ describe('model command', () => {
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(visibleModels, null, 2));
});
it('should normalize the legacy `stt` type to `asr` when filtering', async () => {
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([
{ displayName: 'Whisper', enabled: true, id: 'whisper-1', type: 'asr' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'list', 'openai', '--type', 'stt']);
expect(mockTrpcClient.aiModel.getAiProviderModelList.query).toHaveBeenCalledWith(
expect.objectContaining({ id: 'openai', type: 'asr' }),
);
});
});
describe('view', () => {
@@ -170,28 +157,6 @@ describe('model command', () => {
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created model'));
});
it('should normalize the legacy `stt` type to `asr`', async () => {
mockTrpcClient.aiModel.createAiModel.mutate.mockResolvedValue('whisper-1');
const program = createProgram();
await program.parseAsync([
'node',
'test',
'model',
'create',
'--id',
'whisper-1',
'--provider',
'openai',
'--type',
'stt',
]);
expect(mockTrpcClient.aiModel.createAiModel.mutate).toHaveBeenCalledWith(
expect.objectContaining({ id: 'whisper-1', providerId: 'openai', type: 'asr' }),
);
});
});
describe('edit', () => {
@@ -219,29 +184,6 @@ describe('model command', () => {
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated model'));
});
it('should normalize the legacy `stt` type to `asr`', async () => {
mockTrpcClient.aiModel.updateAiModel.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'model',
'edit',
'whisper-1',
'--provider',
'openai',
'--type',
'stt',
]);
expect(mockTrpcClient.aiModel.updateAiModel.mutate).toHaveBeenCalledWith({
id: 'whisper-1',
providerId: 'openai',
value: expect.objectContaining({ type: 'asr' }),
});
});
it('should error when no changes specified', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'edit', 'gpt-4', '--provider', 'openai']);
+8 -15
View File
@@ -7,11 +7,6 @@ import { log } from '../utils/logger';
const isVisibleModel = (model: { visible?: boolean }) => model.visible !== false;
// The model type `stt` was renamed to the standard `asr`. Accept the legacy
// alias on CLI input and forward/compare `asr`, so existing scripts and muscle
// memory keep working against the new router schema.
const normalizeModelType = (type: string): string => (type === 'stt' ? 'asr' : type);
export function registerModelCommand(program: Command) {
const model = program.command('model').description('Manage AI models');
@@ -24,7 +19,7 @@ export function registerModelCommand(program: Command) {
.option('--enabled', 'Only show enabled models')
.option(
'--type <type>',
'Filter by model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
'Filter by model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
)
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
@@ -34,20 +29,18 @@ export function registerModelCommand(program: Command) {
) => {
const client = await getTrpcClient();
const typeFilter = options.type ? normalizeModelType(options.type) : undefined;
const input: Record<string, any> = { id: providerId };
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
if (options.enabled) input.enabled = true;
if (typeFilter) input.type = typeFilter;
if (options.type) input.type = options.type;
const result = await client.aiModel.getAiProviderModelList.query(input as any);
let items = (Array.isArray(result) ? result : ((result as any).items ?? [])).filter(
isVisibleModel,
);
if (typeFilter) {
items = items.filter((m: any) => m.type === typeFilter);
if (options.type) {
items = items.filter((m: any) => m.type === options.type);
}
if (options.json !== undefined) {
@@ -113,7 +106,7 @@ export function registerModelCommand(program: Command) {
.option('--display-name <name>', 'Display name')
.option(
'--type <type>',
'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
'chat',
)
.action(
@@ -123,7 +116,7 @@ export function registerModelCommand(program: Command) {
const input: Record<string, any> = {
id: options.id,
providerId: options.provider,
type: normalizeModelType(options.type || 'chat'),
type: options.type || 'chat',
};
if (options.displayName) input.displayName = options.displayName;
@@ -139,7 +132,7 @@ export function registerModelCommand(program: Command) {
.description('Update model info')
.requiredOption('--provider <providerId>', 'Provider ID')
.option('--display-name <name>', 'Display name')
.option('--type <type>', 'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)')
.option('--type <type>', 'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)')
.action(
async (id: string, options: { displayName?: string; provider: string; type?: string }) => {
if (!options.displayName && !options.type) {
@@ -151,7 +144,7 @@ export function registerModelCommand(program: Command) {
const value: Record<string, any> = {};
if (options.displayName) value.displayName = options.displayName;
if (options.type) value.type = normalizeModelType(options.type);
if (options.type) value.type = options.type;
await client.aiModel.updateAiModel.mutate({
id,
-46
View File
@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildInstallCommand, compareSemver } from './update';
describe('compareSemver', () => {
it('compares core versions', () => {
expect(compareSemver('1.2.3', '1.2.2')).toBe(1);
expect(compareSemver('1.2.2', '1.2.3')).toBe(-1);
expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
expect(compareSemver('2.0.0', '1.9.9')).toBe(1);
});
it('tolerates a leading v and missing segments', () => {
expect(compareSemver('v1.2.0', '1.2.0')).toBe(0);
expect(compareSemver('1.2', '1.2.0')).toBe(0);
expect(compareSemver('1.3', '1.2.9')).toBe(1);
});
it('ranks a stable release above a prerelease of the same core', () => {
expect(compareSemver('1.2.3', '1.2.3-beta.1')).toBe(1);
expect(compareSemver('1.2.3-beta.1', '1.2.3')).toBe(-1);
expect(compareSemver('1.2.3-beta.2', '1.2.3-beta.1')).toBe(1);
expect(compareSemver('1.2.3-beta.1', '1.2.3-beta.1')).toBe(0);
});
});
describe('buildInstallCommand', () => {
it('builds the global install command per package manager', () => {
expect(buildInstallCommand('npm', '@lobehub/cli@1.0.0')).toEqual({
args: ['install', '-g', '@lobehub/cli@1.0.0'],
command: 'npm',
});
expect(buildInstallCommand('pnpm', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'pnpm',
});
expect(buildInstallCommand('bun', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'bun',
});
expect(buildInstallCommand('yarn', '@lobehub/cli@1.0.0')).toEqual({
args: ['global', 'add', '@lobehub/cli@1.0.0'],
command: 'yarn',
});
});
});
-187
View File
@@ -1,187 +0,0 @@
import { spawn } from 'node:child_process';
import { realpathSync } from 'node:fs';
import type { Command } from 'commander';
import pc from 'picocolors';
// Pull package metadata from the shared `src/pkg.ts` module (resolved at the
// bundled entry's depth) rather than a local `require('../../package.json')`,
// which would point outside the package once bundled into dist/index.js.
import { cliPackageName, cliVersion } from '../pkg';
import { log } from '../utils/logger';
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
interface UpdateOptions {
check?: boolean;
packageManager?: PackageManager;
tag?: string;
}
/**
* Detect which package manager installed the CLI so we run the matching global
* upgrade command. We first trust an explicit `npm_config_user_agent` (set when
* invoked through a package-manager script) and otherwise infer from the path of
* the running binary. Falls back to npm.
*/
export function detectPackageManager(): PackageManager {
const ua = process.env.npm_config_user_agent;
if (ua) {
if (ua.startsWith('pnpm')) return 'pnpm';
if (ua.startsWith('yarn')) return 'yarn';
if (ua.startsWith('bun')) return 'bun';
if (ua.startsWith('npm')) return 'npm';
}
try {
const binPath = realpathSync(process.argv[1] ?? '').replaceAll('\\', '/');
if (binPath.includes('/pnpm/')) return 'pnpm';
if (binPath.includes('/.bun/') || binPath.includes('/bun/')) return 'bun';
if (binPath.includes('/yarn/') || binPath.includes('/.yarn/')) return 'yarn';
} catch {
// ignore fall back to npm
}
return 'npm';
}
/** Build the global-install command for the detected package manager. */
export function buildInstallCommand(
pm: PackageManager,
spec: string,
): { args: string[]; command: string } {
switch (pm) {
case 'pnpm': {
return { args: ['add', '-g', spec], command: 'pnpm' };
}
case 'yarn': {
return { args: ['global', 'add', spec], command: 'yarn' };
}
case 'bun': {
return { args: ['add', '-g', spec], command: 'bun' };
}
default: {
return { args: ['install', '-g', spec], command: 'npm' };
}
}
}
/** Compare two semver strings. Returns 1 if a > b, -1 if a < b, 0 if equal. */
export function compareSemver(a: string, b: string): number {
const parse = (value: string) => {
const [core, pre] = value.replace(/^v/, '').split('-');
const nums = core.split('.').map((part) => Number.parseInt(part, 10) || 0);
return { nums, pre };
};
const pa = parse(a);
const pb = parse(b);
for (let i = 0; i < 3; i += 1) {
const diff = (pa.nums[i] || 0) - (pb.nums[i] || 0);
if (diff !== 0) return diff > 0 ? 1 : -1;
}
// Equal core: a stable release outranks a prerelease of the same core.
if (!pa.pre && pb.pre) return 1;
if (pa.pre && !pb.pre) return -1;
if (pa.pre && pb.pre) return pa.pre === pb.pre ? 0 : pa.pre > pb.pre ? 1 : -1;
return 0;
}
async function fetchLatestVersion(name: string, tag: string): Promise<string> {
const url = `https://registry.npmjs.org/${name}/${encodeURIComponent(tag)}`;
const res = await fetch(url, { headers: { accept: 'application/json' } });
if (!res.ok) {
throw new Error(`npm registry returned status ${res.status} for tag "${tag}"`);
}
const data = (await res.json()) as { version?: string };
if (!data.version) {
throw new Error('npm registry response is missing the "version" field');
}
return data.version;
}
function runInstall(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
shell: process.platform === 'win32',
stdio: 'inherit',
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} exited with code ${code ?? 'null'}`));
});
});
}
export function registerUpdateCommand(program: Command) {
program
.command('update')
.description('Update the LobeHub CLI to the latest published version')
.option('--check', 'Only check for a newer version without installing')
.option('--tag <tag>', 'npm dist-tag to update to', 'latest')
.option(
'--package-manager <pm>',
`Force a package manager (${PACKAGE_MANAGERS.join(', ')}) instead of auto-detecting`,
)
.action(async (options: UpdateOptions) => {
if (options.packageManager && !PACKAGE_MANAGERS.includes(options.packageManager)) {
log.error(
`Unsupported package manager "${options.packageManager}". Use one of: ${PACKAGE_MANAGERS.join(', ')}.`,
);
process.exit(1);
return;
}
const current = cliVersion;
const tag = options.tag || 'latest';
log.info(`Current version: ${pc.bold(current)}`);
let latest: string;
try {
latest = await fetchLatestVersion(cliPackageName, tag);
} catch (error) {
log.error(`Unable to check for updates: ${(error as Error).message}`);
process.exit(1);
return;
}
log.info(`Latest version: ${pc.bold(latest)} ${pc.dim(`(${tag})`)}`);
if (compareSemver(latest, current) <= 0) {
log.info(pc.green('Already on the latest version.'));
return;
}
if (options.check) {
log.info(
`Update available: ${current}${pc.green(latest)}. Run ${pc.cyan('lh update')} to upgrade.`,
);
return;
}
const pm = options.packageManager || detectPackageManager();
const spec = `${cliPackageName}@${latest}`;
const { args, command } = buildInstallCommand(pm, spec);
log.info(`Upgrading via ${pc.bold(pm)}: ${pc.dim([command, ...args].join(' '))}`);
try {
await runInstall(command, args);
log.info(pc.green(`Successfully updated to ${latest}. Restart any running sessions.`));
} catch (error) {
log.error(`Update failed: ${(error as Error).message}`);
log.error(`You can upgrade manually: ${[command, ...args].join(' ')}`);
process.exit(1);
}
});
}
-82
View File
@@ -19,22 +19,11 @@ vi.mock('node:os', async (importOriginal) => {
};
});
// Mock only `execFileSync` (used by isDaemonProcess to read a process command
// line); keep the real `spawn` so nothing else changes.
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
return { ...actual, execFileSync: vi.fn() };
});
// eslint-disable-next-line import-x/first
import { execFileSync } from 'node:child_process';
// eslint-disable-next-line import-x/first
import {
appendLog,
getLogPath,
getRunningDaemonPid,
isDaemonProcess,
isProcessAlive,
readPid,
readStatus,
@@ -46,15 +35,9 @@ import {
writeStatus,
} from './manager';
// A command line that matches the daemon signature (`connect … --daemon-child`).
const DAEMON_COMMAND = '/usr/local/bin/node /path/to/cli.js connect --daemon-child';
describe('daemon manager', () => {
beforeEach(async () => {
await mkdir(mockDir, { recursive: true });
// Default: any inspected PID looks like our daemon. Tests that need a
// reused / unrelated PID override this per-case.
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
});
afterEach(() => {
@@ -97,36 +80,6 @@ describe('daemon manager', () => {
});
});
describe('isDaemonProcess', () => {
it('should return true when the command line matches the daemon signature', () => {
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
expect(isDaemonProcess(12345)).toBe(true);
expect(execFileSync).toHaveBeenCalledWith(
'ps',
['-ww', '-p', '12345', '-o', 'command='],
expect.any(Object),
);
});
it('should return false for an unrelated process command line', () => {
vi.mocked(execFileSync).mockReturnValue('/usr/bin/vim notes.txt' as any);
expect(isDaemonProcess(12345)).toBe(false);
});
it('should return false when the signature is only partially present', () => {
// `connect` without the internal `--daemon-child` flag is not our daemon.
vi.mocked(execFileSync).mockReturnValue('/usr/bin/node /path/cli connect' as any);
expect(isDaemonProcess(12345)).toBe(false);
});
it('should return false when ps is unavailable / throws', () => {
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('ps: command not found');
});
expect(isDaemonProcess(12345)).toBe(false);
});
});
describe('getRunningDaemonPid', () => {
it('should return null when no PID file', () => {
expect(getRunningDaemonPid()).toBeNull();
@@ -157,23 +110,6 @@ describe('daemon manager', () => {
expect(readStatus()).toBeNull();
});
it('should treat a live but reused (non-daemon) PID as stale and clean up', () => {
// process.pid is alive, but the inspected command line is not our daemon —
// simulates the OS reusing a dead daemon's PID for an unrelated process.
writePid(process.pid);
writeStatus({
connectionStatus: 'connected',
gatewayUrl: 'https://test.com',
pid: process.pid,
startedAt: new Date().toISOString(),
});
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
expect(getRunningDaemonPid()).toBeNull();
expect(readPid()).toBeNull();
expect(readStatus()).toBeNull();
});
});
describe('status file', () => {
@@ -296,23 +232,5 @@ describe('daemon manager', () => {
killSpy.mockRestore();
});
it('should NOT SIGTERM a live PID that is not our daemon', () => {
// Stale daemon.pid whose PID was reused by an unrelated, living process.
writePid(process.pid);
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const result = stopDaemon();
expect(result).toBe(false);
// Only the liveness probe (signal 0) is allowed — never a real SIGTERM.
expect(killSpy).not.toHaveBeenCalledWith(process.pid, 'SIGTERM');
// Stale metadata is cleaned up so we don't keep re-checking it.
expect(readPid()).toBeNull();
killSpy.mockRestore();
});
});
});
+3 -33
View File
@@ -1,4 +1,4 @@
import { execFileSync, spawn } from 'node:child_process';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -70,34 +70,6 @@ export function isProcessAlive(pid: number): boolean {
}
}
/**
* Verify a live PID actually belongs to a LobeHub connect daemon.
*
* A bare `isProcessAlive` check is not enough: if a daemon dies without cleaning
* up `daemon.pid` (crash, `kill -9`, reboot), the OS can later reuse that PID
* for an unrelated process. Acting on the stale PID would let `lh logout` /
* `connect stop` SIGTERM a stranger. The daemon is always spawned as
* `<node> … connect … --daemon-child`, so we confirm that signature in the
* process command line before trusting the PID.
*
* Best-effort and deliberately conservative: if the command line can't be read
* (e.g. `ps` is unavailable), we return `false` so callers never kill a process
* we can't positively identify.
*/
export function isDaemonProcess(pid: number): boolean {
try {
// `-ww` disables column truncation so the trailing `--daemon-child` flag is
// never cut off; stderr is silenced so a dead PID just yields an empty match.
const command = execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return command.includes('--daemon-child') && command.includes('connect');
} catch {
return false;
}
}
/**
* Get the PID of a running daemon, cleaning up stale PID files.
* Returns null if no daemon is running.
@@ -106,11 +78,9 @@ export function getRunningDaemonPid(): number | null {
const pid = readPid();
if (pid === null) return null;
// Require both liveness AND identity — a live-but-reused PID is treated as
// stale so we never act on a process that isn't ours.
if (isProcessAlive(pid) && isDaemonProcess(pid)) return pid;
if (isProcessAlive(pid)) return pid;
// Stale PID file — process is dead or the PID now belongs to someone else.
// Stale PID file — process is dead
removePid();
removeStatus();
return null;
-16
View File
@@ -1,16 +0,0 @@
import { createRequire } from 'node:module';
/**
* Single source of truth for this package's own metadata.
*
* Must live directly under `src/` (depth 1), the same depth as the bundled
* entry `dist/index.js`, so `../package.json` resolves to `@lobehub/cli`'s own
* package.json both when running from source (`bun src/index.ts`) and from the
* tsdown bundle (`dist/index.js`). A module one directory deeper would resolve
* the path outside the package once everything is bundled into a single file.
*/
const require = createRequire(import.meta.url);
const pkg = require('../package.json') as { name: string; version: string };
export const cliPackageName = pkg.name;
export const cliVersion = pkg.version;
+7 -5
View File
@@ -1,3 +1,5 @@
import { createRequire } from 'node:module';
import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
@@ -31,10 +33,11 @@ import { registerStatusCommand } from './commands/status';
import { registerTaskCommand } from './commands/task';
import { registerThreadCommand } from './commands/thread';
import { registerTopicCommand } from './commands/topic';
import { registerUpdateCommand } from './commands/update';
import { registerUserCommand } from './commands/user';
import { registerVerifyCommand } from './commands/verify';
import { cliVersion } from './pkg';
const require = createRequire(import.meta.url);
const { version } = require('../package.json');
export function createProgram() {
const program = new Command();
@@ -42,7 +45,7 @@ export function createProgram() {
program
.name('lh')
.description('LobeHub CLI - manage and connect to LobeHub services')
.version(cliVersion);
.version(version);
registerLoginCommand(program);
registerLogoutCommand(program);
@@ -77,9 +80,8 @@ export function createProgram() {
registerConfigCommand(program);
registerEvalCommand(program);
registerMigrateCommand(program);
registerUpdateCommand(program);
return program;
}
export { cliPackageName, cliVersion } from './pkg';
export { version as cliVersion };
-125
View File
@@ -1,125 +0,0 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { TrpcClient } from '../api/client';
/**
* Minimal extension → MIME map for files uploaded from the local filesystem.
* Unknown extensions fall back to `application/octet-stream`.
*/
const MIME_MAP: Record<string, string> = {
aac: 'audio/aac',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
flac: 'audio/flac',
gif: 'image/gif',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
json: 'application/json',
m4a: 'audio/mp4',
md: 'text/markdown',
mp3: 'audio/mpeg',
mp4: 'video/mp4',
ogg: 'audio/ogg',
pdf: 'application/pdf',
png: 'image/png',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
svg: 'image/svg+xml',
txt: 'text/plain',
wav: 'audio/wav',
webm: 'audio/webm',
webp: 'image/webp',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
/**
* Detect a MIME type from a file name's extension.
*/
export const detectMimeType = (fileName: string): string => {
const ext = path.extname(fileName).toLowerCase().slice(1);
return MIME_MAP[ext] || 'application/octet-stream';
};
export interface UploadLocalFileOptions {
knowledgeBaseId?: string;
parentId?: string;
}
/**
* Read a file from the local filesystem, upload it to S3 via a pre-signed URL,
* and create the corresponding file record. Shared by `file upload` and
* `kb upload`.
*
* @returns the created file record
*/
export const uploadLocalFile = async (
client: TrpcClient,
filePath: string,
options: UploadLocalFileOptions = {},
) => {
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) {
throw new Error(`File not found: ${resolved}`);
}
const stat = fs.statSync(resolved);
if (!stat.isFile()) {
throw new Error(`Not a file: ${resolved}`);
}
const fileName = path.basename(resolved);
const fileBuffer = fs.readFileSync(resolved);
// Compute SHA-256 hash for deduplication
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const ext = path.extname(fileName).toLowerCase().slice(1);
const fileType = detectMimeType(fileName);
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
// 1. Dedup: if the same bytes are already stored (and the object still
// exists), skip the S3 upload entirely and reuse the existing url.
const existing = (await client.file.checkFileHash.mutate({ hash })) as {
isExist?: boolean;
url?: string;
};
let pathname: string;
if (existing?.isExist && existing.url) {
pathname = existing.url;
} else {
// 2. Get a pre-signed upload URL and PUT the bytes to S3
pathname = ext ? `files/${date}/${hash}.${ext}` : `files/${date}/${hash}`;
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
const uploadRes = await fetch(presignedUrl, {
body: fileBuffer,
headers: { 'Content-Type': fileType },
method: 'PUT',
});
if (!uploadRes.ok) {
throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
}
}
// 3. Create the file record
return await client.file.createFile.mutate({
fileType,
hash,
knowledgeBaseId: options.knowledgeBaseId,
metadata: {
date,
dirname: '',
filename: fileName,
path: pathname,
},
name: fileName,
parentId: options.parentId,
size: stat.size,
url: pathname,
});
};
+2 -2
View File
@@ -127,8 +127,8 @@
],
"overrides": {
"node-gyp": "^12.4.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"vitest": "3.2.6"
}
}
-6
View File
@@ -17,9 +17,3 @@ packages:
- './stubs/business-const'
- './stubs/types'
- '.'
allowBuilds:
electron: set this to true or false
electron-winstaller: set this to true or false
esbuild: set this to true or false
get-windows: set this to true or false
node-mac-permissions: set this to true or false
@@ -15,7 +15,6 @@ import type {
GitWorkingTreeFiles,
GitWorkingTreePatches,
GitWorkingTreeStatus,
GitWorktreeListItem,
} from '@lobechat/electron-client-ipc';
import {
checkoutGitBranch as runCheckoutGitBranch,
@@ -31,7 +30,6 @@ import {
gitInfo as computeGitInfo,
listGitBranches as computeListGitBranches,
listGitRemoteBranches as computeListGitRemoteBranches,
listGitWorktrees as computeListGitWorktrees,
pullGitBranch as runPullGitBranch,
pushGitBranch as runPushGitBranch,
renameGitBranch as runRenameGitBranch,
@@ -85,11 +83,6 @@ export default class GitController extends ControllerModule {
return computeListGitRemoteBranches(dirPath);
}
@IpcMethod()
async listGitWorktrees(dirPath: string): Promise<GitWorktreeListItem[]> {
return computeListGitWorktrees(dirPath);
}
@IpcMethod()
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
return computeGitWorkingTreeStatus(dirPath);
@@ -366,14 +366,14 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async readFiles({ paths, cwd }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
logger.debug('Starting batch file reading:', { count: paths.length });
const results: LocalReadFileResult[] = [];
for (const filePath of paths) {
logger.debug('Reading single file:', { filePath });
const result = await readLocalFile({ cwd, path: filePath });
const result = await readLocalFile({ path: filePath });
results.push(result);
}
@@ -400,9 +400,9 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async handleMoveFiles({ items, cwd }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
logger.debug('Starting batch file move:', { itemsCount: items?.length });
return moveLocalFiles({ cwd, items });
return moveLocalFiles({ items });
}
@IpcMethod()
@@ -418,9 +418,9 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async handleWriteFile({ path: filePath, content, cwd }: WriteLocalFileParams) {
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
logger.debug(`Writing file ${filePath}`, { contentLength: content?.length });
return writeLocalFile({ content, cwd, path: filePath });
return writeLocalFile({ content, path: filePath });
}
@IpcMethod()
@@ -438,14 +438,12 @@ export default class LocalFileCtr extends ControllerModule {
@IpcMethod()
async getLocalFilePreviewUrl({
accept,
allowExternalFile,
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
try {
const url = await this.app.localFileProtocolManager.createPreviewUrl({
accept,
allowExternalFile,
filePath,
workspaceRoot: workingDirectory,
});
@@ -464,14 +462,12 @@ export default class LocalFileCtr extends ControllerModule {
@IpcMethod()
async getLocalFilePreview({
accept,
allowExternalFile,
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewResult> {
try {
const preview = await this.app.localFileProtocolManager.readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot: workingDirectory,
});
@@ -226,7 +226,6 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
accept: undefined,
allowExternalFile: undefined,
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
@@ -263,7 +262,6 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
accept: 'image',
allowExternalFile: undefined,
filePath: '/workspace/image.png',
workspaceRoot: '/workspace',
});
@@ -272,29 +270,6 @@ describe('LocalFileCtr', () => {
url: 'localfile://file/workspace/image.png?token=abc',
});
});
it('should forward user-approved external preview URL access', async () => {
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
);
const result = await localFileCtr.getLocalFilePreviewUrl({
allowExternalFile: true,
path: '/tmp/worktree-switcher-demo.html',
workingDirectory: '/tmp',
});
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
allowExternalFile: true,
accept: undefined,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(result).toEqual({
success: true,
url: 'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
});
});
});
describe('getLocalFilePreview', () => {
@@ -312,7 +287,6 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
accept: undefined,
allowExternalFile: undefined,
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
@@ -355,7 +329,6 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
accept: 'image',
allowExternalFile: undefined,
filePath: '/workspace/image.png',
workspaceRoot: '/workspace',
});
@@ -368,35 +341,6 @@ describe('LocalFileCtr', () => {
success: true,
});
});
it('should forward user-approved external preview reads', async () => {
mockLocalFileProtocolManager.readPreviewFile.mockResolvedValue({
buffer: Buffer.from('<h1>Demo</h1>'),
contentType: 'text/html',
realPath: '/tmp/worktree-switcher-demo.html',
});
const result = await localFileCtr.getLocalFilePreview({
allowExternalFile: true,
path: '/tmp/worktree-switcher-demo.html',
workingDirectory: '/tmp',
});
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
allowExternalFile: true,
accept: undefined,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(result).toEqual({
preview: {
content: '<h1>Demo</h1>',
contentType: 'text/html',
type: 'text',
},
success: true,
});
});
});
describe('handleWriteFile', () => {
@@ -21,7 +21,6 @@ const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
const logger = createLogger('core:LocalFileProtocolManager');
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
const EXTERNAL_PREVIEW_APPROVAL_TTL_MS = 10 * 60 * 1000;
const normalizeAbsolutePath = (filePath: string): string | null => {
const normalized = path.normalize(filePath);
@@ -60,7 +59,10 @@ type PreviewFileAccept = 'image';
const normalizeContentType = (contentType: string): string =>
contentType.split(';')[0].trim().toLowerCase();
const isAcceptedPreviewContentType = (contentType: string, accept?: PreviewFileAccept): boolean => {
const isAcceptedPreviewContentType = (
contentType: string,
accept?: PreviewFileAccept,
): boolean => {
if (!accept) return true;
const normalizedContentType = normalizeContentType(contentType);
@@ -82,8 +84,6 @@ const isAcceptedPreviewContentType = (contentType: string, accept?: PreviewFileA
export class LocalFileProtocolManager {
private readonly approvedWorkspaceRoots = new Set<string>();
private readonly externalPreviewApprovals = new Map<string, number>();
private readonly indexedProjectRoots = new Set<string>();
private handlerRegistered = false;
@@ -229,12 +229,10 @@ export class LocalFileProtocolManager {
async createPreviewUrl({
accept,
allowExternalFile,
filePath,
workspaceRoot,
}: {
accept?: PreviewFileAccept;
allowExternalFile?: boolean;
filePath: string;
workspaceRoot: string;
}): Promise<string | null> {
@@ -245,12 +243,11 @@ export class LocalFileProtocolManager {
? (
await this.readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot,
})
)?.realPath
: await this.resolveApprovedPreviewPath({ allowExternalFile, filePath, workspaceRoot });
: await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
if (!realFilePath) return null;
this.cleanupExpiredTokens();
@@ -266,21 +263,14 @@ export class LocalFileProtocolManager {
async readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot,
}: {
accept?: PreviewFileAccept;
allowExternalFile?: boolean;
filePath: string;
workspaceRoot: string;
}): Promise<PreviewFileReadResult | null> {
const realFilePath = await this.resolveApprovedPreviewPath({
allowExternalFile,
filePath,
persistExternalApproval: false,
workspaceRoot,
});
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
if (!realFilePath) return null;
const fileStat = await stat(realFilePath);
@@ -290,10 +280,6 @@ export class LocalFileProtocolManager {
const contentType = resolveLocalFileMimeType(realFilePath, buffer);
if (!isAcceptedPreviewContentType(contentType, accept)) return null;
if (allowExternalFile) {
this.grantExternalPreviewApproval(realFilePath);
}
return {
buffer,
contentType,
@@ -341,14 +327,10 @@ export class LocalFileProtocolManager {
}
private async resolveApprovedPreviewPath({
allowExternalFile,
filePath,
persistExternalApproval = true,
workspaceRoot,
}: {
allowExternalFile?: boolean;
filePath: string;
persistExternalApproval?: boolean;
workspaceRoot: string;
}): Promise<string | null> {
const normalizedFilePath = normalizeAbsolutePath(filePath);
@@ -363,44 +345,15 @@ export class LocalFileProtocolManager {
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
const workspaceRootApproved =
this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) ||
this.indexedProjectRoots.has(normalizedRealWorkspaceRoot);
if (
workspaceRootApproved &&
isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
) {
return normalizedRealFilePath;
return null;
}
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
if (this.hasExternalPreviewApproval(normalizedRealFilePath)) return normalizedRealFilePath;
if (allowExternalFile) {
return this.approveExternalPreviewFile(normalizedRealFilePath, {
persist: persistExternalApproval,
});
}
return null;
}
private async approveExternalPreviewFile(
realFilePath: string,
{ persist = true }: { persist?: boolean } = {},
): Promise<string | null> {
const fileStat = await stat(realFilePath);
if (!fileStat.isFile()) return null;
if (persist) {
this.grantExternalPreviewApproval(realFilePath);
}
return realFilePath;
}
private grantExternalPreviewApproval(realFilePath: string) {
this.cleanupExpiredExternalPreviewApprovals();
this.externalPreviewApprovals.set(realFilePath, Date.now() + EXTERNAL_PREVIEW_APPROVAL_TTL_MS);
return normalizedRealFilePath;
}
private cleanupExpiredTokens() {
@@ -412,15 +365,6 @@ export class LocalFileProtocolManager {
}
}
private cleanupExpiredExternalPreviewApprovals() {
const now = Date.now();
for (const [realPath, expiresAt] of this.externalPreviewApprovals) {
if (expiresAt <= now) {
this.externalPreviewApprovals.delete(realPath);
}
}
}
private hasPreviewToken(token: string): boolean {
const record = this.previewTokens.get(token);
if (!record) return false;
@@ -439,16 +383,4 @@ export class LocalFileProtocolManager {
return record.realPath === realResolvedPath;
}
private hasExternalPreviewApproval(realFilePath: string): boolean {
const expiresAt = this.externalPreviewApprovals.get(realFilePath);
if (!expiresAt) return false;
if (expiresAt <= Date.now()) {
this.externalPreviewApprovals.delete(realFilePath);
return false;
}
return true;
}
}
@@ -263,31 +263,6 @@ describe('LocalFileProtocolManager', () => {
expect(url).toBeNull();
});
it('mints preview URLs for user-approved external files only', async () => {
const manager = new LocalFileProtocolManager();
const url = await manager.createPreviewUrl({
allowExternalFile: true,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
if (!url) throw new Error('Expected external local file preview URL');
expect(url).toContain('token=');
const repeatedUrl = await manager.createPreviewUrl({
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(repeatedUrl).toContain('token=');
const neighborUrl = await manager.createPreviewUrl({
filePath: '/tmp/other.html',
workspaceRoot: '/tmp',
});
expect(neighborUrl).toBeNull();
});
it('can approve a project root derived from an already approved nested scope', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveWorkspaceRoot('/Users/alice/project/packages/app');
@@ -351,26 +326,6 @@ describe('LocalFileProtocolManager', () => {
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/.env');
});
it('does not keep external approval when an image-only external preview rejects text', async () => {
const manager = new LocalFileProtocolManager();
mockReadFile.mockResolvedValue(Buffer.from('SECRET=value'));
const result = await manager.readPreviewFile({
accept: 'image',
allowExternalFile: true,
filePath: '/tmp/secret.txt',
workspaceRoot: '/tmp',
});
expect(result).toBeNull();
const repeatedUrl = await manager.createPreviewUrl({
filePath: '/tmp/secret.txt',
workspaceRoot: '/tmp',
});
expect(repeatedUrl).toBeNull();
});
it('does not read preview payloads outside the approved workspace root', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveIndexedProjectRoot('/Users/alice/project');
@@ -1,5 +1,5 @@
// apps/desktop/src/main/menus/impl/BaseMenuPlatform.ts
import type { BaseWindow, MenuItemConstructorOptions } from 'electron';
import type { MenuItemConstructorOptions } from 'electron';
import { BrowserWindow } from 'electron';
import type { App } from '@/core/App';
@@ -34,26 +34,6 @@ export abstract class BaseMenuPlatform {
];
}
protected closeFocusedTabOrWindow(targetWindow?: BaseWindow | null): void {
const focused =
targetWindow && 'webContents' in targetWindow
? (targetWindow as BrowserWindow)
: BrowserWindow.getFocusedWindow();
if (!focused) return;
if (focused.webContents.isDevToolsOpened()) {
focused.webContents.closeDevTools();
return;
}
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
}
private buildZoomMenuItemOption(
action: ZoomAction,
label: string,
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, Menu, shell } from 'electron';
import { app, dialog, Menu, shell } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
@@ -7,9 +7,6 @@ import { LinuxMenu } from './linux';
// Mock Electron modules
vi.mock('electron', () => ({
BrowserWindow: class BrowserWindow {
static getFocusedWindow = vi.fn();
},
Menu: {
buildFromTemplate: vi.fn((template) => ({ template })),
setApplicationMenu: vi.fn(),
@@ -342,100 +339,6 @@ describe('LinuxMenu', () => {
expect(closeItem.role).toBeUndefined();
});
it('should close open DevTools before delegating CmdOrCtrl+W to renderer window logic', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => true),
},
};
closeItem.click(undefined, focusedWindow);
expect(focusedWindow.webContents.closeDevTools).toHaveBeenCalled();
expect(focusedWindow.close).not.toHaveBeenCalled();
expect(mockApp.browserManager.getMainWindow).not.toHaveBeenCalled();
});
it('should broadcast tab close when CmdOrCtrl+W targets the main window', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const mainBrowserWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => false),
},
};
const broadcast = vi.fn();
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue({
broadcast,
browserWindow: mainBrowserWindow,
} as any);
closeItem.click(undefined, mainBrowserWindow);
expect(broadcast).toHaveBeenCalledWith('closeCurrentTabOrWindow');
expect(mainBrowserWindow.close).not.toHaveBeenCalled();
});
it('should close non-main windows when CmdOrCtrl+W has no DevTools panel to close', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const mainBrowserWindow = {
webContents: {
isDevToolsOpened: vi.fn(() => false),
},
};
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => false),
},
};
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue({
broadcast: vi.fn(),
browserWindow: mainBrowserWindow,
} as any);
closeItem.click(undefined, focusedWindow);
expect(focusedWindow.close).toHaveBeenCalled();
});
it('should use the focused window when Electron does not pass a menu target window', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => true),
},
};
vi.mocked(BrowserWindow.getFocusedWindow).mockReturnValue(focusedWindow as any);
closeItem.click();
expect(focusedWindow.webContents.closeDevTools).toHaveBeenCalled();
});
it('should use role for minimize (accelerator handled by Electron)', () => {
linuxMenu.buildAndSetAppMenu();
+11 -2
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, clipboard, dialog, Menu, shell } from 'electron';
import { app, BrowserWindow, clipboard, dialog, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -122,7 +122,16 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
{ type: 'separator' },
{
accelerator: 'CmdOrCtrl+W',
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
label: t('window.close'),
},
{ label: t('window.minimize'), role: 'minimize' },
+11 -2
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, clipboard, Menu, shell } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -164,7 +164,16 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
{ type: 'separator' },
{
accelerator: 'CmdOrCtrl+W',
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
label: t('window.close'),
},
],
+11 -2
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, clipboard, Menu, shell } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -185,7 +185,16 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
{ label: t('window.minimize'), role: 'minimize' },
{
accelerator: 'CmdOrCtrl+W',
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
label: t('window.close'),
},
],
@@ -38,12 +38,7 @@ import {
ToolResolver,
} from '@lobechat/context-engine';
import { parse } from '@lobechat/conversation-flow';
import {
applyModelExtendParams,
type ChatStreamPayload,
consumeStreamUntilDone,
type ModelExtendParams,
} from '@lobechat/model-runtime';
import { consumeStreamUntilDone } from '@lobechat/model-runtime';
import {
context as otelContext,
SpanKind,
@@ -72,7 +67,6 @@ import {
} from '@lobechat/types';
import { sanitizeToolCallArguments, serializePartsForStorage } from '@lobechat/utils';
import debug from 'debug';
import { type ExtendParamsType, ModelProvider } from 'model-bank';
import { composioEnv } from '@/config/composio';
import { type MessageModel, MessageModel as MessageModelClass } from '@/database/models/message';
@@ -86,10 +80,6 @@ import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/type
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import type { HookDispatcher } from '@/server/services/agentRuntime/hooks/HookDispatcher';
import type {
ExecGroupMemberParams,
ExecGroupMemberResult,
} from '@/server/services/agentRuntime/types';
import {
type DeviceAccessReason,
isDeviceToolIdentifier,
@@ -99,7 +89,6 @@ import { FileService } from '@/server/services/file';
import { MessageService } from '@/server/services/message';
import { OnboardingService } from '@/server/services/onboarding';
import {
type ServerAgentMemberRunner,
type ServerSubAgentRunner,
type ToolExecutionResultResponse,
type ToolExecutionService,
@@ -416,147 +405,6 @@ const buildServerVirtualSubAgentRunner = (
};
};
/**
* Build the per-tool "call agent member" runner for the group orchestration
* server tool (`lobe-group-management`). Mirrors {@link buildServerVirtualSubAgentRunner}
* but for group members: it owns the group tool message (the parked tool call)
* and the per-member anchors that drive the K=N member barrier.
*
* For each `agentMember.run(...)` it:
* 1. creates the group tool placeholder (`tool_call_id` = the group-management
* call id) stamped with the barrier target + finish disposition;
* 2. for a single member uses that placeholder as the member anchor; for
* multiple members creates one child anchor per member under it;
* 3. forks each member via `ctx.execGroupMember` (in-group or isolated);
* 4. backfills anchors for members that failed to start so the barrier can
* still complete, and tears everything down when none started.
*
* Returns `undefined` when group-member execution is unavailable (no
* `execGroupMember` callback, or missing agent/topic/group context).
*/
const buildServerAgentMemberRunner = (
ctx: RuntimeExecutorContext,
state: AgentState,
chatToolPayload: ChatToolPayload,
parentMessageId: string,
): ServerAgentMemberRunner | undefined => {
const execGroupMember = ctx.execGroupMember;
if (!execGroupMember) return undefined;
const agentId = state.metadata?.agentId;
const topicId = ctx.topicId ?? state.metadata?.topicId;
const groupId = state.metadata?.groupId ?? undefined;
if (!agentId || !topicId || !groupId) return undefined;
return {
run: async ({ members, mode, onComplete, disableTools, timeout }) => {
const expectedMembers = members.length;
if (expectedMembers === 0) return { started: false, startedCount: 0 };
// 1. Group tool placeholder — the parked tool call the supervisor op waits
// on. Stamped with the barrier target + finish disposition so the resume
// path (and verify watchdog) resolve resume-vs-finish on their own.
const groupTool = await ctx.messageModel.create({
agentId,
content: '',
parentId: parentMessageId,
plugin: chatToolPayload as any,
pluginState: { expectedMembers, onComplete, status: 'pending' },
role: 'tool',
threadId: state.metadata?.threadId,
tool_call_id: chatToolPayload.id,
topicId,
});
// 2. Per-member anchors. A single member collapses onto the group tool
// message; multiple members each get a child anchor under it.
const anchorIds: string[] = [];
if (expectedMembers === 1) {
anchorIds.push(groupTool.id);
} else {
for (let i = 0; i < expectedMembers; i += 1) {
const memberToolCallId = `${chatToolPayload.id}::m${i}`;
const anchor = await ctx.messageModel.create({
agentId,
content: '',
parentId: groupTool.id,
plugin: { ...(chatToolPayload as any), id: memberToolCallId },
pluginState: { status: 'pending' },
role: 'tool',
threadId: state.metadata?.threadId,
tool_call_id: memberToolCallId,
topicId,
});
anchorIds.push(anchor.id);
}
}
// 3. Fork members.
let startedCount = 0;
await Promise.all(
members.map(async (member, i) => {
const anchorMessageId = anchorIds[i];
try {
const result = await execGroupMember({
agentId: member.agentId,
anchorMessageId,
disableTools,
expectedMembers,
groupId,
groupToolMessageId: groupTool.id,
instruction: member.instruction,
mode,
onComplete,
parentOperationId: ctx.operationId,
timeout,
topicId,
});
if (result?.started) {
startedCount += 1;
return;
}
} catch (error) {
log(
'buildServerAgentMemberRunner: member %s failed to start: %O',
member.agentId,
error,
);
}
// Member failed to start — its completion bridge will never fire, so
// backfill the anchor as errored to keep the K=N barrier reachable.
try {
await ctx.messageModel.updateToolMessage(anchorMessageId, {
content: `Agent member "${member.agentId}" failed to start.`,
pluginState: { status: 'error' },
});
} catch (error) {
log(
'buildServerAgentMemberRunner: failed to mark anchor %s as errored: %O',
anchorMessageId,
error,
);
}
}),
);
// None started — no bridge will ever fire, so tear down the placeholders
// and let the caller surface an inline tool error instead of parking.
if (startedCount === 0) {
for (const id of new Set([...anchorIds, groupTool.id])) {
try {
await ctx.messageModel.deleteMessage(id);
} catch (error) {
log('buildServerAgentMemberRunner: cleanup failed for %s: %O', id, error);
}
}
return { started: false, startedCount: 0 };
}
return { started: true, startedCount };
},
};
};
const shouldRetryLLM = (kind: LLMErrorKind, attempt: number, maxRetries: number) =>
kind === 'retry' && attempt <= maxRetries;
@@ -674,12 +522,6 @@ export interface RuntimeExecutorContext {
botPlatformContext?: BotPlatformContext;
discordContext?: any;
evalContext?: EvalContext;
/**
* Callback to fork a group member ("call agent member") under a
* `lobe-group-management` tool call. Injected by AiAgentService; powers the
* per-tool `agentMember` runner (in-group + isolated members, K=N barrier).
*/
execGroupMember?: (params: ExecGroupMemberParams) => Promise<ExecGroupMemberResult>;
/**
* Callback to run a legacy agent invocation server-side.
* Injected by AiAgentService so exec_sub_agent / exec_sub_agents executors
@@ -879,7 +721,6 @@ export const createRuntimeExecutors = (
type ContentPart = { text: string; type: 'text' } | { image: string; type: 'image' };
let shouldReplayAssistantReasoning = false;
let preserveThinkingForPayload: boolean | undefined;
let resolvedExtendParams: ModelExtendParams | undefined;
// Process messages through serverMessagesEngine to inject system role, knowledge, etc.
// Rebuild params from agentConfig at execution time (capabilities built dynamically)
@@ -895,39 +736,19 @@ export const createRuntimeExecutors = (
: undefined;
const preserveThinkingRequested = preserveThinkingConfigured === true;
const readExtendParams = (
card: (typeof builtinModels)[number] | undefined,
): string[] | undefined =>
card &&
'settings' in card &&
card.settings &&
typeof card.settings === 'object' &&
'extendParams' in card.settings
? (card.settings as { extendParams?: string[] }).extendParams
: undefined;
const modelCard = builtinModels.find(
(item) =>
item.providerId === provider &&
(item.id === model || item.config?.deploymentName === model),
);
const canonicalModelCard = builtinModels.find(
(item) => item.id === model || item.config?.deploymentName === model,
);
const modelKnowledgeCutoff =
modelCard?.knowledgeCutoff ??
(provider === ModelProvider.LobeHub ? canonicalModelCard?.knowledgeCutoff : undefined);
let modelExtendParams = readExtendParams(modelCard);
// Aggregation providers (e.g. `lobehub`) may serve a model without copying
// its origin `settings.extendParams`. Fall back to the canonical model card
// (matched by id across any provider) so reasoning/thinking params like
// `thinkingLevel` still reach the model. Mirrors the client-side
// `transformToAiModelList` re-namespacing behavior.
if (!modelExtendParams || modelExtendParams.length === 0) {
modelExtendParams = readExtendParams(canonicalModelCard);
}
const modelExtendParams =
modelCard &&
'settings' in modelCard &&
modelCard.settings &&
typeof modelCard.settings === 'object' &&
'extendParams' in modelCard.settings
? (modelCard.settings as { extendParams?: string[] }).extendParams
: undefined;
const modelSupportsPreserveThinkingFromCard =
Array.isArray(modelExtendParams) && modelExtendParams.includes('preserveThinking');
@@ -942,19 +763,6 @@ export const createRuntimeExecutors = (
modelSupportsPreserveThinking && typeof preserveThinkingConfigured === 'boolean'
? preserveThinkingConfigured
: undefined;
// Resolve model extend params (thinkingLevel, reasoning effort, urlContext, …)
// from the agent chat config so the server-side agent runtime forwards the same
// runtime params the client chat service does. Without this, e.g. Gemini 3 Pro's
// `thinkingLevel` never reaches the request and thought summaries come back empty.
if (agentConfig.chatConfig) {
resolvedExtendParams = applyModelExtendParams({
chatConfig: agentConfig.chatConfig,
extendParams: modelExtendParams as ExtendParamsType[] | undefined,
model,
});
}
const messagesForContext = shouldReplayAssistantReasoning
? (llmPayload.messages as UIChatMessage[])
: stripAssistantReasoningForReplay(llmPayload.messages as UIChatMessage[]);
@@ -1252,12 +1060,6 @@ export const createRuntimeExecutors = (
},
userTimezone: ctx.userTimezone,
capabilities: {
isCanUseAudio: (m: string, p: string) => {
const info =
builtinModels.find((item) => item.id === m && item.providerId === p) ??
builtinModels.find((item) => item.id === m);
return info?.abilities?.audio ?? false;
},
isCanUseFC: (m: string, p: string) => {
const info = builtinModels.find((item) => item.id === m && item.providerId === p);
return info?.abilities?.functionCall ?? true;
@@ -1303,7 +1105,6 @@ export const createRuntimeExecutors = (
},
messages: messagesForContext,
model,
modelKnowledgeCutoff,
provider,
systemRole: agentConfig.systemRole ?? undefined,
toolDiscoveryConfig,
@@ -1403,9 +1204,6 @@ export const createRuntimeExecutors = (
model,
stream,
tools,
// ModelExtendParams keeps provider-specific effort/thinking values as loose
// strings (e.g. hy3's 'no_think'); the runtime payload narrows them, so cast.
...(resolvedExtendParams as Partial<ChatStreamPayload>),
...(typeof preserveThinkingForPayload === 'boolean' && {
preserveThinking: preserveThinkingForPayload,
}),
@@ -2664,14 +2462,7 @@ export const createRuntimeExecutors = (
toolExecutionService.executeTool(chatToolPayload, {
activeDeviceId: state.metadata?.activeDeviceId,
agentId: state.metadata?.agentId,
agentMember: buildServerAgentMemberRunner(
ctx,
state,
chatToolPayload,
payload.parentMessageId,
),
documentId: state.metadata?.documentId,
editingAgentId: state.metadata?.editingAgentId,
execSubAgent: ctx.execSubAgent,
executionTimeoutMs: timeoutMs,
groupId: state.metadata?.groupId,
@@ -2704,10 +2495,6 @@ export const createRuntimeExecutors = (
toolResultMaxLength,
topicId: ctx.topicId,
userId: ctx.userId,
// Device-bound cwd folded into deviceSystemInfo at operation
// creation; resume-safe via computeDeviceContext (recovers it
// from the prior tool message's pluginState.metadata).
workingDirectory: state.metadata?.deviceSystemInfo?.workingDirectory,
workspaceId: state.metadata?.workspaceId ?? ctx.workspaceId,
}),
{
@@ -3257,12 +3044,6 @@ export const createRuntimeExecutors = (
toolExecutionService.executeTool(chatToolPayload, {
activeDeviceId: state.metadata?.activeDeviceId,
agentId: state.metadata?.agentId,
agentMember: buildServerAgentMemberRunner(
ctx,
state,
chatToolPayload,
payload.parentMessageId,
),
documentId: state.metadata?.documentId,
execSubAgent: ctx.execSubAgent,
executionTimeoutMs: timeoutMs,
@@ -14,7 +14,6 @@ const mockBuiltinModels = vi.hoisted(() => [
{
abilities: { functionCall: true, video: false, vision: true },
id: 'gpt-4',
knowledgeCutoff: '2024-06',
providerId: 'openai',
},
{
@@ -59,9 +58,6 @@ vi.mock('@/server/services/message', () => ({
// @lobechat/model-runtime resolves to @cloud/business-model-runtime which has
// cloud-specific dependencies that are unavailable in the test environment
vi.mock('@lobechat/model-runtime', () => ({
// The executor resolves extend params via this helper; an empty result keeps
// the runtime payload unchanged, matching this suite's pre-existing behavior.
applyModelExtendParams: vi.fn(() => ({})),
consumeStreamUntilDone: vi.fn().mockResolvedValue(undefined),
// `llmErrorClassification.ts` reads these at module-load time; an empty
// spec map is fine here because this suite never exercises the runtime
@@ -78,9 +74,6 @@ vi.mock('@/business/client/model-bank/loadModels', () => ({
// model-bank is a TypeScript source file that cannot be dynamically imported in vitest
vi.mock('model-bank', () => ({
LOBE_DEFAULT_MODEL_LIST: mockBuiltinModels,
ModelProvider: {
LobeHub: 'lobehub',
},
}));
// composioEnv uses @t3-oss/env-nextjs which throws in jsdom (treats it as client context)
@@ -132,7 +125,6 @@ describe('RuntimeExecutors', () => {
mockMessageModel = {
create: vi.fn().mockResolvedValue({ id: 'msg-123' }),
deleteMessage: vi.fn().mockResolvedValue({ success: true }),
// call_llm does a parent existence preflight; return a truthy row by
// default so existing tests don't have to stub it.
findById: vi.fn().mockResolvedValue({ id: 'msg-existing' }),
@@ -1579,87 +1571,6 @@ describe('RuntimeExecutors', () => {
);
});
it('should pass model knowledge cutoff into serverMessagesEngine', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
const instruction = {
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'openai',
},
type: 'call_llm' as const,
};
await executors.call_llm!(instruction, state);
expect(engineSpy).toHaveBeenCalledWith(
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
);
});
it('should resolve LobeHub routed model knowledge cutoff by model id fallback', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
await executors.call_llm!(
{
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'lobehub',
},
type: 'call_llm' as const,
},
state,
);
expect(engineSpy).toHaveBeenCalledWith(
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
);
});
it('should omit model knowledge cutoff for unknown non-LobeHub providers', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
await executors.call_llm!(
{
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'custom-openai',
},
type: 'call_llm' as const,
},
state,
);
expect(engineSpy.mock.calls[0][0]).toHaveProperty('modelKnowledgeCutoff', undefined);
});
it('should keep current turn when agent historyCount is 0', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
@@ -4939,9 +4850,10 @@ describe('RuntimeExecutors', () => {
...overrides,
});
it('call_tool preserves stop:true for legacy execSubAgent state', async () => {
it('call_tool sets stop:true in tool_result payload when tool returns execSubAgent state', async () => {
// Simulate agentManagement.callAgent returning execSubAgent state
mockToolExecutionService.executeTool.mockResolvedValue({
content: 'Legacy async task result',
content: '🚀 Triggered async task to call agent "target-agent"',
executionTime: 10,
state: {
parentMessageId: 'tool-msg-id',
@@ -4982,112 +4894,13 @@ describe('RuntimeExecutors', () => {
expect((result.nextContext?.payload as any).stop).toBe(true);
});
it('call_tool lets server callAgent run as a deferred tool via the subAgent runner', async () => {
const mockExecVirtualSubAgent = vi
.fn()
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
const ctxWithCallback = {
...ctx,
execVirtualSubAgent: mockExecVirtualSubAgent,
topicId: 'topic-123',
};
mockMessageModel.create.mockResolvedValueOnce({ id: 'tool-msg-id' });
mockToolExecutionService.executeTool.mockImplementation(
async (_payload: any, context: any) => {
const subAgent = await context.subAgent.run({
agentId: 'target-agent-id',
description: 'Call agent target-agent',
instruction: 'Do something useful',
timeout: 1_800_000,
});
return {
content: '',
deferred: true,
executionTime: 10,
state: {
status: 'pending',
subOperationId: subAgent.subOperationId,
targetAgentId: 'target-agent-id',
threadId: subAgent.threadId,
},
success: subAgent.started,
};
},
);
const executors = createRuntimeExecutors(ctxWithCallback);
const state = createMockState();
const instruction = {
payload: {
parentMessageId: 'assistant-msg-id',
toolCalling: {
apiName: 'callAgent',
arguments: JSON.stringify({
agentId: 'target-agent-id',
instruction: 'Do something useful',
runAsTask: true,
}),
id: 'tool-call-1',
identifier: 'lobe-agent-management',
type: 'default' as const,
},
},
type: 'call_tool' as const,
};
const result = await executors.call_tool!(instruction, state);
expect(mockMessageModel.create).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'parent-agent-id',
plugin: expect.objectContaining({
apiName: 'callAgent',
identifier: 'lobe-agent-management',
}),
pluginState: { status: 'pending' },
parentId: 'assistant-msg-id',
role: 'tool',
tool_call_id: 'tool-call-1',
topicId: 'topic-123',
}),
);
expect(mockExecVirtualSubAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'target-agent-id',
instruction: 'Do something useful',
parentMessageId: 'tool-msg-id',
parentOperationId: 'op-123',
title: 'Call agent target-agent',
topicId: 'topic-123',
}),
);
expect(result.newState.status).toBe('waiting_for_async_tool');
expect(result.newState.pendingToolsCalling).toEqual([
expect.objectContaining({
apiName: 'callAgent',
id: 'tool-call-1',
identifier: 'lobe-agent-management',
}),
]);
expect(result.events).toEqual([
expect.objectContaining({
canResume: true,
reason: 'async_tool',
type: 'interrupted',
}),
]);
expect(result.nextContext).toBeUndefined();
});
it('exec_sub_agent executor creates task message and calls execSubAgent callback', async () => {
const mockExecSubAgent = vi
const mockExecSubAgentTask = vi
.fn()
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
const ctxWithCallback = {
...ctx,
execSubAgent: mockExecSubAgent,
execSubAgent: mockExecSubAgentTask,
topicId: 'topic-123',
};
@@ -5113,9 +4926,6 @@ describe('RuntimeExecutors', () => {
expect(mockMessageModel.create).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'parent-agent-id',
metadata: expect.objectContaining({
targetAgentId: 'target-agent-id',
}),
role: 'task',
parentId: 'tool-msg-id',
topicId: 'topic-123',
@@ -5123,7 +4933,7 @@ describe('RuntimeExecutors', () => {
);
// execSubAgent callback fired with targetAgentId
expect(mockExecSubAgent).toHaveBeenCalledWith(
expect(mockExecSubAgentTask).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'target-agent-id',
instruction: 'Do something useful',
@@ -5137,10 +4947,10 @@ describe('RuntimeExecutors', () => {
});
it('exec_sub_agent blocks nested dispatch when current state is already a sub-agent', async () => {
const mockExecSubAgent = vi.fn();
const mockExecSubAgentTask = vi.fn();
const ctxWithCallback = {
...ctx,
execSubAgent: mockExecSubAgent,
execSubAgentTask: mockExecSubAgentTask,
topicId: 'topic-123',
};
@@ -5173,7 +4983,7 @@ describe('RuntimeExecutors', () => {
success: false,
});
expect(mockMessageModel.create).not.toHaveBeenCalled();
expect(mockExecSubAgent).not.toHaveBeenCalled();
expect(mockExecSubAgentTask).not.toHaveBeenCalled();
});
it('exec_sub_agent gracefully skips dispatch when execSubAgent not injected', async () => {
@@ -659,59 +659,6 @@ describe('createServerAgentToolsEngine', () => {
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should disable RemoteDevice when a device is explicitly bound (locked to the selection)', () => {
// A user-selected (bound) device locks the run to that device — the
// activate-device tool is never offered, so the model cannot switch.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
canUseDevice: true,
deviceContext: {
autoActivated: true,
boundDeviceId: 'device-001',
deviceOnline: true,
gatewayConfigured: true,
},
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [RemoteDeviceManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should disable RemoteDevice when the bound device is OFFLINE — no silent hop to another machine', () => {
// The bound device going offline makes the plan device-unrouted, so
// `autoActivated` is false. Without the `boundDeviceId` gate the tool
// would resurface and let the model activate a *different* online device.
// The explicit selection must keep the run locked instead.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
canUseDevice: true,
deviceContext: {
boundDeviceId: 'device-001',
deviceOnline: true,
gatewayConfigured: true,
},
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [RemoteDeviceManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should enable RemoteDevice in bot conversations when caller is trusted (canUseDevice=true)', () => {
// The `!isBotConversation` clause was dropped in — the
// confused-deputy concern that motivated it is now handled at a
@@ -28,11 +28,7 @@ import { ToolsEngine } from '@lobechat/context-engine';
import { type RuntimeEnvMode, type RuntimePlatform } from '@lobechat/types';
import debug from 'debug';
import {
executionTargetToRuntimeMode,
resolveExecutionTarget,
resolveToolMode,
} from '@/helpers/executionTarget';
import { executionTargetToRuntimeMode, resolveExecutionTarget } from '@/helpers/executionTarget';
import {
buildAllowedBuiltinTools,
DEVICE_TOOL_IDENTIFIERS,
@@ -161,7 +157,7 @@ export const createServerAgentToolsEngine = (
const executionTarget =
executionPlan?.target ??
resolveExecutionTarget(agentConfig.agencyConfig, {
clientExecutionAvailable: platform === 'desktop',
isDesktop: platform === 'desktop',
});
const runtimeMode: RuntimeEnvMode = executionTargetToRuntimeMode(executionTarget);
// Device tools (local-system, remote-device proxy) only exist for
@@ -174,7 +170,9 @@ export const createServerAgentToolsEngine = (
const isSearchEnabled = searchMode !== 'off';
// Tool mode: explicit `toolMode` wins; otherwise derive from `enableAgentMode`
// (undefined = agent). `custom` = toolset is exactly the agent's plugins.
const toolMode = resolveToolMode(agentConfig.chatConfig ?? undefined);
const toolMode: 'agent' | 'chat' | 'custom' =
agentConfig.chatConfig?.toolMode ??
(agentConfig.chatConfig?.enableAgentMode === false ? 'chat' : 'agent');
const isChatMode = toolMode === 'chat';
const isCustomMode = toolMode === 'custom';
@@ -233,20 +231,12 @@ export const createServerAgentToolsEngine = (
// Only auto-enable in bot conversations; otherwise let user's plugin selection take effect
...(isBotConversation && { [MessageManifest.identifier]: true }),
// Remote-device proxy: shown only for device-capable targets when the
// server has a proxy, no specific device is auto-activated yet, AND the
// user has NOT explicitly selected a device. Once a device is explicitly
// selected (`boundDeviceId`), the run is locked to it: we never expose the
// activate-device tool, so the model can never switch to another machine —
// not even when the selected device is offline (the run stays unrouted
// until that device comes back, rather than silently hopping elsewhere).
// External bot senders never reach it: the plan degrades denied targets to
// `none` (→ not deviceCapable) and the physical manifest walls drop it for
// `canUseDevice=false` turns.
// server has a proxy but no specific device is auto-activated yet (user
// must pick). External bot senders never reach it: the plan degrades
// denied targets to `none` (→ not deviceCapable) and the physical
// manifest walls drop it for `canUseDevice=false` turns.
[RemoteDeviceManifest.identifier]:
deviceCapable &&
hasDeviceProxy &&
!deviceContext?.autoActivated &&
!deviceContext?.boundDeviceId,
deviceCapable && hasDeviceProxy && !deviceContext?.autoActivated,
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
[WebBrowsingManifest.identifier]: isSearchEnabled,
};
@@ -70,25 +70,6 @@ describe('serverMessagesEngine', () => {
expect(result[0].content).toBe(systemRole + '\n\n' + getCurrentDateContent());
});
it('should inject model knowledge cutoff when provided', async () => {
const messages = createBasicMessages();
const result = await serverMessagesEngine({
messages,
model: 'gpt-4',
modelKnowledgeCutoff: '2024-06',
provider: 'openai',
systemRole: 'You are a helpful assistant',
});
expect(result[0].role).toBe('system');
expect(result[0].content).toBe(
'You are a helpful assistant\n\n' +
getCurrentDateContent() +
'\n\nModel knowledge cutoff: 2024-06',
);
});
it('should handle empty messages', async () => {
const result = await serverMessagesEngine({
messages: [],
@@ -51,7 +51,6 @@ const createServerVariableGenerators = (params: {
export const serverMessagesEngine = async ({
messages = [],
model,
modelKnowledgeCutoff,
provider,
systemRole,
inputTemplate,
@@ -84,7 +83,6 @@ export const serverMessagesEngine = async ({
const engine = new MessagesEngine({
// Capability injection
capabilities: {
isCanUseAudio: capabilities?.isCanUseAudio,
isCanUseFC: capabilities?.isCanUseFC,
isCanUseVideo: capabilities?.isCanUseVideo,
isCanUseVision: capabilities?.isCanUseVision,
@@ -122,7 +120,6 @@ export const serverMessagesEngine = async ({
// Model info
model,
modelKnowledgeCutoff,
provider,
systemRole,
@@ -23,8 +23,6 @@ import type { RuntimeInitialContext, UIChatMessage } from '@lobechat/types';
* Model capability checker functions for server-side
*/
export interface ServerModelCapabilities {
/** Check if audio input is supported */
isCanUseAudio?: (model: string, provider: string) => boolean;
/** Check if function calling is supported */
isCanUseFC?: (model: string, provider: string) => boolean;
/** Check if video is supported */
@@ -132,8 +130,6 @@ export interface ServerMessagesEngineParams {
/** Model ID */
model: string;
/** Model knowledge cutoff date, e.g. `2024-06`. Omit when unknown. */
modelKnowledgeCutoff?: string;
/** Page content context (optional, for document editing) */
pageContentContext?: PageContentContext;
@@ -393,7 +393,6 @@ describe('agentRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentRouter.createCaller(wsCtx());
@@ -411,7 +410,6 @@ describe('agentRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentRouter.createCaller(wsCtx());
@@ -1,146 +0,0 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type * as AgentDocumentModels from '@/database/models/agentDocuments';
import { createCallerFactory } from '@/libs/trpc/lambda';
import { createContextInner } from '@/libs/trpc/lambda/context';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import { agentDocumentRouter } from '../agentDocument';
const mocks = vi.hoisted(() => ({
associate: vi.fn(),
createTopic: vi.fn(),
findByAgentAndDocumentTrigger: vi.fn(),
findRowByDocumentId: vi.fn(),
getServerDB: vi.fn(),
}));
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: mocks.getServerDB,
}));
vi.mock('@/database/models/agentDocuments', async (importOriginal) => {
const actual = await importOriginal<typeof AgentDocumentModels>();
return {
...actual,
AgentDocumentModel: vi.fn(),
};
});
vi.mock('@/database/models/topic', () => ({
TopicModel: vi.fn().mockImplementation(() => ({
create: mocks.createTopic,
findByAgentAndDocumentTrigger: mocks.findByAgentAndDocumentTrigger,
})),
}));
vi.mock('@/database/models/topicDocument', () => ({
TopicDocumentModel: vi.fn().mockImplementation(() => ({
associate: mocks.associate,
})),
}));
vi.mock('@/server/services/agentDocuments', () => ({
AgentDocumentsService: vi.fn(),
}));
vi.mock('@/server/services/agentDocumentVfs', () => ({
AgentDocumentVfsService: vi.fn(),
}));
vi.mock('@/server/services/agentDocuments/toolOutcome', () => ({
emitAgentDocumentToolOutcomeSafely: vi.fn(),
}));
const createCaller = createCallerFactory(agentDocumentRouter);
describe('agentDocumentRouter.getOrCreateChatTopic', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getServerDB.mockResolvedValue({ kind: 'server-db' });
vi.mocked(AgentDocumentsService).mockImplementation(
() =>
({ findRowByDocumentId: mocks.findRowByDocumentId }) as unknown as AgentDocumentsService,
);
});
it('returns the existing topic when a doc-anchored row is already linked', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue({ id: 'topic-existing' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
const result = await caller.getOrCreateChatTopic({
agentId: 'agent-1',
documentId: 'docs_abc',
});
expect(result).toEqual({ topicId: 'topic-existing' });
expect(mocks.findByAgentAndDocumentTrigger).toHaveBeenCalledWith({
agentId: 'agent-1',
documentId: 'docs_abc',
trigger: 'document',
});
expect(mocks.createTopic).not.toHaveBeenCalled();
expect(mocks.associate).not.toHaveBeenCalled();
});
it('creates a new doc-anchored topic and associates it when none exists', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue({
filename: 'spec.md',
id: 'agent-document-1',
title: 'Spec',
});
mocks.createTopic.mockResolvedValue({ id: 'topic-new' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
const result = await caller.getOrCreateChatTopic({
agentId: 'agent-1',
documentId: 'docs_abc',
});
expect(result).toEqual({ topicId: 'topic-new' });
expect(mocks.createTopic).toHaveBeenCalledWith({
agentId: 'agent-1',
title: 'Spec',
trigger: 'document',
});
expect(mocks.associate).toHaveBeenCalledWith({
documentId: 'docs_abc',
topicId: 'topic-new',
});
});
it('falls back to the filename when the document has no title', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue({
filename: 'fallback.md',
id: 'agent-document-1',
title: undefined,
});
mocks.createTopic.mockResolvedValue({ id: 'topic-new' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
await caller.getOrCreateChatTopic({ agentId: 'agent-1', documentId: 'docs_abc' });
expect(mocks.createTopic).toHaveBeenCalledWith({
agentId: 'agent-1',
title: 'fallback.md',
trigger: 'document',
});
});
it('throws NOT_FOUND when the document is missing or not owned by the agent', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue(undefined);
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
await expect(
caller.getOrCreateChatTopic({ agentId: 'agent-1', documentId: 'docs_missing' }),
).rejects.toThrow(/Document not found/);
expect(mocks.createTopic).not.toHaveBeenCalled();
expect(mocks.associate).not.toHaveBeenCalled();
});
});
@@ -500,7 +500,6 @@ describe('agentGroupRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentGroupRouter.createCaller(wsCtx());
@@ -518,7 +517,6 @@ describe('agentGroupRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentGroupRouter.createCaller(wsCtx());
@@ -29,12 +29,10 @@ describe('aiModelRouter', () => {
it('should create ai model', async () => {
const mockCreate = vi.fn().mockResolvedValue({ id: 'model-1' });
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
@@ -46,68 +44,12 @@ describe('aiModelRouter', () => {
});
expect(result).toBe('model-1');
expect(mockFindByIdAndProvider).toHaveBeenCalledWith('test-model', 'test-provider');
expect(mockCreate).toHaveBeenCalledWith({
id: 'test-model',
providerId: 'test-provider',
});
});
it('should reject duplicate ai model before creating', async () => {
const mockCreate = vi.fn();
const mockFindByIdAndProvider = vi.fn().mockResolvedValue({ id: 'test-model' });
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
const caller = aiModelRouter.createCaller(mockCtx);
await expect(
caller.createAiModel({
id: 'test-model',
providerId: 'test-provider',
}),
).rejects.toMatchObject({
code: 'CONFLICT',
message: 'Model "test-model" already exists',
});
expect(mockCreate).not.toHaveBeenCalled();
});
it('should convert duplicate insert races to conflict errors', async () => {
const duplicateError = Object.assign(new Error('failed query'), {
cause: Object.assign(new Error('duplicate key'), {
code: '23505',
constraint: 'ai_models_id_provider_id_user_id_pk',
}),
});
const mockCreate = vi.fn().mockRejectedValue(duplicateError);
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
const caller = aiModelRouter.createCaller(mockCtx);
await expect(
caller.createAiModel({
id: 'test-model',
providerId: 'test-provider',
}),
).rejects.toMatchObject({
code: 'CONFLICT',
message: 'Model "test-model" already exists',
});
});
it('should get ai model by id', async () => {
const mockModel = {
id: 'model-1',
@@ -1,118 +0,0 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { asrRouter } from '../asr';
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: vi.fn(() => ({})),
}));
const transcribeMock = vi.fn();
vi.mock('@/server/modules/ModelRuntime', () => ({
initModelRuntimeFromDB: vi.fn(async () => ({ transcribe: transcribeMock })),
}));
const findByIdMock = vi.fn();
vi.mock('@/database/models/file', () => ({
FileModel: vi.fn(() => ({ findById: findByIdMock })),
}));
const getFileByteArrayMock = vi.fn();
vi.mock('@/server/services/file', () => ({
FileService: vi.fn(() => ({ getFileByteArray: getFileByteArrayMock })),
}));
const caller = asrRouter.createCaller({ jwtPayload: { userId: 'u1' }, userId: 'u1' } as any);
beforeEach(() => {
transcribeMock.mockResolvedValue({ text: 'hello world' });
});
afterEach(() => {
vi.clearAllMocks();
});
describe('asrRouter.transcribe', () => {
it('transcribes inline base64 audio', async () => {
const res = await caller.transcribe({
audioBase64: Buffer.from('audio-bytes').toString('base64'),
fileName: 'clip.mp3',
model: 'whisper-1',
provider: 'openai',
});
expect(res).toEqual({ text: 'hello world' });
expect(findByIdMock).not.toHaveBeenCalled();
const payload = transcribeMock.mock.calls[0][0];
expect(payload.file).toBeInstanceOf(File);
expect(payload.fileName).toBe('clip.mp3');
expect(await payload.file.text()).toBe('audio-bytes');
});
it('resolves a fileId by downloading the bytes from storage', async () => {
findByIdMock.mockResolvedValue({
fileType: 'audio/mp4',
name: 'meeting.m4a',
url: 's3-key/meeting.m4a',
});
getFileByteArrayMock.mockResolvedValue(new Uint8Array(Buffer.from('from-s3')));
const res = await caller.transcribe({ fileId: 'file_123', model: 'whisper-1' });
expect(res).toEqual({ text: 'hello world' });
expect(findByIdMock).toHaveBeenCalledWith('file_123');
expect(getFileByteArrayMock).toHaveBeenCalledWith('s3-key/meeting.m4a');
const payload = transcribeMock.mock.calls[0][0];
expect(payload.fileName).toBe('meeting.m4a');
expect(payload.file.type).toBe('audio/mp4');
expect(await payload.file.text()).toBe('from-s3');
});
it('rejects when neither fileId nor audioBase64 is provided', async () => {
await expect(caller.transcribe({ model: 'whisper-1' } as any)).rejects.toThrow();
});
it('rejects oversized inline base64 and guides to fileId', async () => {
// > 3MB decoded → base64 string exceeds the cap
const tooBig = 'A'.repeat(5 * 1024 * 1024);
await expect(caller.transcribe({ audioBase64: tooBig, model: 'whisper-1' })).rejects.toThrow(
/fileId/i,
);
expect(transcribeMock).not.toHaveBeenCalled();
});
it('rejects when both fileId and audioBase64 are provided', async () => {
await expect(
caller.transcribe({
audioBase64: Buffer.from('x').toString('base64'),
fileId: 'file_123',
model: 'whisper-1',
} as any),
).rejects.toThrow();
});
it('throws NOT_FOUND when the fileId does not exist', async () => {
findByIdMock.mockResolvedValue(undefined);
await expect(caller.transcribe({ fileId: 'missing', model: 'whisper-1' })).rejects.toThrow(
/not found/i,
);
expect(getFileByteArrayMock).not.toHaveBeenCalled();
});
it('throws NOT_FOUND when the stored object is gone (NoSuchKey)', async () => {
findByIdMock.mockResolvedValue({
fileType: 'audio/mp4',
name: 'gone.m4a',
url: 's3-key/gone.m4a',
});
getFileByteArrayMock.mockRejectedValue({ Code: 'NoSuchKey' });
await expect(caller.transcribe({ fileId: 'file_x', model: 'whisper-1' })).rejects.toThrow(
/no longer available/i,
);
});
});
@@ -1,70 +0,0 @@
import { TRPCError } from '@trpc/server';
import { describe, expect, it, vi } from 'vitest';
import type { DeviceModel } from '@/database/models/device';
import { assertWorkspaceRootApproved } from '../deviceWorkspaceGuard';
const mockModel = (row: { defaultCwd?: string | null; workingDirs?: { path: string }[] } | null) =>
({
findByDeviceId: vi.fn().mockResolvedValue(row),
}) as unknown as DeviceModel;
describe('assertWorkspaceRootApproved', () => {
it('allows a root that exactly matches a bound workingDir', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).resolves.toBeUndefined();
});
it('allows a root nested inside a bound workingDir', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj/packages/app'),
).resolves.toBeUndefined();
});
it('allows a root matching defaultCwd when no workingDirs match', async () => {
const model = mockModel({ defaultCwd: '/Users/me/default', workingDirs: [] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/default'),
).resolves.toBeUndefined();
});
it('rejects a root that escapes the approved roots (filesystem root)', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(assertWorkspaceRootApproved(model, 'dev-1', '/')).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
it('rejects a sibling directory that shares a path prefix but is not contained', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj-evil'),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('rejects when the device has no approved roots at all', async () => {
const model = mockModel({ workingDirs: [] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('rejects when the device row is missing', async () => {
const model = mockModel(null);
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).rejects.toBeInstanceOf(TRPCError);
});
it('rejects an empty workspace root with BAD_REQUEST before hitting the DB', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(assertWorkspaceRootApproved(model, 'dev-1', '')).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(model.findByDeviceId).not.toHaveBeenCalled();
});
});
@@ -1,289 +0,0 @@
// @vitest-environment node
/**
* Integration test for the server `lobe-agent-management.callAgent` deferred
* execution flow.
*
* Verifies the full lifecycle end-to-end on the in-memory runtime:
* 1. Parent op LLM emits a `lobe-agent-management____callAgent` tool call.
* 2. The real server executor parks the parent, creates a pending tool
* placeholder, and forks the target agent as a child op.
* 3. The child op completes.
* 4. The completion bridge backfills the placeholder and resumes the parent.
* 5. The parent reaches `done`.
*/
import { type LobeChatDatabase } from '@lobechat/database';
import { agentOperations, agents, messagePlugins, messages } from '@lobechat/database/schemas';
import { getTestDB } from '@lobechat/database/test-utils';
import { and, eq } from 'drizzle-orm';
import OpenAI from 'openai';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { inMemoryAgentStateManager } from '@/server/modules/AgentRuntime/InMemoryAgentStateManager';
import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager';
import { aiAgentRouter } from '../../../aiAgent';
import { cleanupTestUser, createTestUser } from '../setup';
import { createMockResponsesStream, waitForOperationComplete } from './helpers';
process.env.OPENAI_API_KEY = 'sk-test-fake-api-key-for-testing';
let testDB: LobeChatDatabase;
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: vi.fn(() => testDB),
}));
vi.mock('@/server/services/file', () => ({
FileService: vi.fn().mockImplementation(() => ({
getFullFileUrl: vi.fn().mockImplementation((path: string) => (path ? `/files${path}` : null)),
})),
}));
let mockResponsesCreate: any;
let serverDB: LobeChatDatabase;
let userId: string;
let parentAgentId: string;
let targetAgentId: string;
const TARGET_ANSWER = 'The target agent completed the delegated callAgent work.';
const PARENT_FINAL = 'I received the target agent result and the delegated work is complete.';
const createTestContext = () => ({ jwtPayload: { userId }, userId });
const createCallAgentResponse = () => {
const responseId = `resp_call_agent_${Date.now()}`;
const msgItemId = `msg_call_agent_${Date.now()}`;
const callId = 'call_agent_1';
const fnCall = {
arguments: JSON.stringify({
agentId: targetAgentId,
instruction: 'Handle the delegated backend integration task.',
runAsTask: true,
taskTitle: 'Delegated backend integration task',
timeout: 30_000,
}),
call_id: callId,
name: 'lobe-agent-management____callAgent',
type: 'function_call',
};
return createMockResponsesStream([
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [],
status: 'in_progress',
},
type: 'response.created',
},
{
item: {
content: [],
id: msgItemId,
role: 'assistant',
status: 'in_progress',
type: 'message',
},
output_index: 0,
type: 'response.output_item.added',
},
{
content_index: 0,
delta: 'I will delegate this to the target agent.',
item_id: msgItemId,
output_index: 0,
type: 'response.output_text.delta',
},
{ item: fnCall, output_index: 1, type: 'response.output_item.added' },
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [
{
content: [{ text: 'I will delegate this to the target agent.', type: 'output_text' }],
id: msgItemId,
role: 'assistant',
status: 'completed',
type: 'message',
},
fnCall,
],
status: 'completed',
usage: { input_tokens: 30, output_tokens: 20, total_tokens: 50 },
},
type: 'response.completed',
},
]);
};
const createFinalTextResponse = (content: string) => {
const responseId = `resp_final_${Date.now()}_${content.length}`;
const msgItemId = `msg_final_${Date.now()}_${content.length}`;
return createMockResponsesStream([
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [],
status: 'in_progress',
},
type: 'response.created',
},
{
content_index: 0,
delta: content,
item_id: msgItemId,
output_index: 0,
type: 'response.output_text.delta',
},
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [
{
content: [{ text: content, type: 'output_text' }],
id: msgItemId,
role: 'assistant',
status: 'completed',
type: 'message',
},
],
status: 'completed',
usage: { input_tokens: 40, output_tokens: 20, total_tokens: 60 },
},
type: 'response.completed',
},
]);
};
beforeEach(async () => {
serverDB = await getTestDB();
testDB = serverDB;
userId = await createTestUser(serverDB);
const insertedAgents = await serverDB
.insert(agents)
.values([
{
chatConfig: {},
model: 'gpt-5-pro',
plugins: ['lobe-agent-management'],
provider: 'openai',
systemRole: 'You are a supervisor that delegates work to other agents.',
title: 'callAgent Supervisor',
userId,
},
{
chatConfig: {},
model: 'gpt-5-pro',
plugins: [],
provider: 'openai',
systemRole: 'You are the target agent. Return a concise result.',
title: 'callAgent Target',
userId,
},
])
.returning();
parentAgentId = insertedAgents[0].id;
targetAgentId = insertedAgents[1].id;
// `create` is overloaded (streaming / non-streaming); its precise spy type
// isn't assignable to the generic MockInstance fallback, so widen via unknown.
mockResponsesCreate = vi.spyOn(
OpenAI.Responses.prototype,
'create',
) as unknown as typeof mockResponsesCreate;
});
afterEach(async () => {
await cleanupTestUser(serverDB, userId);
vi.clearAllMocks();
vi.restoreAllMocks();
inMemoryAgentStateManager.clear();
inMemoryStreamEventManager.clear();
});
describe('Server callAgent deferred execution', () => {
it('parks the parent, runs the target agent, backfills the tool message and resumes', async () => {
let callCount = 0;
mockResponsesCreate.mockImplementation(() => {
callCount++;
if (callCount === 1) return Promise.resolve(createCallAgentResponse() as any);
if (callCount === 2) return Promise.resolve(createFinalTextResponse(TARGET_ANSWER) as any);
return Promise.resolve(createFinalTextResponse(PARENT_FINAL) as any);
});
const caller = aiAgentRouter.createCaller(createTestContext());
const createResult = await caller.execAgent({
agentId: parentAgentId,
prompt: 'Delegate this work to the target agent and report back.',
userInterventionConfig: { approvalMode: 'headless' },
});
expect(createResult.success).toBe(true);
const finalState = await waitForOperationComplete(
inMemoryAgentStateManager,
createResult.operationId,
{ maxWaitTime: 20_000 },
);
expect(finalState.status).toBe('done');
expect(finalState.pendingToolsCalling ?? []).toHaveLength(0);
expect(mockResponsesCreate).toHaveBeenCalledTimes(3);
const childOps = await serverDB
.select()
.from(agentOperations)
.where(eq(agentOperations.parentOperationId, createResult.operationId));
expect(childOps).toHaveLength(1);
expect(childOps[0]).toMatchObject({
agentId: targetAgentId,
status: 'done',
});
const toolMessages = await serverDB
.select({
content: messages.content,
role: messages.role,
state: messagePlugins.state,
identifier: messagePlugins.identifier,
apiName: messagePlugins.apiName,
toolCallId: messagePlugins.toolCallId,
})
.from(messages)
.innerJoin(messagePlugins, eq(messagePlugins.id, messages.id))
.where(
and(
eq(messages.userId, userId),
eq(messagePlugins.identifier, 'lobe-agent-management'),
eq(messagePlugins.apiName, 'callAgent'),
),
);
expect(toolMessages).toHaveLength(1);
expect(toolMessages[0]).toMatchObject({
apiName: 'callAgent',
content: TARGET_ANSWER,
identifier: 'lobe-agent-management',
role: 'tool',
toolCallId: 'call_agent_1',
});
expect(toolMessages[0].state).toMatchObject({
status: 'completed',
threadId: childOps[0].threadId,
});
}, 30_000);
});
@@ -12,7 +12,6 @@ const mockFindById = vi.fn();
const mockCountTopicsForMemoryExtractor = vi.fn();
const mockDeleteAll = vi.fn();
const mockDeletePersona = vi.fn();
const { mockTriggerProcessUsers } = vi.hoisted(() => ({
mockTriggerProcessUsers: vi.fn(),
}));
@@ -44,12 +43,6 @@ vi.mock('@/database/models/userMemory', () => ({
UserMemoryPreferenceModel: vi.fn(() => ({})),
}));
vi.mock('@/database/models/userMemory/persona', () => ({
UserPersonaModel: vi.fn(() => ({
deletePersona: mockDeletePersona,
})),
}));
vi.mock('@/envs/app', () => ({
appEnv: {
APP_URL: 'https://example.com',
@@ -308,13 +301,11 @@ describe('userMemoryRouter.deleteAll', () => {
it('purges all user memories through the aggregate model', async () => {
mockDeleteAll.mockResolvedValue(undefined);
mockDeletePersona.mockResolvedValue(undefined);
const caller = createCaller();
const result = await caller.deleteAll();
expect(mockDeleteAll).toHaveBeenCalledOnce();
expect(mockDeletePersona).toHaveBeenCalledOnce();
expect(result).toEqual({ success: true });
});
});
@@ -41,7 +41,6 @@ export const updateDocumentInputSchema = z.object({
editorData: z.string().optional(),
fileType: z.string().optional(),
id: z.string(),
lockOwnerId: z.string().optional(),
metadata: z.record(z.any()).optional(),
parentId: z.string().nullable().optional(),
restoreFromHistoryId: z.string().optional(),
@@ -52,7 +51,6 @@ export const updateDocumentInputSchema = z.object({
export const saveDocumentHistoryInputSchema = z.object({
documentId: z.string(),
editorData: z.string(),
lockOwnerId: z.string().optional(),
saveSource: documentHistorySaveSourceSchema,
});
@@ -100,8 +98,6 @@ export interface UpdateDocumentOutput {
export interface SaveDocumentHistoryInput {
documentId: string;
editorData: string;
/** Edit-session id proving the client still holds the workspace page lease. */
lockOwnerId?: string;
saveSource: DocumentHistorySaveSource;
}
@@ -134,7 +130,6 @@ export interface UpdateDocumentInput {
editorData?: string;
fileType?: string;
id: string;
lockOwnerId?: string;
metadata?: Record<string, any>;
parentId?: string | null;
restoreFromHistoryId?: string;
@@ -8,7 +8,6 @@ import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { TopicTrigger } from '@/const/topic';
import { AgentDocumentModel } from '@/database/models/agentDocuments';
import { TopicModel } from '@/database/models/topic';
import { TopicDocumentModel } from '@/database/models/topicDocument';
@@ -255,56 +254,6 @@ export const agentDocumentRouter = router({
return ctx.agentDocumentService.getDocument(input.agentId, input.filename);
}),
/**
* Return the chat topic that anchors the doc-scoped conversation for this
* `(documentId, agentId)` pair, creating it idempotently on the first call.
*
* Topics are marked with `trigger='document'` so they stay out of the main
* sidebar history (`MAIN_SIDEBAR_EXCLUDE_TRIGGERS` already excludes them).
* The mapping is persisted through `topic_documents`, so subsequent calls
* resolve the same topic id.
*/
getOrCreateChatTopic: agentDocumentProcedure
.input(
z.object({
agentId: z.string(),
documentId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.topicModel.findByAgentAndDocumentTrigger({
agentId: input.agentId,
documentId: input.documentId,
trigger: TopicTrigger.Document,
});
if (existing) return { topicId: existing.id };
const document = await ctx.agentDocumentService.findRowByDocumentId(
input.agentId,
input.documentId,
);
if (!document) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Document not found for agentId=${input.agentId}`,
});
}
const title = document.title || document.filename || 'Document chat';
const topic = await ctx.topicModel.create({
agentId: input.agentId,
title,
trigger: TopicTrigger.Document,
});
await ctx.topicDocumentModel.associate({
documentId: input.documentId,
topicId: topic.id,
});
return { topicId: topic.id };
}),
/**
* Create or update a document
*/
@@ -423,16 +372,12 @@ export const agentDocumentRouter = router({
.input(
z.object({
agentId: z.string(),
// Reveal the auto-created `.tool-results` archive. Off by default so
// user-facing lists stay clean; the agent document-listing tool opts in.
includeArchivedToolResults: z.boolean().optional().default(false),
scope: z.enum(['agent', 'currentTopic']).optional().default('agent'),
sourceType: z.enum(['all', 'file', 'web']).optional().default('all'),
topicId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { includeArchivedToolResults } = input;
if (input.scope === 'currentTopic') {
if (!input.topicId) throw new Error('topicId is required to list current topic documents');
@@ -440,13 +385,10 @@ export const agentDocumentRouter = router({
input.agentId,
input.topicId,
input.sourceType,
{ includeArchivedToolResults },
);
}
return ctx.agentDocumentService.listDocuments(input.agentId, input.sourceType, {
includeArchivedToolResults,
});
return ctx.agentDocumentService.listDocuments(input.agentId, input.sourceType);
}),
/**
@@ -139,8 +139,6 @@ const ExecAgentSchema = z
.object({
defaultTaskAssigneeAgentId: z.string().optional(),
documentId: z.string().optional().nullable(),
/** The agent being edited when scope is 'agent_builder' (not the builder builtin itself). */
editingAgentId: z.string().optional(),
groupId: z.string().optional().nullable(),
initialTopicMetadata: z
.object({
+2 -36
View File
@@ -1,4 +1,3 @@
import { TRPCError } from '@trpc/server';
import { type AiProviderModelListItem } from 'model-bank';
import {
AiModelTypeSchema,
@@ -19,30 +18,6 @@ import { getServerGlobalConfig } from '@/server/globalConfig';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { type ProviderConfig } from '@/types/user/settings';
const AI_MODEL_UNIQUE_CONSTRAINT = 'ai_models_id_provider_id_user_id_pk';
const getPostgresErrorField = (error: unknown, field: 'code' | 'constraint') => {
let current = error;
while (current && typeof current === 'object') {
const value = (current as Record<string, unknown>)[field];
if (typeof value === 'string') return value;
current = (current as { cause?: unknown }).cause;
}
};
const isDuplicateAiModelError = (error: unknown) =>
getPostgresErrorField(error, 'code') === '23505' &&
getPostgresErrorField(error, 'constraint') === AI_MODEL_UNIQUE_CONSTRAINT;
const throwDuplicateAiModelError = (id: string): never => {
throw new TRPCError({
code: 'CONFLICT',
message: `Model "${id}" already exists`,
});
};
const aiModelProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
const wsId = ctx.workspaceId ?? undefined;
@@ -107,18 +82,9 @@ export const aiModelRouter = router({
.use(withScopedPermission('ai_model:create'))
.input(CreateAiModelSchema)
.mutation(async ({ input, ctx }) => {
const existingModel = await ctx.aiModelModel.findByIdAndProvider(input.id, input.providerId);
if (existingModel) throwDuplicateAiModelError(input.id);
const data = await ctx.aiModelModel.create(input);
try {
const data = await ctx.aiModelModel.create(input);
return data?.id;
} catch (error) {
if (isDuplicateAiModelError(error)) throwDuplicateAiModelError(input.id);
throw error;
}
return data?.id;
}),
getAiModelById: aiModelProcedure
-161
View File
@@ -1,161 +0,0 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { FileModel } from '@/database/models/file';
import type { LobeChatDatabase } from '@/database/type';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { FileService } from '@/server/services/file';
const asrProcedure = wsCompatProcedure.use(serverDatabase);
// Inline base64 is only for short clips. The whole request must fit inside the
// platform body limit (≈4.5MB on serverless deploys) and base64 inflates bytes
// by ~4/3, so cap the decoded audio well under that — anything larger should be
// uploaded and passed as `fileId`.
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
// base64 length ≈ ceil(bytes / 3) * 4; validating the string length lets us
// reject oversized payloads before allocating/decoding them.
const MAX_INLINE_AUDIO_BASE64_CHARS = Math.ceil(MAX_INLINE_AUDIO_BYTES / 3) * 4;
interface ResolvedAudio {
bytes: Uint8Array;
fileName: string;
mimeType?: string;
}
export const asrRouter = router({
/**
* Automatic Speech Recognition (speech-to-text).
*
* Accepts the audio either as an already-uploaded `fileId` (preferred the
* server streams the bytes from storage, nothing large travels over tRPC) or
* inline as base64 for short clips (capped at `MAX_INLINE_AUDIO_BYTES`;
* larger payloads are rejected with guidance to upload and pass `fileId`).
*
* Note on base64: tRPC here uses an `httpLink` + superjson (JSON only), which
* has no binary representation for a `Buffer`/`Uint8Array` a raw buffer would
* serialize to a per-byte JSON object, far worse than base64. So inline bytes
* stay base64; use `fileId` to avoid inlining entirely.
*
* Transcription is a single request/response (not streamed), so a mutation is
* the right shape.
*/
transcribe: asrProcedure
.input(
z
.object({
/** Base64-encoded audio bytes (short clips only). Mutually exclusive with `fileId`. */
audioBase64: z
.string()
.min(1)
.max(MAX_INLINE_AUDIO_BASE64_CHARS, {
message: `Inline audio is limited to ${MAX_INLINE_AUDIO_BYTES / 1024 / 1024}MB. Upload the file and pass \`fileId\` instead.`,
})
.optional(),
/** Already-uploaded audio file id. Mutually exclusive with `audioBase64`. */
fileId: z.string().min(1).optional(),
/** Original file name (base64 path); its extension helps format detection. */
fileName: z.string().optional(),
/** ISO-639-1 language code (e.g. `en`, `zh`). */
language: z.string().optional(),
/** Audio mime type (base64 path, e.g. `audio/mp4`). */
mimeType: z.string().optional(),
model: z.string().min(1),
/** Optional text to guide the model's style. */
prompt: z.string().optional(),
provider: z.string().default('openai'),
responseFormat: z.enum(['json', 'srt', 'text', 'verbose_json', 'vtt']).optional(),
})
.refine((d) => Boolean(d.fileId) !== Boolean(d.audioBase64), {
message: 'Provide exactly one of `fileId` or `audioBase64`.',
}),
)
.mutation(async ({ ctx, input }): Promise<{ text: string }> => {
const workspaceId = ctx.workspaceId ?? undefined;
const { bytes, fileName, mimeType } = await resolveAudio(ctx, input, workspaceId);
// Resolve the user's provider config (key + baseURL) from the database,
// falling back to server env keys, exactly like chat/embeddings do.
const runtime = await initModelRuntimeFromDB(
ctx.serverDB,
ctx.userId,
input.provider,
workspaceId,
);
// `Uint8Array` is a valid BlobPart at runtime; the cast sidesteps the
// `Uint8Array<ArrayBufferLike>` vs BlobPart generic mismatch in lib.dom.
const file = new File([bytes as BlobPart], fileName, {
type: mimeType || 'application/octet-stream',
});
const result = await runtime.transcribe(
{
file,
fileName,
language: input.language,
model: input.model,
prompt: input.prompt,
responseFormat: input.responseFormat,
},
{ user: ctx.userId },
);
if (!result) {
throw new TRPCError({
code: 'NOT_IMPLEMENTED',
message: `Provider "${input.provider}" does not support ASR.`,
});
}
return result;
}),
});
/**
* Turn the request into raw audio bytes + metadata, from either a stored file
* (downloaded from S3, ownership enforced by the userId-scoped FileModel) or the
* inline base64 payload.
*/
async function resolveAudio(
ctx: { serverDB: LobeChatDatabase; userId: string },
input: { audioBase64?: string; fileId?: string; fileName?: string; mimeType?: string },
workspaceId?: string,
): Promise<ResolvedAudio> {
if (input.fileId) {
const fileModel = new FileModel(ctx.serverDB, ctx.userId, workspaceId);
const fileItem = await fileModel.findById(input.fileId);
if (!fileItem) {
throw new TRPCError({ code: 'NOT_FOUND', message: `File "${input.fileId}" not found.` });
}
const fileService = new FileService(ctx.serverDB, ctx.userId, workspaceId);
let bytes: Uint8Array;
try {
bytes = await fileService.getFileByteArray(fileItem.url);
} catch (error) {
if ((error as { Code?: string }).Code === 'NoSuchKey') {
throw new TRPCError({
code: 'NOT_FOUND',
message: `File "${input.fileId}" is no longer available in storage.`,
});
}
throw error;
}
return { bytes, fileName: fileItem.name, mimeType: fileItem.fileType };
}
return {
bytes: new Uint8Array(Buffer.from(input.audioBase64!, 'base64')),
fileName: input.fileName || 'audio',
mimeType: input.mimeType,
};
}
export type AsrRouter = typeof asrRouter;
@@ -115,33 +115,6 @@ export const connectorRouter = router({
return toolsByConnector;
}),
/**
* Return the connector record with decrypted user-set credentials so the
* edit form can pre-fill accurately. Only the connector owner can call this
* (enforced by connectorProcedure ownership check).
*
* Machine-managed secrets are intentionally excluded:
* - OAuth access/refresh tokens (type 'oauth2') stripped, returned as null
* - oidcConfig.clientSecret (DCR-registered secret) stripped
* User-set credentials (bearer token, custom headers) are returned as-is so
* the edit form can display them.
*/
getForEdit: connectorProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const connector = await ctx.connectorModel.findById(input.id);
if (!connector)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Connector not found' });
const { oidcConfig, credentials, ...rest } = connector;
const safeOidcConfig = oidcConfig ? { ...oidcConfig, clientSecret: undefined } : oidcConfig;
// OAuth tokens are machine-managed — don't return them; the UI only needs
// to know an OAuth flow is configured (reflected via oidcConfig presence).
const safeCredentials = credentials?.type === 'oauth2' ? null : credentials;
return { ...rest, credentials: safeCredentials, oidcConfig: safeOidcConfig };
}),
/**
* The exact redirect URI the server will send to the OAuth/DCR endpoints.
* The Add modal must display THIS value (not a client-derived origin) so the
+7 -100
View File
@@ -8,7 +8,6 @@ import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { deviceGateway } from '@/server/services/deviceGateway';
import { preserveWorkspaceCache } from './deviceWorkingDirs';
import { assertWorkspaceRootApproved } from './deviceWorkspaceGuard';
// Derive the zod enum from the canonical config so new platforms are
// automatically covered without touching this file.
@@ -30,23 +29,6 @@ const deviceProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
});
});
const workspaceFileInput = z.object({
deviceId: z.string(),
workingDirectory: z.string(),
});
/**
* `deviceProcedure` that additionally requires `workingDirectory` to be an
* approved workspace root for the device. Builds the guard into the procedure
* so every file-mutating route inherits it and can never forget the check
* see {@link assertWorkspaceRootApproved} for why the check is necessary.
*/
const workspaceFileProcedure = deviceProcedure.input(workspaceFileInput).use(async (opts) => {
const { deviceId, workingDirectory } = workspaceFileInput.parse(await opts.getRawInput());
await assertWorkspaceRootApproved(opts.ctx.deviceModel, deviceId, workingDirectory);
return opts.next();
});
export const deviceRouter = router({
/**
* Probe whether a specific agent platform (openclaw / hermes) is available
@@ -137,22 +119,6 @@ export const deviceRouter = router({
return result ?? null;
}),
/**
* List the git worktrees attached to the same repository as a directory on a
* remote device, via the device's `listGitWorktrees` RPC. Lets the web/remote
* worktree picker mirror the local desktop's, populated over IPC.
*/
listGitWorktrees: deviceProcedure
.input(z.object({ deviceId: z.string(), path: z.string() }))
.query(async ({ ctx, input }) => {
const result = await deviceGateway.listGitWorktrees({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
});
return result ?? [];
}),
/**
* List the local branches of a directory on a remote device, via the device's
* `listGitBranches` RPC. Lets the web/remote branch switcher populate the same
@@ -352,22 +318,24 @@ export const deviceRouter = router({
* Read-only local file preview for a file on a remote device. The web client
* receives render data, not a `localfile://` URL; saving remains unsupported.
*/
getLocalFilePreview: workspaceFileProcedure
getLocalFilePreview: deviceProcedure
.input(
z.object({
accept: z.enum(['image']).optional(),
deviceId: z.string(),
path: z.string(),
workingDirectory: z.string(),
}),
)
.query(async ({ ctx, input }) => {
return deviceGateway.getLocalFilePreview({
.query(async ({ ctx, input }) =>
deviceGateway.getLocalFilePreview({
accept: input.accept,
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workingDirectory: input.workingDirectory,
});
}),
}),
),
/**
* Project skills (`.agents/skills` / `.claude/skills`) for a directory on a
@@ -400,67 +368,6 @@ export const deviceRouter = router({
}),
),
/**
* Move files/folders within a directory on a remote device, via the device's
* `moveLocalFiles` RPC. Powers the Files tree's drag-to-move in device mode.
*/
moveProjectFiles: workspaceFileProcedure
.input(
z.object({
items: z.array(z.object({ newPath: z.string(), oldPath: z.string() })),
}),
)
.mutation(async ({ ctx, input }) => {
return deviceGateway.moveProjectFiles({
deviceId: input.deviceId,
items: input.items,
userId: ctx.userId,
workingDirectory: input.workingDirectory,
});
}),
/**
* Rename a single file/folder in a directory on a remote device, via the
* device's `renameLocalFile` RPC.
*/
renameProjectFile: workspaceFileProcedure
.input(
z.object({
newName: z.string(),
path: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
return deviceGateway.renameProjectFile({
deviceId: input.deviceId,
newName: input.newName,
path: input.path,
userId: ctx.userId,
workingDirectory: input.workingDirectory,
});
}),
/**
* Save edited content back to a file on a remote device, via the device's
* `writeLocalFile` RPC. Powers remote save in the LocalFile editor.
*/
writeProjectFile: workspaceFileProcedure
.input(
z.object({
content: z.string(),
path: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
return deviceGateway.writeProjectFile({
content: input.content,
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workingDirectory: input.workingDirectory,
});
}),
/**
* Check whether a path exists on a remote device and is a directory, via the
* device's `statPath` RPC. Lets a web client validate a manually-entered
@@ -1,52 +0,0 @@
import { TRPCError } from '@trpc/server';
import type { DeviceModel } from '@/database/models/device';
import { isPathWithinRoot } from '@/server/services/deviceGateway';
/**
* Validate that a client-supplied workspace root is actually one the user has
* bound to this device.
*
* The file routes (move / rename / write / preview) receive `workingDirectory`
* from the same untrusted browser session that supplies the file paths. The
* gateway's `assertPathsWithinWorkspace` only proves the paths sit *inside that
* directory* it never proves the directory itself is legitimate. So a caller
* could set `workingDirectory` to `/` (or `C:\`), pass that containment check
* trivially, and reach any path on the device.
*
* To close that hole we re-derive the approved roots from the *server-owned*
* device row the `workingDirs` recent list and `defaultCwd`, both written only
* via `device.updateDevice` / the run path, never trusted from this request
* and require the requested root to equal or nest inside one of them before any
* RPC is forwarded. The picker upserts every chosen directory into `workingDirs`
* (see `useCommitWorkingDirectory`) and run start upserts the bound cwd, so a
* legitimately-selected workspace is always present here.
*/
export const assertWorkspaceRootApproved = async (
deviceModel: DeviceModel,
deviceId: string,
workingDirectory: string,
): Promise<void> => {
if (!workingDirectory) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'A workspace root is required for file operations',
});
}
const device = await deviceModel.findByDeviceId(deviceId);
const approvedRoots = [
...(device?.workingDirs ?? []).map((dir) => dir.path),
...(device?.defaultCwd ? [device.defaultCwd] : []),
].filter((root): root is string => Boolean(root));
const approved = approvedRoots.some((root) => isPathWithinRoot(root, workingDirectory));
if (!approved) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Working directory is not an approved workspace for this device',
});
}
};
+6 -11
View File
@@ -183,7 +183,6 @@ export const documentRouter = router({
input.documentId,
editorData,
input.saveSource,
input.lockOwnerId,
);
}),
@@ -256,27 +255,23 @@ export const documentRouter = router({
acquireDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return input.ownerId
? ctx.documentService.acquireDocumentLockWithOwner(input.id, input.ownerId)
: ctx.documentService.acquireDocumentLock(input.id);
return ctx.documentService.acquireDocumentLock(input.id);
}),
getDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.documentService.getDocumentLock(input.id, input.ownerId);
return ctx.documentService.getDocumentLock(input.id);
}),
releaseDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
if (input.ownerId)
await ctx.documentService.releaseDocumentLockWithOwner(input.id, input.ownerId);
else await ctx.documentService.releaseDocumentLock(input.id);
await ctx.documentService.releaseDocumentLock(input.id);
}),
updateDocument: documentProcedure
-2
View File
@@ -32,7 +32,6 @@ import { aiChatRouter } from './aiChat';
import { aiModelRouter } from './aiModel';
import { aiProviderRouter } from './aiProvider';
import { apiKeyRouter } from './apiKey';
import { asrRouter } from './asr';
import { botMessageRouter } from './botMessage';
import { briefRouter } from './brief';
import { changelogRouter } from './changelog';
@@ -99,7 +98,6 @@ export const lambdaRouter = router({
aiModel: aiModelRouter,
aiProvider: aiProviderRouter,
apiKey: apiKeyRouter,
asr: asrRouter,
chunk: chunkRouter,
comfyui: comfyuiRouter,
config: configRouter,
+41 -12
View File
@@ -1,5 +1,6 @@
import { DEFAULT_INBOX_AVATAR, INBOX_SESSION_ID } from '@lobechat/const';
import { TRPCError } from '@trpc/server';
import { eq } from 'drizzle-orm';
import { and, desc, eq, ne, or } from 'drizzle-orm';
import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
@@ -11,7 +12,6 @@ import {
isMessengerPlatformEnabled,
type MessengerPlatform,
} from '@/config/messenger';
import { AgentModel } from '@/database/models/agent';
import {
MessengerAccountLinkConflictError,
MessengerAccountLinkModel,
@@ -23,6 +23,7 @@ import { RbacModel } from '@/database/models/rbac';
import { WorkspaceModel } from '@/database/models/workspace';
import { agents, users } from '@/database/schemas';
import type { LobeChatDatabase } from '@/database/type';
import { buildWorkspaceWhere } from '@/database/utils/workspace';
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { getServerFeatureFlagsStateFromRuntimeConfig } from '@/server/featureFlags';
@@ -121,12 +122,6 @@ const messengerProcedure = authedProcedure.use(serverDatabase).use(async (opts)
// userId), and per-agent authorization happens in-handler via
// `resolveAuthorizedAgentScope`.
messengerLinkModel: new MessengerAccountLinkModel(ctx.serverDB, ctx.userId),
// The bindable-agents scope is request-driven — the cascading scope
// picker passes the workspace via input, not the ambient header — so
// expose a workspace-parameterized AgentModel factory rather than a
// single pre-scoped instance.
getAgentModel: (workspaceId?: string | null) =>
new AgentModel(ctx.serverDB, ctx.userId, workspaceId ?? undefined),
},
});
});
@@ -459,10 +454,44 @@ export const messengerRouter = router({
}
}
// Inbox meta fallback, the virtual-or-inbox filter, inbox pinning, and the
// `isInbox` flag all live in the model. Blank non-inbox titles stay null
// here so the web picker can apply its own i18n default.
return ctx.getAgentModel(workspaceId).listMessengerBindableAgents();
const rows = await serverDB
.select({
avatar: agents.avatar,
backgroundColor: agents.backgroundColor,
id: agents.id,
slug: agents.slug,
title: agents.title,
})
.from(agents)
.where(
and(
buildWorkspaceWhere({ userId, workspaceId: workspaceId ?? undefined }, agents),
or(ne(agents.virtual, true), eq(agents.slug, INBOX_SESSION_ID)),
),
)
.orderBy(desc(agents.updatedAt));
const mapped = rows
.filter((row) => row.id)
.map((row) => ({
avatar: row.avatar || (row.slug === INBOX_SESSION_ID ? DEFAULT_INBOX_AVATAR : null),
backgroundColor: row.backgroundColor,
id: row.id,
slug: row.slug,
title: row.title || (row.slug === INBOX_SESSION_ID ? 'LobeAI' : null),
}));
// Pin the inbox/LobeAI agent to the top regardless of updatedAt — it's
// the implicit "default" agent and should always be the first option.
const inboxIdx = mapped.findIndex((row) => row.slug === INBOX_SESSION_ID);
if (inboxIdx > 0) {
const [inbox] = mapped.splice(inboxIdx, 1);
mapped.unshift(inbox);
}
return mapped.map(({ slug, ...rest }) => ({
...rest,
isInbox: slug === INBOX_SESSION_ID,
}));
}),
/**
@@ -1,19 +1,21 @@
import { TASK_TEMPLATE_RECOMMEND_MAX_COUNT } from '@lobechat/const';
import { KNOWN_TASK_TEMPLATE_IDS } from '@lobechat/const';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { TaskTemplateService } from '@/server/services/taskTemplate';
import { ENABLED_SKILL_SOURCES, TaskTemplateService } from '@/server/services/taskTemplate';
const listDailyRecommendSchema = z.object({
count: z.number().int().min(1).max(TASK_TEMPLATE_RECOMMEND_MAX_COUNT).optional(),
count: z.number().int().min(1).optional(),
interestKeys: z.array(z.string().max(64)).max(32),
locale: z.string().max(32).optional(),
refreshSeed: z.string().min(1).max(32).optional(),
});
const templateIdSchema = z.object({
templateId: z.number().int().positive(),
templateId: z
.string()
.max(64)
.refine((id) => KNOWN_TASK_TEMPLATE_IDS.has(id), { message: 'Unknown task template id' }),
});
export const taskTemplateRouter = router({
@@ -26,7 +28,7 @@ export const taskTemplateRouter = router({
const service = new TaskTemplateService(ctx.userId);
const data = await service.listDailyRecommend(input.interestKeys, {
count: input.count,
locale: input.locale,
enabledSkillSources: ENABLED_SKILL_SOURCES,
refreshSeed: input.refreshSeed,
});
return { data, success: true };
+34 -11
View File
@@ -4,21 +4,19 @@ import {
type RecentTopicGroupMember,
} from '@lobechat/types';
import { cleanObject } from '@lobechat/utils';
import { inArray } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import { after } from 'next/server';
import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { AgentModel } from '@/database/models/agent';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { ChatGroupModel } from '@/database/models/chatGroup';
import { MessageModel } from '@/database/models/message';
import { TopicModel } from '@/database/models/topic';
import { TopicShareModel } from '@/database/models/topicShare';
import { AgentMigrationRepo } from '@/database/repositories/agentMigration';
import { TopicImporterRepo } from '@/database/repositories/topicImporter';
import { chatGroups } from '@/database/schemas';
import { agents, chatGroups, chatGroupsAgents } from '@/database/schemas';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { type BatchTaskResult } from '@/types/service';
@@ -37,9 +35,7 @@ const topicProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
return opts.next({
ctx: {
agentMigrationRepo: new AgentMigrationRepo(ctx.serverDB, ctx.userId, wsId),
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
agentOperationModel: new AgentOperationModel(ctx.serverDB, ctx.userId, wsId),
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
topicImporterRepo: new TopicImporterRepo(ctx.serverDB, ctx.userId, wsId),
topicModel: new TopicModel(ctx.serverDB, ctx.userId, wsId),
topicShareModel: new TopicShareModel(ctx.serverDB, ctx.userId, wsId),
@@ -449,14 +445,22 @@ export const topicRouter = router({
// Collect all agentIds to fetch agent info
const allAgentIds = [...new Set(topicAgentIdMap.values())];
// Batch query agent info (already normalized for the inbox agent)
// Batch query agent info
const agentInfoMap = new Map<
string,
{ avatar: string | null; backgroundColor: string | null; id: string; title: string | null }
>();
if (allAgentIds.length > 0) {
const agentInfos = await ctx.agentModel.getAgentAvatarsByIds(allAgentIds);
const agentInfos = await ctx.serverDB
.select({
avatar: agents.avatar,
backgroundColor: agents.backgroundColor,
id: agents.id,
title: agents.title,
})
.from(agents)
.where(inArray(agents.id, allAgentIds));
for (const agent of agentInfos) {
agentInfoMap.set(agent.id, agent);
@@ -477,9 +481,28 @@ export const topicRouter = router({
.from(chatGroups)
.where(inArray(chatGroups.id, allGroupIds));
// Query group member avatars (already normalized for the inbox agent)
const groupMembersMap: Map<string, RecentTopicGroupMember[]> =
await ctx.chatGroupModel.getMemberAvatarsByGroupIds(allGroupIds);
// Query group member agents (get avatar info)
const groupMembersRaw = await ctx.serverDB
.select({
agentAvatar: agents.avatar,
agentBackgroundColor: agents.backgroundColor,
chatGroupId: chatGroupsAgents.chatGroupId,
order: chatGroupsAgents.order,
})
.from(chatGroupsAgents)
.leftJoin(agents, eq(chatGroupsAgents.agentId, agents.id))
.where(inArray(chatGroupsAgents.chatGroupId, allGroupIds));
// Group members by chatGroupId
const groupMembersMap = new Map<string, RecentTopicGroupMember[]>();
for (const member of groupMembersRaw) {
const members = groupMembersMap.get(member.chatGroupId) || [];
members.push({
avatar: member.agentAvatar,
backgroundColor: member.agentBackgroundColor,
});
groupMembersMap.set(member.chatGroupId, members);
}
// Build group info map
for (const group of chatGroupInfos) {
@@ -104,7 +104,6 @@ export const userMemoryRouter = router({
deleteAll: userMemoryWriteProcedure.mutation(async ({ ctx }) => {
await ctx.userMemoryModel.deleteAll();
await ctx.personaModel.deletePersona();
return { success: true };
}),
-2
View File
@@ -13,7 +13,6 @@ import { aiProviderRouter } from '../lambda/aiProvider';
import { briefRouter } from '../lambda/brief';
import { chunkRouter } from '../lambda/chunk';
import { configRouter } from '../lambda/config';
import { deviceRouter } from '../lambda/device';
import { documentRouter } from '../lambda/document';
import { fileRouter } from '../lambda/file';
import { homeRouter } from '../lambda/home';
@@ -37,7 +36,6 @@ export const mobileRouter = router({
aiProvider: aiProviderRouter,
chunk: chunkRouter,
config: configRouter,
device: deviceRouter,
document: documentRouter,
file: fileRouter,
healthcheck: publicProcedure.query(() => "i'm live!"),
@@ -1,10 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
isMarketConnectionsAuthError,
isMarketConnectionsTimeoutError,
listMarketConnectionsWithTimeout,
listOptionalMarketConnectionsWithTimeout,
MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
} from './marketConnections';
@@ -33,33 +31,4 @@ describe('marketConnections helpers', () => {
expect(isMarketConnectionsTimeoutError(new DOMException('Aborted', 'AbortError'))).toBe(true);
expect(isMarketConnectionsTimeoutError(new Error('market failed'))).toBe(false);
});
it('detects Market auth failures', () => {
expect(
isMarketConnectionsAuthError({
errorBody: { error: 'unauthorized', error_description: 'Missing bearer token' },
status: 401,
}),
).toBe(true);
expect(isMarketConnectionsAuthError(new Error('Network error'))).toBe(false);
});
it('returns empty connections for optional auth failures', async () => {
const listConnections = vi.fn().mockRejectedValue({
errorBody: { error: 'unauthorized', error_description: 'Missing bearer token' },
status: 401,
});
await expect(listOptionalMarketConnectionsWithTimeout({ listConnections })).resolves.toEqual({
connections: [],
success: true,
});
});
it('rethrows non-auth failures for optional connections', async () => {
const error = new Error('Market API unavailable');
const listConnections = vi.fn().mockRejectedValue(error);
await expect(listOptionalMarketConnectionsWithTimeout({ listConnections })).rejects.toBe(error);
});
});
@@ -4,47 +4,6 @@ export const MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS = 10_000;
type MarketConnectClient = Pick<MarketSDK['connect'], 'listConnections'>;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const getStringField = (value: unknown, key: string) => {
if (!isRecord(value)) return;
const field = value[key];
return typeof field === 'string' ? field : undefined;
};
const includesAuthError = (value?: string) => {
const normalized = value?.toLowerCase();
if (!normalized) return false;
return (
normalized === 'unauthorized' ||
normalized === 'invalid_token' ||
normalized === 'token_expired' ||
normalized.includes('missing bearer token') ||
normalized.includes('unauthorized') ||
normalized.includes('invalid_token') ||
normalized.includes('token expired')
);
};
export const isMarketConnectionsAuthError = (error: unknown): boolean => {
if (!isRecord(error)) return false;
const status = error.status;
const errorBody = error.errorBody;
return (
status === 401 ||
includesAuthError(getStringField(error, 'name')) ||
includesAuthError(getStringField(error, 'message')) ||
includesAuthError(getStringField(errorBody, 'error')) ||
includesAuthError(getStringField(errorBody, 'error_description'))
);
};
export const isMarketConnectionsTimeoutError = (error: unknown): boolean =>
error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
@@ -56,18 +15,3 @@ export const listMarketConnectionsWithTimeout = async (
signal: AbortSignal.timeout(timeoutMs),
});
};
export const listOptionalMarketConnectionsWithTimeout = async (
marketConnect: MarketConnectClient,
timeoutMs = MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
): Promise<ListConnectionsResponse> => {
try {
return await listMarketConnectionsWithTimeout(marketConnect, timeoutMs);
} catch (error) {
if (isMarketConnectionsAuthError(error)) {
return { connections: [], success: true };
}
throw error;
}
};
+2 -2
View File
@@ -23,7 +23,7 @@ import { createSandboxService } from '@/server/services/sandbox';
import { scheduleToolCallReport } from './_helpers';
import {
isMarketConnectionsTimeoutError,
listOptionalMarketConnectionsWithTimeout,
listMarketConnectionsWithTimeout,
MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
} from './_helpers/marketConnections';
@@ -540,7 +540,7 @@ export const marketRouter = router({
log('connectListConnections');
try {
const response = await listOptionalMarketConnectionsWithTimeout(ctx.marketSDK.connect);
const response = await listMarketConnectionsWithTimeout(ctx.marketSDK.connect);
// Debug logging
log('connectListConnections raw response: %O', response);
log('connectListConnections connections: %O', response.connections);
+4 -6
View File
@@ -1,5 +1,5 @@
// @vitest-environment node
import { DEFAULT_AGENT_CONFIG, DEFAULT_INBOX_AVATAR, DEFAULT_INBOX_TITLE } from '@lobechat/const';
import { DEFAULT_AGENT_CONFIG } from '@lobechat/const';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentModel } from '@/database/models/agent';
@@ -190,12 +190,10 @@ describe('AgentService', () => {
expect(result?.provider).toBe('anthropic');
});
it('should fallback inbox title and avatar', async () => {
it('should merge avatar from builtin-agents package definition', async () => {
const mockAgent = {
avatar: null,
id: 'agent-1',
slug: 'inbox',
title: null,
model: 'gpt-4',
};
@@ -209,8 +207,8 @@ describe('AgentService', () => {
const newService = new AgentService(mockDb, mockUserId);
const result = await newService.getBuiltinAgent('inbox');
expect((result as any)?.avatar).toBe(DEFAULT_INBOX_AVATAR);
expect((result as any)?.title).toBe(DEFAULT_INBOX_TITLE);
// Avatar should be merged from BUILTIN_AGENTS definition
expect((result as any)?.avatar).toBe('/avatars/lobe-ai.png');
});
it('should not include avatar for non-builtin agents', async () => {
+3 -10
View File
@@ -10,7 +10,6 @@ import { type PartialDeep } from 'type-fest';
import { AgentModel } from '@/database/models/agent';
import { SessionModel } from '@/database/models/session';
import { UserModel } from '@/database/models/user';
import { normalizeInboxAgentAvatar, normalizeInboxAgentTitle } from '@/database/utils/inboxAgent';
import { getRedisConfig } from '@/envs/redis';
import {
getJSONFromRedis,
@@ -84,20 +83,14 @@ export class AgentService {
const mergedConfig = this.mergeDefaultConfig(agent, defaultAgentConfig);
if (!mergedConfig) return null;
const identity = { slug: (mergedConfig as { slug?: string | null }).slug ?? slug };
const normalizedConfig = {
...mergedConfig,
avatar: normalizeInboxAgentAvatar(mergedConfig.avatar, identity),
title: normalizeInboxAgentTitle(mergedConfig.title, identity),
};
// Use builtin avatar as fallback only when DB has no custom avatar
const builtinAgent = BUILTIN_AGENTS[slug as BuiltinAgentSlug];
if (builtinAgent?.avatar && !normalizedConfig.avatar) {
return { ...normalizedConfig, avatar: builtinAgent.avatar };
if (builtinAgent?.avatar && !mergedConfig.avatar) {
return { ...mergedConfig, avatar: builtinAgent.avatar };
}
return normalizedConfig;
return mergedConfig;
}
/**
@@ -96,11 +96,8 @@ describe('AgentDocumentsService', () => {
findContextByAgent: vi.fn(),
findByDocumentIds: vi.fn(),
findByFilename: vi.fn(),
findByParentAndFilename: vi.fn(),
findSkillDocsByAgent: vi.fn(),
hasByAgent: vi.fn(),
listByAgent: vi.fn(),
listByDocumentIds: vi.fn(),
rename: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
@@ -285,19 +282,21 @@ describe('AgentDocumentsService', () => {
describe('listDocuments', () => {
it('should return a list of documents with documentId, filename, id, and title', async () => {
mockModel.listByAgent.mockResolvedValue([
mockModel.findByAgent.mockResolvedValue([
{
content: 'c1',
documentId: 'documents-1',
filename: 'a.md',
id: 'doc-1',
loadPosition: undefined,
policy: null,
title: 'A',
},
{
content: 'c2',
documentId: 'documents-2',
filename: 'b.md',
id: 'doc-2',
loadPosition: undefined,
policy: null,
title: 'B',
},
]);
@@ -305,8 +304,7 @@ describe('AgentDocumentsService', () => {
const service = new AgentDocumentsService(db, userId);
const result = await service.listDocuments('agent-1');
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1');
expect(mockModel.findByAgent).not.toHaveBeenCalled();
expect(mockModel.findByAgent).toHaveBeenCalledWith('agent-1');
expect(result).toEqual([
{
documentId: 'documents-1',
@@ -324,75 +322,6 @@ describe('AgentDocumentsService', () => {
},
]);
});
it('should pass sourceType filtering to the model', async () => {
mockModel.listByAgent.mockResolvedValue([]);
const service = new AgentDocumentsService(db, userId);
await service.listDocuments('agent-1', 'web');
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1', { sourceType: 'web' });
expect(mockModel.findByAgent).not.toHaveBeenCalled();
});
it('should hide the .tool-results archive folder and its children by default', async () => {
mockModel.listByAgent.mockResolvedValue([
{
documentId: 'archive-root',
fileType: 'custom/folder',
filename: '.tool-results',
id: 'doc-archive',
parentId: null,
title: '.tool-results',
},
{
documentId: 'archive-child',
filename: 'dump.md',
id: 'doc-child',
parentId: 'archive-root',
title: 'dump',
},
{
documentId: 'documents-1',
filename: 'a.md',
id: 'doc-1',
parentId: null,
title: 'A',
},
]);
const service = new AgentDocumentsService(db, userId);
const result = await service.listDocuments('agent-1');
expect(result.map((d) => d.documentId)).toEqual(['documents-1']);
});
it('should include the .tool-results archive when includeArchivedToolResults is set', async () => {
mockModel.listByAgent.mockResolvedValue([
{
documentId: 'archive-root',
fileType: 'custom/folder',
filename: '.tool-results',
id: 'doc-archive',
parentId: null,
title: '.tool-results',
},
{
documentId: 'documents-1',
filename: 'a.md',
id: 'doc-1',
parentId: null,
title: 'A',
},
]);
const service = new AgentDocumentsService(db, userId);
const result = await service.listDocuments('agent-1', undefined, {
includeArchivedToolResults: true,
});
expect(result.map((d) => d.documentId)).toEqual(['archive-root', 'documents-1']);
});
});
describe('listDocumentsForTopic', () => {
@@ -401,19 +330,19 @@ describe('AgentDocumentsService', () => {
{ id: 'documents-2', title: 'B' },
{ id: 'documents-1', title: 'A' },
]);
mockModel.listByDocumentIds.mockResolvedValue([
mockModel.findByDocumentIds.mockResolvedValue([
{
documentId: 'documents-1',
filename: 'a.md',
id: 'agent-doc-1',
loadPosition: undefined,
policy: null,
title: 'A',
},
{
documentId: 'documents-2',
filename: 'b.md',
id: 'agent-doc-2',
loadPosition: undefined,
policy: null,
title: 'B',
},
]);
@@ -422,11 +351,10 @@ describe('AgentDocumentsService', () => {
const result = await service.listDocumentsForTopic('agent-1', 'topic-1');
expect(mockTopicDocumentModel.findByTopicId).toHaveBeenCalledWith('topic-1');
expect(mockModel.listByDocumentIds).toHaveBeenCalledWith('agent-1', [
expect(mockModel.findByDocumentIds).toHaveBeenCalledWith('agent-1', [
'documents-2',
'documents-1',
]);
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
expect(result).toEqual([
{
documentId: 'documents-2',
@@ -444,77 +372,6 @@ describe('AgentDocumentsService', () => {
},
]);
});
it('should pass sourceType filtering to the topic document summary query', async () => {
mockTopicDocumentModel.findByTopicId.mockResolvedValue([{ id: 'documents-1' }]);
mockModel.listByDocumentIds.mockResolvedValue([]);
const service = new AgentDocumentsService(db, userId);
await service.listDocumentsForTopic('agent-1', 'topic-1', 'web');
expect(mockModel.listByDocumentIds).toHaveBeenCalledWith('agent-1', ['documents-1'], {
sourceType: 'web',
});
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
});
it('should hide an archived tool result whose `.tool-results` folder is not topic-associated', async () => {
// The archive folder is created by mkdir but only the archived file gets
// associated with the topic, so the folder never appears in the list.
mockTopicDocumentModel.findByTopicId.mockResolvedValue([
{ id: 'archive-child', title: 'dump' },
]);
mockModel.listByDocumentIds.mockResolvedValue([
{
documentId: 'archive-child',
filename: 'topic_call.txt',
id: 'agent-doc-archive-child',
parentId: 'archive-root',
title: 'dump',
},
]);
mockModel.findByParentAndFilename.mockResolvedValue({
documentId: 'archive-root',
fileType: 'custom/folder',
filename: '.tool-results',
id: 'agent-doc-archive-root',
parentId: null,
});
const service = new AgentDocumentsService(db, userId);
const result = await service.listDocumentsForTopic('agent-1', 'topic-1');
expect(mockModel.findByParentAndFilename).toHaveBeenCalledWith(
'agent-1',
null,
'.tool-results',
);
expect(result).toEqual([]);
});
it('should keep the archived tool result when includeArchivedToolResults is set', async () => {
mockTopicDocumentModel.findByTopicId.mockResolvedValue([
{ id: 'archive-child', title: 'dump' },
]);
mockModel.listByDocumentIds.mockResolvedValue([
{
documentId: 'archive-child',
filename: 'topic_call.txt',
id: 'agent-doc-archive-child',
parentId: 'archive-root',
title: 'dump',
},
]);
const service = new AgentDocumentsService(db, userId);
const result = await service.listDocumentsForTopic('agent-1', 'topic-1', undefined, {
includeArchivedToolResults: true,
});
expect(result.map((d) => d.documentId)).toEqual(['archive-child']);
// No folder lookup needed when archives are included.
expect(mockModel.findByParentAndFilename).not.toHaveBeenCalled();
});
});
describe('getDocumentByFilename', () => {
@@ -13,8 +13,6 @@ import type {
AgentDocument,
AgentDocumentContextPayload,
AgentDocumentContextRow,
AgentDocumentListItem,
AgentDocumentListSourceType,
AgentDocumentWithRules,
ToolUpdateLoadRule,
} from '@/database/models/agentDocuments';
@@ -71,13 +69,18 @@ type ProjectableAgentDocument = Pick<
'content' | 'editorData' | 'fileType' | 'templateId'
>;
/** Collect ids of root `.tool-results` archive folders present in a doc list. */
const collectArchiveFolderIds = <
/**
* Hide the auto-created `.tool-results/` archive (root folder + its children)
* from user-facing document lists. Agents still discover archived entries via
* the tool-oriented `listDocuments` / `listDocumentsForTopic` paths, which hit
* the model directly.
*/
const excludeArchivedToolResults = <
T extends Pick<AgentDocument, 'documentId' | 'parentId' | 'filename' | 'fileType'>,
>(
docs: T[],
): Set<string> =>
new Set(
): T[] => {
const archiveFolderIds = new Set(
docs
.filter(
(d) =>
@@ -87,24 +90,6 @@ const collectArchiveFolderIds = <
)
.map((d) => d.documentId),
);
/**
* Hide the auto-created `.tool-results/` archive (root folder + its children)
* from user-facing document lists. Applied by default everywhere, including
* `listDocuments` / `listDocumentsForTopic`. The tool runtime that lets agents
* discover archived entries opts back in via `includeArchivedToolResults`.
*
* `archiveFolderIds` lets callers whose list may not contain the folder row
* supply the ids explicitly the topic path only sees the archived file
* (which is topic-associated), never the folder, so it can't be derived from
* the list alone.
*/
const excludeArchivedToolResults = <
T extends Pick<AgentDocument, 'documentId' | 'parentId' | 'filename' | 'fileType'>,
>(
docs: T[],
archiveFolderIds: Set<string> = collectArchiveFolderIds(docs),
): T[] => {
if (archiveFolderIds.size === 0) return docs;
return docs.filter(
(d) =>
@@ -626,51 +611,54 @@ export class AgentDocumentsService {
}
}
async listDocuments(
agentId: string,
sourceType?: AgentDocumentListSourceType,
options?: { includeArchivedToolResults?: boolean },
) {
const docs = sourceType
? await this.agentDocumentModel.listByAgent(agentId, { sourceType })
: await this.agentDocumentModel.listByAgent(agentId);
return options?.includeArchivedToolResults ? docs : excludeArchivedToolResults(docs);
async listDocuments(agentId: string, sourceType?: 'all' | 'file' | 'web') {
const docs = await this.agentDocumentModel.findByAgent(agentId);
const filtered =
sourceType && sourceType !== 'all' ? docs.filter((d) => d.sourceType === sourceType) : docs;
return filtered.map((d) => ({
...deriveAgentDocumentFields(d),
description: d.description,
documentId: d.documentId,
fileType: d.fileType,
filename: d.filename,
id: d.id,
loadPosition: d.policy?.context?.position,
parentId: d.parentId,
sourceType: d.sourceType,
templateId: d.templateId,
title: d.title,
updatedAt: d.updatedAt,
}));
}
async listDocumentsForTopic(
agentId: string,
topicId: string,
sourceType?: AgentDocumentListSourceType,
options?: { includeArchivedToolResults?: boolean },
sourceType?: 'all' | 'file' | 'web',
) {
const topicDocs = await this.topicDocumentModel.findByTopicId(topicId);
const documentIds = topicDocs.map((doc) => doc.id);
const docs = sourceType
? await this.agentDocumentModel.listByDocumentIds(agentId, documentIds, { sourceType })
: await this.agentDocumentModel.listByDocumentIds(agentId, documentIds);
const docs = await this.agentDocumentModel.findByDocumentIds(agentId, documentIds);
const docsByDocumentId = new Map(docs.map((doc) => [doc.documentId, doc]));
const ordered = topicDocs
return topicDocs
.map((topicDoc) => docsByDocumentId.get(topicDoc.id))
.filter((doc): doc is AgentDocumentListItem => Boolean(doc));
if (options?.includeArchivedToolResults) return ordered;
// The `.tool-results` folder is never topic-associated (only the archived
// file is), so it isn't in `ordered`. Look it up directly so the archived
// file can be filtered out by its parent id.
const archiveFolder = await this.agentDocumentModel.findByParentAndFilename(
agentId,
null,
TOOL_RESULTS_DIR_NAME,
);
const archiveFolderIds =
archiveFolder?.fileType === DOCUMENT_FOLDER_TYPE
? new Set([archiveFolder.documentId])
: new Set<string>();
return excludeArchivedToolResults(ordered, archiveFolderIds);
.filter((doc): doc is AgentDocumentWithRules => Boolean(doc))
.filter((doc) => !sourceType || sourceType === 'all' || doc.sourceType === sourceType)
.map((doc) => ({
...deriveAgentDocumentFields(doc),
description: doc.description,
documentId: doc.documentId,
fileType: doc.fileType,
filename: doc.filename,
id: doc.id,
loadPosition: doc.policy?.context?.position,
parentId: doc.parentId,
sourceType: doc.sourceType,
templateId: doc.templateId,
title: doc.title,
updatedAt: doc.updatedAt,
}));
}
async getDocumentByFilename(agentId: string, filename: string) {
@@ -17,9 +17,6 @@ import {
} from './types';
vi.mock('@lobechat/model-runtime', () => ({
// RuntimeExecutors (loaded transitively) resolves extend params via this
// helper; an empty result keeps the runtime payload unchanged.
applyModelExtendParams: vi.fn(() => ({})),
getModelPropertyWithFallback: vi.fn(),
// `llmErrorClassification.ts` reads these at module-load time; an empty
// spec map is fine here because this suite never exercises the runtime
@@ -1856,186 +1853,6 @@ describe('AgentRuntimeService', () => {
expect(won).toBe(false);
expect(mockQueueService.scheduleMessage).not.toHaveBeenCalled();
});
it('schedules a finish step when the parked tool requests onComplete=finish (skipCallSupervisor / delegate)', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [{ id: 'tc1' }],
status: 'waiting_for_async_tool',
stepCount: 4,
});
(service as any).serverDB.query = {
messagePlugins: {
findFirst: vi.fn().mockResolvedValue({
id: 'msg-tc1',
state: { onComplete: 'finish', status: 'completed' },
toolCallId: 'tc1',
}),
},
};
(service as any).messageModel.findById = vi.fn().mockResolvedValue({ content: 'answer' });
vi.spyOn(AgentOperationModel.prototype, 'tryResumeFromAsyncTool').mockResolvedValue(true);
const won = await service.tryResumeParentFromAsyncTool({ parentOperationId: parentOpId });
expect(won).toBe(true);
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
expect.objectContaining({ payload: { finishAfterAsyncTool: true }, stepIndex: 4 }),
);
});
});
describe('completeGroupActionMember', () => {
const memberState = {
messages: [
{ content: 'question', role: 'user' },
{ content: 'final answer', role: 'assistant' },
],
metadata: { agentId: 'agent-a' },
modelRuntimeConfig: { model: 'gpt-test' },
status: 'done',
usage: { llm: { tokens: { total: 42 } }, tools: { totalCalls: 2 } },
};
let updateToolMessage: ReturnType<typeof vi.fn>;
let resumeSpy: MockInstance<AgentRuntimeService['tryResumeParentFromAsyncTool']>;
beforeEach(() => {
updateToolMessage = vi.fn().mockResolvedValue({ success: true });
(service as any).messageModel.updateToolMessage = updateToolMessage;
resumeSpy = vi.spyOn(service, 'tryResumeParentFromAsyncTool').mockResolvedValue(true);
});
it('single in-group member: backfills a receipt onto the group tool and resumes', async () => {
const won = await service.completeGroupActionMember({
anchorMessageId: 'grp-tool-1',
expectedMembers: 1,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(won).toBe(true);
expect(updateToolMessage).toHaveBeenCalledWith(
'grp-tool-1',
expect.objectContaining({
content: 'Agent agent-a responded in the group.',
pluginState: expect.objectContaining({ status: 'completed' }),
}),
);
expect(resumeSpy).toHaveBeenCalledWith(
{ parentOperationId: 'parent-1' },
{ scheduleVerifyOnHold: true },
);
});
it('single isolated member: backfills the final answer', async () => {
await service.completeGroupActionMember({
anchorMessageId: 'grp-tool-1',
expectedMembers: 1,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'isolated',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(updateToolMessage).toHaveBeenCalledWith(
'grp-tool-1',
expect.objectContaining({ content: 'final answer' }),
);
});
it('multi-member: holds (no group-tool backfill, no resume) until the barrier is met', async () => {
(service as any).serverDB.query = {
messagePlugins: { findFirst: vi.fn() },
messages: {
findMany: vi
.fn()
.mockResolvedValue([{ content: 'a note', id: 'anchor-0', role: 'tool' }]),
},
};
mockCoordinator.loadAgentState.mockResolvedValue({
status: 'waiting_for_async_tool',
stepCount: 1,
});
const won = await service.completeGroupActionMember({
anchorMessageId: 'anchor-0',
expectedMembers: 2,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(won).toBe(false);
expect(updateToolMessage).toHaveBeenCalledWith('anchor-0', expect.anything());
expect(updateToolMessage).not.toHaveBeenCalledWith('grp-tool-1', expect.anything());
expect(resumeSpy).not.toHaveBeenCalled();
});
it('multi-member: last completion backfills the group tool and resumes', async () => {
(service as any).serverDB.query = {
messagePlugins: { findFirst: vi.fn() },
messages: {
findMany: vi.fn().mockResolvedValue([
{ content: 'a', id: 'anchor-0', role: 'tool' },
{ content: 'b', id: 'anchor-1', role: 'tool' },
]),
},
};
const won = await service.completeGroupActionMember({
anchorMessageId: 'anchor-1',
expectedMembers: 2,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-2',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(won).toBe(true);
expect(updateToolMessage).toHaveBeenCalledWith('anchor-1', expect.anything());
expect(updateToolMessage).toHaveBeenCalledWith(
'grp-tool-1',
expect.objectContaining({
content: 'All 2 agent members completed.',
pluginState: expect.objectContaining({ status: 'completed' }),
}),
);
expect(resumeSpy).toHaveBeenCalled();
});
it('throws when the anchor backfill fails so the webhook redelivers', async () => {
updateToolMessage.mockResolvedValue({ success: false });
await expect(
service.completeGroupActionMember({
anchorMessageId: 'grp-tool-1',
expectedMembers: 1,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
}),
).rejects.toThrow(/failed to backfill anchor/);
expect(resumeSpy).not.toHaveBeenCalled();
});
});
describe('completeSubAgentBridge', () => {
@@ -2140,93 +1957,4 @@ describe('AgentRuntimeService', () => {
expect(resumeSpy).not.toHaveBeenCalled();
});
});
describe('resolveAsyncToolOnComplete', () => {
it('returns finish when ANY pending tool requests finish (not just the first)', async () => {
// First pending tool resumes; a later one is a group finish action. The
// disposition must scan all pending tools, not only pending[0].
(service as any).serverDB.query = {
messagePlugins: {
findFirst: vi
.fn()
.mockResolvedValueOnce({ state: { status: 'completed' } })
.mockResolvedValueOnce({ state: { onComplete: 'finish', status: 'completed' } }),
},
};
const result = await (service as any).resolveAsyncToolOnComplete([
{ id: 'tc1' },
{ id: 'tc2' },
]);
expect(result).toBe('finish');
});
it('returns resume when no pending tool requests finish', async () => {
(service as any).serverDB.query = {
messagePlugins: {
findFirst: vi.fn().mockResolvedValue({ state: { status: 'completed' } }),
},
};
const result = await (service as any).resolveAsyncToolOnComplete([
{ id: 'tc1' },
{ id: 'tc2' },
]);
expect(result).toBe('resume');
});
});
describe('group member timeout watchdog', () => {
const timeoutParams = {
anchorMessageId: 'anchor-1',
expectedMembers: 1,
groupToolMessageId: 'grp-tool-1',
memberOperationId: 'member-op-1',
mode: 'isolated' as const,
onComplete: 'resume' as const,
parentOperationId: 'parent-1',
};
it('no-ops when the member already reached a terminal state', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({ status: 'done' });
const interruptSpy = vi.spyOn(service, 'interruptOperation');
const bridgeSpy = vi.spyOn(service, 'completeGroupActionMember');
const result = await service.executeStep({
groupMemberTimeout: timeoutParams,
operationId: 'member-op-1',
stepIndex: 0,
} as any);
expect(result.success).toBe(true);
expect(result.nextStepScheduled).toBe(false);
expect(interruptSpy).not.toHaveBeenCalled();
expect(bridgeSpy).not.toHaveBeenCalled();
});
it('interrupts the member and bridges a timeout when it is still running', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({ status: 'running' });
const interruptSpy = vi.spyOn(service, 'interruptOperation').mockResolvedValue(true);
const bridgeSpy = vi.spyOn(service, 'completeGroupActionMember').mockResolvedValue(true);
const result = await service.executeStep({
groupMemberTimeout: timeoutParams,
operationId: 'member-op-1',
stepIndex: 0,
} as any);
expect(interruptSpy).toHaveBeenCalledWith('member-op-1');
expect(bridgeSpy).toHaveBeenCalledWith(
expect.objectContaining({
onComplete: 'resume',
operationId: 'member-op-1',
parentOperationId: 'parent-1',
reason: 'timeout',
}),
);
expect(result.nextStepScheduled).toBe(true);
});
});
});
@@ -67,11 +67,6 @@ import { buildStepPresentation, formatTokenCount } from './stepPresentation';
import {
type AgentExecutionParams,
type AgentExecutionResult,
type ExecGroupMemberParams,
type ExecGroupMemberResult,
type GroupActionMemberBridgeParams,
type GroupActionOnComplete,
type GroupMemberTimeoutParams,
type OperationCreationParams,
type OperationCreationResult,
type OperationStatusResult,
@@ -104,7 +99,7 @@ const ASYNC_TOOL_VERIFY_DELAY_MS = 15_000;
* shot) so a transient miss a read-replica lag, a sibling dying between
* backfill and resume is retried rather than leaving the parent stuck in
* `waiting_for_async_tool` forever. With exponential backoff from a 15s base,
* 5 attempts span ~15s ~7.75min total before giving up. For details see: async sub-agent suspend/resume stability hardening bounded watchdog retry with exponential backoff instead of single-shot verification.
* 5 attempts span ~15s ~7.75min total before giving up. See LOBE-10385.
*/
const ASYNC_TOOL_VERIFY_MAX_ATTEMPTS = 5;
@@ -161,13 +156,6 @@ const toAgentSignalSnapshotEvents = (
* top-level option. One named home for the whole upward-call surface.
*/
export interface AgentRuntimeDelegate {
/**
* Fork a group member ("call agent member") under a `lobe-group-management`
* tool call. Handles both in-group (non-isolated, shared group session) and
* isolated members, installing the group-action member completion bridge that
* enforces the K=N member barrier before resuming/finishing the supervisor.
*/
execGroupMember?: (params: ExecGroupMemberParams) => Promise<ExecGroupMemberResult>;
/**
* Run a legacy agent invocation through the full high-level pipeline
* (AiAgentService.execSubAgent execAgent: agent-config resolution, tool
@@ -625,27 +613,18 @@ export class AgentRuntimeService {
rejectionReason,
rejectAndContinue,
resumeAsyncTool,
finishAfterAsyncTool,
groupMemberTimeout,
toolMessageId,
verifyAsyncToolBarrier,
asyncToolVerifyAttempt,
externalRetryCount = 0,
} = params;
// Group member timeout watchdog: enforce a member's deadline without claiming
// the step lock. No-op if the member already finished; otherwise interrupt it
// and bridge a `timeout` completion so the parked supervisor resumes/finishes.
if (groupMemberTimeout) {
return this.handleGroupMemberTimeout(groupMemberTimeout);
}
// Watchdog re-check for a parked async-tool wait: re-run the barrier + CAS
// without claiming the step lock or executing anything. Idempotent — the
// CAS guarantees at most one real resume regardless of how many checks run.
// Opt back into `scheduleVerifyOnHold` with the next attempt so an
// unsatisfied barrier re-arms (bounded backoff) instead of giving up after
// a single shot — bounded watchdog retry ensures transient misses are recovered.
// a single shot — the core LOBE-10385 fix.
if (verifyAsyncToolBarrier) {
const attempt = asyncToolVerifyAttempt ?? 1;
log(
@@ -908,29 +887,6 @@ export class AgentRuntimeService {
);
}
// Finish a parked supervisor op WITHOUT another LLM turn (group
// orchestration skipCallSupervisor / delegate). Refresh messages so the
// final group conversation is captured, transition straight to `done`,
// and let the standard `!shouldContinue` finalization below record
// completion + dispatch hooks. Skips runtime.step entirely.
let forcedFinishState: AgentState | undefined;
if (finishAfterAsyncTool && currentState.status === 'waiting_for_async_tool') {
const refreshed = await this.refreshMessagesFromDB(currentState);
currentState = structuredClone(currentState);
currentState.messages = refreshed;
currentState.pendingToolsCalling = [];
currentState.status = 'done';
currentState.interruption = undefined;
currentState.lastModified = new Date().toISOString();
forcedFinishState = currentState;
log(
'[%s][%d] Finishing parked supervisor op after async tool (%d messages)',
operationId,
stepIndex,
refreshed.length,
);
}
// Pre-step computation: extract device context from DB messages
// Follows front-end computeStepContext pattern — computed at step boundary, not inside executors
if (!currentState.metadata?.activeDeviceId) {
@@ -948,11 +904,9 @@ export class AgentRuntimeService {
}
}
// Execute step (skipped when force-finishing a parked supervisor op).
// Execute step
const startAt = Date.now();
const stepResult = forcedFinishState
? { events: [], newState: forcedFinishState, nextContext: undefined }
: await runtime.step(currentState, currentContext);
const stepResult = await runtime.step(currentState, currentContext);
// Inner runtime.step() catches model-runtime exceptions and stuffs the
// raw error into newState.error without re-throwing — so the outer
@@ -1719,11 +1673,6 @@ export class AgentRuntimeService {
* query hits a read replica that hasn't seen the just-committed write.
*/
knownFulfilledMessageId?: string;
/**
* Group orchestration disposition (skipCallSupervisor / delegate finish).
* When omitted, resolved from the parked tool message's pluginState.
*/
onComplete?: GroupActionOnComplete;
scheduleVerifyOnHold?: boolean;
/** 1-based watchdog attempt to arm when the parent isn't resumable yet. */
verifyAttempt?: number;
@@ -1775,15 +1724,6 @@ export class AgentRuntimeService {
return false;
}
// Group orchestration's skipCallSupervisor / delegate ends the supervisor
// op without another LLM turn: the same CAS gate flips the parked op, but
// the scheduled step finishes it (`finishAfterAsyncTool`) instead of
// re-entering the LLM (`resumeAsyncTool`). Self-describing so the generic
// verify watchdog resolves it correctly: the option (if any) wins, else the
// hint persisted on the parked tool message's pluginState, else resume.
const onComplete: GroupActionOnComplete =
options?.onComplete ?? (await this.resolveAsyncToolOnComplete(pending));
// Single-fire guard: only one concurrent completion flips the op.
const won = await new AgentOperationModel(this.serverDB, this.userId).tryResumeFromAsyncTool(
parentOperationId,
@@ -1796,12 +1736,7 @@ export class AgentRuntimeService {
asyncToolResumeCounter.add(1, { outcome: 'resumed' });
log(
'[%s] won async-tool resume CAS, scheduling step %d (onComplete: %s)',
parentOperationId,
state.stepCount,
onComplete,
);
log('[%s] won async-tool resume CAS, scheduling step %d', parentOperationId, state.stepCount);
if (this.queueService) {
await this.queueService.scheduleMessage({
@@ -1809,8 +1744,7 @@ export class AgentRuntimeService {
delay: 100,
endpoint: `${this.baseURL}/run`,
operationId: parentOperationId,
payload:
onComplete === 'finish' ? { finishAfterAsyncTool: true } : { resumeAsyncTool: true },
payload: { resumeAsyncTool: true },
priority: 'high',
stepIndex: state.stepCount,
});
@@ -1832,7 +1766,7 @@ export class AgentRuntimeService {
* miss (read-replica lag, a sibling dying between backfill and resume) is thus
* retried instead of permanently stranding the parent. Once attempts are
* exhausted the chain stops and the `verify_exhausted` metric fires so the
* orphan is observable. For details see: async sub-agent suspend/resume stability hardening bounded watchdog retry with exponential backoff.
* orphan is observable. See LOBE-10385.
*/
private async maybeScheduleAsyncToolVerify(
parentOperationId: string,
@@ -2001,252 +1935,6 @@ export class AgentRuntimeService {
return true;
}
/**
* Resolve the resume disposition for a parked op from the disposition hint
* persisted on its first pending tool message's pluginState. Group
* orchestration stamps `onComplete: 'finish'` there for skipCallSupervisor /
* delegate; everything else (sub-agents, client tools) resolves to `resume`.
* Self-describing so the generic verify watchdog finishes the right ops.
*/
private async resolveAsyncToolOnComplete(
pending: ChatToolPayload[],
): Promise<GroupActionOnComplete> {
// A batched turn can park multiple deferred/client tools. If ANY of them is
// a group action requesting finish (skipCallSupervisor / delegate), the
// orchestration must finish — reading only pending[0] would miss a group
// finish call that isn't the first pending tool and wrongly resume.
for (const tool of pending) {
const plugin = await this.serverDB.query.messagePlugins.findFirst({
where: (mp, { eq }) => eq(mp.toolCallId, tool.id),
});
const pluginState = plugin?.state as { onComplete?: string } | null;
if (pluginState?.onComplete === 'finish') return 'finish';
}
return 'resume';
}
/**
* Count fulfilled member anchors under a group-management tool call child
* `role: 'tool'` messages whose content is non-empty or whose pluginState is
* terminal. The K=N member barrier for broadcast / executeAgentTasks: the
* group tool message is only backfilled (satisfying the parked op's
* single-tool barrier) once this reaches the expected member count.
*/
private async countFulfilledMemberAnchors(groupToolMessageId: string): Promise<number> {
const children = await this.serverDB.query.messages.findMany({
where: (m, { and, eq }) => and(eq(m.parentId, groupToolMessageId), eq(m.role, 'tool')),
});
let fulfilled = 0;
for (const child of children) {
if (child.content && child.content.length > 0) {
fulfilled += 1;
continue;
}
const plugin = await this.serverDB.query.messagePlugins.findFirst({
where: (mp, { eq }) => eq(mp.id, child.id),
});
const pluginState = plugin?.state as { status?: string } | null;
if (pluginState?.status === 'completed' || pluginState?.status === 'error') fulfilled += 1;
}
return fulfilled;
}
/**
* Completion bridge for the group orchestration "call agent member" path
* (`lobe-group-management`: speak / broadcast / delegate / executeAgentTask(s)).
* Mirrors {@link completeSubAgentBridge} but enforces a K=N member barrier:
*
* 1. Backfill this member's anchor tool message (in_group a short receipt,
* since the member already spoke in the shared group conversation;
* isolated the member's final answer from its hidden thread).
* 2. Multi-member actions: hold until every member anchor is fulfilled, then
* backfill the supervisor's group tool message so the parked op's
* single-tool barrier passes. Single-member actions collapse the anchor
* onto the group tool call, so step 1 already satisfies the barrier.
* 3. Barrier-check + CAS resume/finish the parked supervisor via
* `tryResumeParentFromAsyncTool` (finish disposition read from the group
* tool message's pluginState).
*
* THROWS on infra failure of any backfill so the queue-mode callback returns
* non-2xx and QStash redelivers backfills are idempotent and the resume is
* CAS-guarded, so redelivery is safe.
*/
async completeGroupActionMember(params: GroupActionMemberBridgeParams): Promise<boolean> {
const {
anchorMessageId,
expectedMembers,
groupToolMessageId,
mode,
operationId,
parentOperationId,
reason,
threadId,
} = params;
const failed = reason === 'error' || reason === 'interrupted' || reason === 'timeout';
const finalState =
params.finalState ?? (await this.coordinator.loadAgentState(operationId)) ?? undefined;
log(
'[%s] group-member bridge → parent %s (mode: %s, reason: %s, %d members)',
operationId,
parentOperationId,
mode,
reason,
expectedMembers,
);
// 1. Backfill this member's anchor.
const messages = Array.isArray(finalState?.messages) ? finalState.messages : [];
const lastAssistant = [...messages]
.reverse()
.find((m: { role?: string }) => m?.role === 'assistant');
const agentLabel = (finalState?.metadata?.agentId as string | undefined) ?? 'member';
const anchorContent = failed
? `Agent member did not complete (${reason}).`
: mode === 'in_group'
? `Agent ${agentLabel} responded in the group.`
: (lastAssistant?.content as string | undefined) ||
'Agent member completed without a textual answer.';
const anchorBackfill = await this.messageModel.updateToolMessage(anchorMessageId, {
content: anchorContent,
pluginError: failed ? formatErrorForMetadata(finalState?.error) : undefined,
pluginState: {
model: finalState?.modelRuntimeConfig?.model,
status: failed ? 'error' : 'completed',
threadId,
totalToolCalls: finalState?.usage?.tools?.totalCalls,
totalTokens: finalState?.usage?.llm?.tokens?.total,
},
});
if (!anchorBackfill.success) {
throw new Error(
`Group-member bridge: failed to backfill anchor ${anchorMessageId} for parent ${parentOperationId}`,
);
}
// 2. K=N member barrier (multi-member actions only — single-member actions
// use the group tool call itself as the anchor, already backfilled above).
if (expectedMembers > 1 && anchorMessageId !== groupToolMessageId) {
const fulfilled = await this.countFulfilledMemberAnchors(groupToolMessageId);
if (fulfilled < expectedMembers) {
log(
'[%s] group-member barrier %d/%d, holding parent %s',
operationId,
fulfilled,
expectedMembers,
parentOperationId,
);
const parentState = await this.coordinator.loadAgentState(parentOperationId);
if (parentState) {
await this.maybeScheduleAsyncToolVerify(parentOperationId, parentState, {
scheduleVerifyOnHold: true,
});
}
return false;
}
// All members done — backfill the group tool call so the parked op's
// single-tool barrier ([groupTool]) passes. Idempotent across racing
// last-committers; the resume/finish CAS guarantees one transition.
const groupBackfill = await this.messageModel.updateToolMessage(groupToolMessageId, {
content: `All ${expectedMembers} agent members completed.`,
pluginState: { expectedMembers, status: 'completed' },
});
if (!groupBackfill.success) {
throw new Error(
`Group-member bridge: failed to backfill group tool ${groupToolMessageId} for parent ${parentOperationId}`,
);
}
}
// 3. Barrier + CAS + resume/finish the parked supervisor op.
return this.tryResumeParentFromAsyncTool({ parentOperationId }, { scheduleVerifyOnHold: true });
}
/**
* Schedule the group-member timeout watchdog. Fired `delayMs` after the member
* op is forked; if the member hasn't finished by then, the watchdog interrupts
* it and bridges a `timeout` completion so the parked supervisor doesn't wait
* forever. No-op when the queue is disabled or the timeout is non-positive.
*/
async scheduleGroupMemberTimeout(
params: GroupMemberTimeoutParams,
delayMs: number,
): Promise<void> {
if (!this.queueService || !(delayMs > 0)) return;
try {
await this.queueService.scheduleMessage({
context: undefined,
delay: delayMs,
endpoint: `${this.baseURL}/run`,
// Keyed on the member op so the /run worker can resolve userId from its
// metadata, same trust chain as every other scheduled step.
operationId: params.memberOperationId,
payload: { groupMemberTimeout: params },
priority: 'normal',
stepIndex: 0,
});
log(
'[%s] scheduled group-member timeout in %dms (parent %s)',
params.memberOperationId,
delayMs,
params.parentOperationId,
);
} catch (error) {
log(
'[%s] failed to schedule group-member timeout (non-fatal): %O',
params.memberOperationId,
error,
);
}
}
/**
* Enforce a group member's timeout. No-op if the member already reached a
* terminal state (its own completion bridge handles that). Otherwise interrupt
* the member and bridge a `timeout` completion backfilling its anchor and
* resuming/finishing the parked supervisor via the K=N barrier. The member's
* own interrupt bridge may also fire; both are idempotent (anchor rewrite +
* CAS-guarded resume).
*/
private async handleGroupMemberTimeout(
params: GroupMemberTimeoutParams,
): Promise<AgentExecutionResult> {
const state = await this.coordinator.loadAgentState(params.memberOperationId);
const status = state?.status as string | undefined;
if (!state || status === 'done' || status === 'error' || status === 'interrupted') {
log(
'[%s] group-member timeout: member already terminal (%s), no-op',
params.memberOperationId,
status,
);
return { nextStepScheduled: false, state: {}, success: true };
}
log(
'[%s] group-member timeout fired, interrupting + bridging timeout to parent %s',
params.memberOperationId,
params.parentOperationId,
);
await this.interruptOperation(params.memberOperationId);
const resumed = await this.completeGroupActionMember({
anchorMessageId: params.anchorMessageId,
expectedMembers: params.expectedMembers,
finalState: state,
groupToolMessageId: params.groupToolMessageId,
mode: params.mode,
onComplete: params.onComplete,
operationId: params.memberOperationId,
parentOperationId: params.parentOperationId,
reason: 'timeout',
});
return { nextStepScheduled: resumed, state: {}, success: true };
}
/**
* Reload the conversation messages from the database and flatten them for the
* runtime. Used when resuming a parked op so the next LLM step sees tool
@@ -2380,7 +2068,6 @@ export class AgentRuntimeService {
evalContext: metadata?.evalContext,
execSubAgent: this.delegate.execSubAgent,
execVirtualSubAgent: this.delegate.execVirtualSubAgent,
execGroupMember: this.delegate.execGroupMember,
hookDispatcher,
loadAgentState: this.coordinator.loadAgentState.bind(this.coordinator),
messageModel: this.messageModel,
@@ -3,78 +3,69 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { createDefaultSnapshotStore, shouldUseAgentS3Tracing } from '../snapshotStore';
const s3Store = { kind: 's3' } as any;
const fileStore = { kind: 'file' } as any;
const createS3 = vi.fn(() => s3Store);
const createFile = vi.fn(() => fileStore);
const factories = { createFile, createS3 };
const s3SnapshotStoreMock = vi.fn(() => ({ kind: 's3' }));
const fileSnapshotStoreMock = vi.fn(() => ({ kind: 'file' }));
const setEnv = (nodeEnv: string, agentS3Tracing?: string) => {
vi.stubEnv('NODE_ENV', nodeEnv);
vi.stubEnv('ENABLE_AGENT_S3_TRACING', agentS3Tracing);
};
const loadModule = vi.fn((moduleName: string) => {
if (moduleName === '@/server/modules/AgentTracing') {
return { S3SnapshotStore: s3SnapshotStoreMock };
}
if (moduleName === '@lobechat/agent-tracing') {
return { FileSnapshotStore: fileSnapshotStoreMock };
}
throw new Error(`Unexpected module: ${moduleName}`);
});
describe('agent runtime snapshot store defaults', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
vi.restoreAllMocks();
});
it('enables S3 tracing by default in production when env is unset', () => {
setEnv('production');
expect(shouldUseAgentS3Tracing()).toBe(true);
expect(createDefaultSnapshotStore(factories)).toBe(s3Store);
expect(createS3).toHaveBeenCalledTimes(1);
expect(createFile).not.toHaveBeenCalled();
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
});
it('uses the local file snapshot store in development when env is unset', () => {
setEnv('development');
expect(shouldUseAgentS3Tracing()).toBe(false);
expect(createDefaultSnapshotStore(factories)).toBe(fileStore);
expect(createS3).not.toHaveBeenCalled();
expect(createFile).toHaveBeenCalledTimes(1);
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 'file' });
expect(loadModule).toHaveBeenCalledWith('@lobechat/agent-tracing');
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
expect(fileSnapshotStoreMock).toHaveBeenCalledTimes(1);
});
it('lets ENABLE_AGENT_S3_TRACING=1 force S3 tracing outside production', () => {
setEnv('development', '1');
expect(shouldUseAgentS3Tracing()).toBe(true);
expect(createDefaultSnapshotStore(factories)).toBe(s3Store);
expect(createS3).toHaveBeenCalledTimes(1);
expect(createFile).not.toHaveBeenCalled();
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
});
it('lets an explicit ENABLE_AGENT_S3_TRACING value disable the production default', () => {
setEnv('production', '0');
expect(shouldUseAgentS3Tracing()).toBe(false);
expect(createDefaultSnapshotStore(factories)).toBeNull();
expect(createS3).not.toHaveBeenCalled();
expect(createFile).not.toHaveBeenCalled();
});
it('degrades to null (never throws) when S3 store construction fails', () => {
setEnv('production');
vi.spyOn(console, 'error').mockImplementation(() => {});
const boom = vi.fn(() => {
throw new Error('missing S3 creds');
});
expect(createDefaultSnapshotStore({ createS3: boom })).toBeNull();
expect(boom).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalled();
});
it('constructs a real store via the default (non-injected) path', () => {
// Guards the regression: the default path must build a store with NO dynamic
// require. In dev that is the statically-imported FileSnapshotStore
// (S3 needs creds, so dev is the safe env to assert a non-null default).
setEnv('development');
expect(createDefaultSnapshotStore()).not.toBeNull();
expect(createDefaultSnapshotStore(loadModule)).toBeNull();
expect(loadModule).not.toHaveBeenCalled();
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
});
});
@@ -1,9 +1,20 @@
import { FileSnapshotStore, type ISnapshotStore } from '@lobechat/agent-tracing';
import { S3SnapshotStore } from '@/server/modules/AgentTracing';
import type { ISnapshotStore } from '@lobechat/agent-tracing';
const ENABLE_AGENT_S3_TRACING_VALUE = '1';
type SnapshotStoreConstructor = new () => ISnapshotStore;
type SnapshotStoreModuleLoader = (moduleName: string) => unknown;
interface FileSnapshotStoreModule {
FileSnapshotStore: SnapshotStoreConstructor;
}
interface S3SnapshotStoreModule {
S3SnapshotStore: SnapshotStoreConstructor;
}
const nodeRequire: SnapshotStoreModuleLoader = (moduleName) => require(moduleName);
export const shouldUseAgentS3Tracing = () => {
const explicitValue = process.env.ENABLE_AGENT_S3_TRACING;
@@ -12,18 +23,6 @@ export const shouldUseAgentS3Tracing = () => {
return process.env.NODE_ENV === 'production';
};
/**
* Constructor injection for tests. The defaults are the statically-imported
* stores never load them via a dynamic `require(moduleName)`: the module name
* goes through an indirection the bundler can't statically analyze, so the `@/`
* build-time alias fails to resolve at runtime and the store silently becomes
* `null` (this once disabled ALL production snapshots).
*/
export interface SnapshotStoreFactories {
createFile?: () => ISnapshotStore;
createS3?: () => ISnapshotStore;
}
/**
* Create default snapshot store based on environment.
* - ENABLE_AGENT_S3_TRACING=1 -> S3SnapshotStore
@@ -32,22 +31,28 @@ export interface SnapshotStoreFactories {
* - Otherwise -> null (no tracing)
*/
export const createDefaultSnapshotStore = (
factories: SnapshotStoreFactories = {},
loadModule: SnapshotStoreModuleLoader = nodeRequire,
): ISnapshotStore | null => {
if (shouldUseAgentS3Tracing()) {
try {
return (factories.createS3 ?? (() => new S3SnapshotStore()))();
} catch (e) {
// Tracing is best-effort — a misconfigured S3 (e.g. missing creds) must
// never break the agent run. But surface it loudly: a swallowed failure
// here previously disabled all production snapshots without a trace.
console.error('[snapshotStore] failed to create S3SnapshotStore, tracing disabled:', e);
return null;
const { S3SnapshotStore } = loadModule(
'@/server/modules/AgentTracing',
) as S3SnapshotStoreModule;
return new S3SnapshotStore();
} catch {
// S3SnapshotStore not available
}
}
if (process.env.NODE_ENV === 'development') {
return (factories.createFile ?? (() => new FileSnapshotStore()))();
try {
const { FileSnapshotStore } = loadModule(
'@lobechat/agent-tracing',
) as FileSnapshotStoreModule;
return new FileSnapshotStore();
} catch {
// agent-tracing not available
}
}
return null;
@@ -129,22 +129,6 @@ export interface AgentExecutionParams {
asyncToolVerifyAttempt?: number;
context?: AgentRuntimeContext;
externalRetryCount?: number;
/**
* Finish (rather than resume) a `waiting_for_async_tool` supervisor op after
* its group members have completed. Used by `skipCallSupervisor` / delegate in
* group orchestration: the orchestration ends without another supervisor LLM
* turn. Scheduled by the group-action member barrier via
* `tryResumeParentFromAsyncTool({ onComplete: 'finish' })`.
*/
finishAfterAsyncTool?: boolean;
/**
* Watchdog payload to enforce a group member's timeout: when the member op
* hasn't reached a terminal state by its deadline, interrupt it and bridge a
* `timeout` completion so the parked supervisor resumes/finishes instead of
* waiting forever. Scheduled by `scheduleGroupMemberTimeout` after the member
* op is forked.
*/
groupMemberTimeout?: GroupMemberTimeoutParams;
humanInput?: any;
operationId: string;
/**
@@ -205,106 +189,6 @@ export interface SubAgentBridgeParams {
toolMessageId: string;
}
// ==================== Group Orchestration (call agent member) ====================
/** Whether a group member runs in the shared group session or an isolated thread. */
export type GroupActionMemberMode = 'in_group' | 'isolated';
/** Whether the supervisor resumes or finishes once all members complete. */
export type GroupActionOnComplete = 'resume' | 'finish';
/**
* Params for the group-action member completion bridge see
* `AgentRuntimeService.completeGroupActionMember`. Mirrors the sub-agent bridge
* but enforces a K=N member barrier: each member backfills its own anchor, and
* the supervisor's group tool message is only backfilled (which satisfies the
* parked op's barrier) once every member's anchor is fulfilled.
*/
export interface GroupActionMemberBridgeParams {
/**
* The per-member anchor `role: 'tool'` message to backfill. Equals
* `groupToolMessageId` when `expectedMembers === 1` (single-member actions
* collapse the anchor onto the group tool call itself).
*/
anchorMessageId: string;
/** Total members forked under this group tool call — the K=N barrier target. */
expectedMembers: number;
/** Child member op's final state — passed in local mode; loaded otherwise. */
finalState?: AgentState;
/** The supervisor's parked group-management tool message (`tool_call_id` = call id). */
groupToolMessageId: string;
/** in_group → backfill a short note; isolated → backfill the member's final answer. */
mode: GroupActionMemberMode;
/** Resume the supervisor LLM, or finish the orchestration (skipCallSupervisor/delegate). */
onComplete: GroupActionOnComplete;
/** Child (member) operation ID. */
operationId: string;
parentOperationId: string;
reason: string;
/** Isolation thread id (isolated mode only). */
threadId?: string;
}
/**
* Watchdog payload that enforces a group member's timeout. Scheduled after an
* isolated member op is forked; when it fires, if the member op hasn't reached a
* terminal state it is interrupted and a `timeout` completion is bridged so the
* parked supervisor resumes/finishes (satisfying the K=N barrier) instead of
* waiting indefinitely.
*/
export interface GroupMemberTimeoutParams {
anchorMessageId: string;
expectedMembers: number;
groupToolMessageId: string;
/** The forked member operation id whose deadline this enforces. */
memberOperationId: string;
mode: GroupActionMemberMode;
onComplete: GroupActionOnComplete;
parentOperationId: string;
}
/**
* Params handed to the {@link AgentRuntimeDelegate.execGroupMember} callback
* fork one group member (in-group or isolated) under a group-management tool
* call, installing the group-action member completion bridge.
*/
export interface ExecGroupMemberParams {
/** Member agent id. */
agentId: string;
/** Per-member anchor message id the bridge backfills. */
anchorMessageId: string;
/** Disable tools for this member (broadcast — voice opinions only). */
disableTools?: boolean;
/** K=N barrier target stored on the group tool message. */
expectedMembers: number;
/** Group id. */
groupId: string;
/** Supervisor's group-management tool message id (the parked tool call). */
groupToolMessageId: string;
/** Optional supervisor instruction guiding the member's response. */
instruction?: string;
/** in_group (non-isolated group session) or isolated (own thread). */
mode: GroupActionMemberMode;
/** Resume or finish the supervisor once all members complete. */
onComplete: GroupActionOnComplete;
/** Parent (supervisor) operation id. */
parentOperationId: string;
/** Per-member timeout (ms), isolated mode. */
timeout?: number;
/** Group topic id. */
topicId: string;
}
export interface ExecGroupMemberResult {
error?: string;
/** Forked member operation id (when started). */
operationId?: string;
/** Whether the member op was forked. */
started: boolean;
/** Isolation thread id (isolated mode only). */
threadId?: string;
}
export interface OperationCreationParams {
activeDeviceId?: string;
agentConfig?: any;
@@ -54,17 +54,6 @@ const MEMORY_WRITE_TARGET_BY_API_NAME: Record<string, { idKey: string; layer: La
[MemoryApiName.updateIdentityMemory]: { idKey: 'identityId', layer: LayersEnum.Identity },
};
const TOOL_NAME_SEPARATOR = '____';
const DEFAULT_MEMORY_TARGET_TITLE = 'Memory saved';
const getMemoryWriteApiNameFromToolName = (name: unknown) => {
const toolName = getString(name);
if (!toolName) return;
const slashIndex = toolName.indexOf('/');
const apiName = slashIndex >= 0 ? toolName.slice(slashIndex + 1) : toolName;
return MEMORY_WRITE_API_NAME_SET.has(apiName) ? apiName : undefined;
};
const hasSuccessfulMemoryWrite = (state: AgentState) => {
const byTool = state.usage?.tools?.byTool ?? [];
@@ -83,19 +72,6 @@ const hasFailedMemoryWrite = (state: AgentState) => {
);
};
const getSuccessfulMemoryWriteTargetConfig = (state: AgentState) => {
const byTool = state.usage?.tools?.byTool ?? [];
for (const entry of byTool) {
if (entry.calls <= entry.errors) continue;
const apiName = getMemoryWriteApiNameFromToolName(entry.name);
if (!apiName) continue;
return MEMORY_WRITE_TARGET_BY_API_NAME[apiName];
}
};
const getString = (value: unknown) => {
return pickTrimmedString(value);
};
@@ -281,15 +257,6 @@ export const resolveMemoryActionTargetFromState = (
if (target) return target;
}
}
const targetConfig = getSuccessfulMemoryWriteTargetConfig(state);
if (!targetConfig) return;
return {
memoryLayer: targetConfig.layer,
title: DEFAULT_MEMORY_TARGET_TITLE,
type: 'memory',
};
};
/**
@@ -1,4 +1,3 @@
import { LayersEnum } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import type { AgentSignalOperationMarker } from '@/server/services/agentSignal/operationMarker';
@@ -70,42 +69,6 @@ describe('buildSelfIterationReceipts', () => {
});
});
it('preserves structured memory target metadata for layer-specific navigation', () => {
const [, memory] = buildSelfIterationReceipts({
...baseInput,
mutations: [
{
apiName: 'writeMemory',
data: {
kind: 'mutation',
resourceId: 'pref_1',
status: 'applied',
summary: 'Use concise implementation notes.',
target: {
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
title: 'Prefers concise implementation notes',
type: 'memory',
},
},
kind: 'mutation',
toolCallId: 'call_pref',
},
],
});
expect(memory.title).toBe('Prefers concise implementation notes');
expect(memory.target).toEqual({
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
summary: 'Use concise implementation notes.',
title: 'Prefers concise implementation notes',
type: 'memory',
});
});
it('maps proposal creation to a proposed review receipt without a target', () => {
const [, proposal] = buildSelfIterationReceipts({
...baseInput,
@@ -1,6 +1,5 @@
// @vitest-environment node
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
import { LayersEnum } from '@lobechat/types';
import { describe, expect, it, vi } from 'vitest';
import { createCompletionPolicy } from '../../../../policies/completionPolicy';
@@ -150,18 +149,7 @@ describe('S2 completion loop (policy → handler → projection → persist)', (
mutations: [
{
apiName: 'writeMemory',
data: {
resourceId: 'pref_1',
status: 'applied',
summary: 'Saved tone preference',
target: {
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
title: 'Tone preference',
type: 'memory',
},
},
data: { resourceId: 'mem_1', status: 'applied', summary: 'Saved tone preference' },
kind: 'mutation',
},
],
@@ -181,12 +169,7 @@ describe('S2 completion loop (policy → handler → projection → persist)', (
expect(memory.anchorMessageId).toBe('assistant_msg_1');
expect(memory.triggerMessageId).toBe('user_msg_1');
expect(memory.topicId).toBe('topic_1');
expect(memory.target).toMatchObject({
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
type: 'memory',
});
expect(memory.target).toMatchObject({ id: 'mem_1', type: 'memory' });
});
it('no-ops when the completion carries no self-iteration payload (no marker stamped)', async () => {
@@ -1,5 +1,4 @@
import { MemoryApiName, MemoryIdentifier } from '@lobechat/builtin-tool-memory';
import { LayersEnum } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { extractSelfIterationCompletionPayload } from '../extractCompletionPayload';
@@ -58,7 +57,7 @@ describe('extractSelfIterationCompletionPayload', () => {
expect(result?.artifacts).toHaveLength(1);
});
it('synthesizes a writeMemory mutation with a preference target for a memory-kind run', () => {
it('synthesizes a writeMemory mutation for a memory-kind run from finalState usage', () => {
const result = extractSelfIterationCompletionPayload(
buildState(
{
@@ -67,34 +66,6 @@ describe('extractSelfIterationCompletionPayload', () => {
userId: 'user_1',
},
{
messages: [
{
id: 'msg_preference',
role: 'assistant',
tool_calls: [
{
function: {
arguments: JSON.stringify({
summary: 'Prefer direct implementation with focused tests.',
title: 'Prefers direct implementation',
withPreference: {
conclusionDirectives: 'Prefer direct implementation with focused tests.',
},
}),
name: `${MemoryIdentifier}____${MemoryApiName.addPreferenceMemory}`,
},
id: 'call_preference',
type: 'function',
},
],
},
{
content:
'Preference memory "Prefers direct implementation" saved with memoryId: "mem_1" and preferenceId: "pref_1"',
role: 'tool',
tool_call_id: 'call_preference',
},
],
status: 'finished',
usage: {
tools: {
@@ -116,48 +87,6 @@ describe('extractSelfIterationCompletionPayload', () => {
expect(result?.mutations).toHaveLength(1);
expect(result?.mutations[0].apiName).toBe('writeMemory');
expect((result?.mutations[0].data as { status?: string }).status).toBe('applied');
expect((result?.mutations[0].data as { resourceId?: string }).resourceId).toBe('pref_1');
expect((result?.mutations[0].data as { target?: Record<string, unknown> }).target).toEqual({
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
summary: 'Prefer direct implementation with focused tests.',
title: 'Prefers direct implementation',
type: 'memory',
});
});
it('falls back to the successful memory tool api when finalState lacks tool call details', () => {
const result = extractSelfIterationCompletionPayload(
buildState(
{
agentId: 'agent_user_1',
agentSignal: { kind: 'memory', sourceId: 'mem-src_fallback' },
userId: 'user_1',
},
{
status: 'finished',
usage: {
tools: {
byTool: [
{
calls: 1,
errors: 0,
name: `${MemoryIdentifier}/${MemoryApiName.addPreferenceMemory}`,
},
],
},
},
},
),
);
expect(result?.mutations).toHaveLength(1);
expect((result?.mutations[0].data as { target?: Record<string, unknown> }).target).toEqual({
memoryLayer: LayersEnum.Preference,
title: 'Memory saved',
type: 'memory',
});
});
it('yields no memory mutation when the memory run did not apply a write', () => {
@@ -1,5 +1,3 @@
import { LayersEnum } from '@lobechat/types';
import type { AgentSignalOperationMarker } from '@/server/services/agentSignal/operationMarker';
import type { AgentSignalReceipt } from '../../receiptService';
@@ -55,9 +53,6 @@ const str = (value: unknown): string | undefined =>
const isSkippedStatus = (status: unknown): boolean =>
typeof status === 'string' && status.startsWith('skipped');
const isMemoryLayer = (value: unknown): value is LayersEnum =>
Object.values(LayersEnum).includes(value as LayersEnum);
export interface BuildSelfIterationReceiptsInput {
agentId: string;
/** Non-actionable idea / intent recorder outputs (kind: artifact). */
@@ -156,15 +151,9 @@ export const buildSelfIterationReceipts = (
? 'skipped'
: (SUCCESS_STATUS_BY_API[apiName] ?? 'applied');
const target = isRecord(data.target) ? data.target : undefined;
const targetId = str(target?.id) ?? str(data.resourceId);
const memoryId = kind === 'memory' ? str(target?.memoryId) : undefined;
const memoryLayer =
kind === 'memory' && isMemoryLayer(target?.memoryLayer) ? target.memoryLayer : undefined;
const summaryText = str(data.summary);
const targetTitle = str(target?.title);
const title =
targetTitle ?? summaryText ?? DEFAULT_TITLE_BY_API[apiName] ?? 'Agent Signal action';
const resourceId = str(data.resourceId);
const title = summaryText ?? DEFAULT_TITLE_BY_API[apiName] ?? 'Agent Signal action';
return [
{
@@ -183,9 +172,7 @@ export const buildSelfIterationReceipts = (
? {}
: {
target: {
...(targetId ? { id: targetId } : {}),
...(memoryId ? { memoryId } : {}),
...(memoryLayer ? { memoryLayer } : {}),
...(resourceId ? { id: resourceId } : {}),
...(summaryText ? { summary: summaryText } : {}),
title,
type: kind,
@@ -49,10 +49,9 @@ const extractMemoryMutations = (finalState: AgentState): ToolResultWithKind[] =>
apiName: 'writeMemory',
data: {
kind: 'mutation',
...(result.target ? { target: result.target } : {}),
resourceId: result.target?.id ?? result.target?.memoryId,
status: 'applied',
summary: result.detail ?? result.target?.summary,
summary: result.detail,
},
kind: 'mutation',
},
@@ -180,35 +180,10 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
platform: 'darwin' as const,
};
// Override the agent's agencyConfig and rebuild the service. Auto-activation
// is now exclusive to `executionTarget: 'auto'` — the default (`local`) never
// grabs a device — so the auto-activation specs opt in explicitly.
const useAgencyConfig = async (agencyConfig: Record<string, unknown>) => {
const { AgentService } = await import('@/server/services/agent');
vi.mocked(AgentService).mockImplementation(
() =>
({
getAgentConfig: vi.fn().mockResolvedValue({
agencyConfig,
chatConfig: {},
files: [],
id: 'agent-1',
knowledgeBases: [],
model: 'gpt-4',
plugins: [],
provider: 'openai',
systemRole: 'You are a helpful assistant',
}),
}) as any,
);
service = new AiAgentService(mockDb, userId);
};
describe('IM/Bot scenario with botContext', () => {
it('should auto-activate when exactly one device is online (executionTarget: auto)', async () => {
it('should auto-activate when exactly one device is online', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -227,10 +202,9 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
expect(createOpArgs.activeDeviceId).toBe('device-001');
});
it('should NOT auto-activate when multiple devices are online (executionTarget: auto)', async () => {
it('should NOT auto-activate when multiple devices are online', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice, onlineDevice2]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -249,10 +223,9 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
expect(createOpArgs.activeDeviceId).toBeUndefined();
});
it('should NOT auto-activate when no devices are online (executionTarget: auto)', async () => {
it('should NOT auto-activate when no devices are online', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -270,35 +243,12 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
const createOpArgs = mockCreateOperation.mock.calls[0][0];
expect(createOpArgs.activeDeviceId).toBeUndefined();
});
it('should NOT auto-activate the single online device by default (executionTarget unset → local)', async () => {
// The default mode never grabs a device — only explicit `auto` does.
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
await useAgencyConfig({}); // unset executionTarget → default `local`
await service.execAgent({
agentId: 'agent-1',
botContext: {
applicationId: 'app-1',
isOwner: true,
platform: 'discord',
platformThreadId: 'discord:guild-1:channel-1',
senderExternalUserId: 'owner-id',
} as any,
prompt: 'List my files',
});
const createOpArgs = mockCreateOperation.mock.calls[0][0];
expect(createOpArgs.activeDeviceId).toBeUndefined();
});
});
describe('IM/Bot scenario with discordContext', () => {
it('should auto-activate when exactly one device is online (executionTarget: auto)', async () => {
it('should auto-activate when exactly one device is online', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -313,15 +263,16 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
});
describe('Web UI scenario (no botContext/discordContext)', () => {
// In `auto` mode a single online device is activated up-front, so the
// local-system system prompt's {{workingDirectory}} / {{hostname}}
// placeholders resolve instead of reaching the LLM as literals. Multi-device
// users still pick explicitly (the model selects via the remote-device
// tool). The default mode never auto-activates.
it('should auto-activate the only online device (executionTarget: auto)', async () => {
// regular chat used to leave activeDeviceId undefined when no
// device was bound, which caused the local-system system prompt's
// {{workingDirectory}} / {{hostname}} placeholders to reach the LLM as
// literals. The model would then waste the first N steps groping for cwd.
// Now we auto-activate when exactly one device is online — multi-device
// users still need to bind explicitly, since picking one by recency
// would be a guess that could route tool calls to the wrong machine.
it('should auto-activate the only online device', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -333,10 +284,9 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
expect(createOpArgs.activeDeviceId).toBe('device-001');
});
it('should NOT auto-activate when multiple devices are online (executionTarget: auto)', async () => {
it('should NOT auto-activate when multiple devices are online', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice, onlineDevice2]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -347,24 +297,9 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
expect(createOpArgs.activeDeviceId).toBeUndefined();
});
it('should NOT auto-activate when no devices are online (executionTarget: auto)', async () => {
it('should NOT auto-activate when no devices are online', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
prompt: 'List my files',
});
const createOpArgs = mockCreateOperation.mock.calls[0][0];
expect(createOpArgs.activeDeviceId).toBeUndefined();
});
it('should NOT auto-activate the single online device by default (unset → local)', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
await useAgencyConfig({}); // unset executionTarget → default `local`
await service.execAgent({
agentId: 'agent-1',
@@ -547,16 +482,33 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
});
// Verifies topic-stored metadata.boundDeviceId is NOT silently reused as
// the runtime bound device. Setup: `auto` mode, topic.metadata says
// device-002, but the only online device is device-001. If the topic
// metadata were reused as boundDeviceId, activeDeviceId would be undefined
// (device-002 is offline). Auto-activation instead picks the single online
// the runtime bound device. Setup: topic.metadata says device-002, but the
// only online device is device-001. If the topic metadata were reused as
// boundDeviceId, activeDeviceId would be undefined (device-002 is offline).
// After auto-activate, we instead pick the most-recent online
// device (device-001) — proving the topic's stale metadata wasn't honored.
it('should not reuse topic boundDeviceId when no explicit deviceId is provided', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
topicMock.findById.mockResolvedValue({ metadata: { boundDeviceId: 'device-002' } });
await useAgencyConfig({ executionTarget: 'auto' });
const { AgentService } = await import('@/server/services/agent');
vi.mocked(AgentService).mockImplementation(
() =>
({
getAgentConfig: vi.fn().mockResolvedValue({
chatConfig: {},
files: [],
id: 'agent-1',
knowledgeBases: [],
model: 'gpt-4',
plugins: [],
provider: 'openai',
systemRole: 'You are a helpful assistant',
}),
}) as any,
);
service = new AiAgentService(mockDb, userId);
await service.execAgent({
agentId: 'agent-1',
@@ -633,11 +585,11 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
});
// Mirrors the "should not reuse topic boundDeviceId" test above with a
// different mock shape. `auto` mode, topic metadata stores device-002, but
// only device-001 is online; if topic metadata leaked into boundDeviceId,
// different mock shape. Topic metadata stores device-002, but only
// device-001 is online; if topic metadata leaked into boundDeviceId,
// activeDeviceId would be undefined (since device-002 is offline). The
// auto-activation picks device-001 instead, confirming the stale
// topic.metadata.boundDeviceId path is dead.
// post-auto-activate picks device-001 instead, confirming the
// stale topic.metadata.boundDeviceId path is dead.
it('should not reuse topic metadata bound device when no deviceId is supplied', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
@@ -645,7 +597,6 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
id: 'topic-1',
metadata: { boundDeviceId: 'device-002' },
});
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -684,10 +635,27 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
});
describe('Remote Device tool injection when device is auto-activated', () => {
it('should mark autoActivated when single device is auto-activated (IM/Bot, executionTarget: auto)', async () => {
it('should mark autoActivated when single device is auto-activated (IM/Bot)', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
await useAgencyConfig({ executionTarget: 'auto' });
const { AgentService } = await import('@/server/services/agent');
vi.mocked(AgentService).mockImplementation(
() =>
({
getAgentConfig: vi.fn().mockResolvedValue({
chatConfig: {},
files: [],
id: 'agent-1',
knowledgeBases: [],
model: 'gpt-4',
plugins: [],
provider: 'openai',
systemRole: 'You are a helpful assistant',
}),
}) as any,
);
service = new AiAgentService(mockDb, userId);
await service.execAgent({
agentId: 'agent-1',
@@ -215,9 +215,7 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
{ deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' },
]);
mockGetAgentConfig.mockResolvedValue(
createBaseAgentConfig({ agencyConfig: { executionTarget: 'auto' } }),
);
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
@@ -424,9 +424,8 @@ describe('AiAgentService.execAgent - file upload handling', () => {
expect(mockCreateOperation).toHaveBeenCalled();
const userMessageCall = mockMessageCreate.mock.calls.find((call) => call[0].role === 'user');
// all uploads failed → no fileIds, normalized to undefined (no empty
// messagesFiles relation attached)
expect(userMessageCall![0].files).toBeUndefined();
// files array is empty since upload failed, so should be undefined-ish
expect(userMessageCall![0].files).toEqual([]);
});
});
@@ -2,33 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AiAgentService } from '../index';
const {
mockMessageCreate,
mockResolveAttachmentsByFileIds,
mockSpawnHeteroSandbox,
mockIngestAttachment,
} = vi.hoisted(() => ({
mockIngestAttachment: vi.fn(),
mockMessageCreate: vi.fn(),
mockResolveAttachmentsByFileIds: vi.fn(),
mockSpawnHeteroSandbox: vi.fn().mockResolvedValue(undefined),
}));
const emptyResolvedAttachments = {
fileList: [],
imageList: [],
orderedFileIds: [],
videoList: [],
warnings: [],
};
vi.mock('@/server/services/file', () => ({
FileService: vi.fn().mockImplementation(() => ({})),
}));
vi.mock('../ingestAttachment', () => ({
ingestAttachment: mockIngestAttachment,
}));
const { mockMessageCreate, mockResolveAttachmentMetadata, mockSpawnHeteroSandbox } = vi.hoisted(
() => ({
mockMessageCreate: vi.fn(),
mockResolveAttachmentMetadata: vi.fn(),
mockSpawnHeteroSandbox: vi.fn().mockResolvedValue(undefined),
}),
);
vi.mock('@/libs/trusted-client', () => ({
generateTrustedClientToken: vi.fn().mockReturnValue(undefined),
@@ -120,13 +100,14 @@ vi.mock('@/server/services/heterogeneousAgent/sandboxRunner', () => ({
}));
vi.mock('@/server/services/file/resolveAttachments', () => ({
resolveAttachmentsByFileIds: mockResolveAttachmentsByFileIds,
}));
vi.mock('@/server/services/document', () => ({
DocumentService: vi.fn().mockImplementation(() => ({
parseFile: vi.fn().mockResolvedValue({ content: '' }),
})),
resolveAttachmentMetadata: mockResolveAttachmentMetadata,
resolveAttachmentsByFileIds: vi.fn().mockResolvedValue({
fileList: [],
imageList: [],
orderedFileIds: [],
videoList: [],
warnings: [],
}),
}));
vi.mock('@/server/services/agentRuntime', () => ({
@@ -166,9 +147,8 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
topicMock.findById.mockResolvedValue(undefined);
topicMock.updateMetadata.mockResolvedValue(undefined);
mockMessageCreate.mockResolvedValue({ id: 'msg-1' });
mockResolveAttachmentsByFileIds.mockResolvedValue({ ...emptyResolvedAttachments });
mockResolveAttachmentMetadata.mockResolvedValue([]);
mockSpawnHeteroSandbox.mockResolvedValue(undefined);
mockIngestAttachment.mockReset();
service = new AiAgentService(mockDb, userId);
});
@@ -185,11 +165,6 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
// without `files`, so images attached in device mode were never linked
// via messagesFiles and disappeared after the optimistic message was
// replaced by the server snapshot.
mockResolveAttachmentsByFileIds.mockResolvedValue({
...emptyResolvedAttachments,
orderedFileIds: ['file-1', 'file-2'],
});
await service.execAgent({
agentId: 'agent-1',
fileIds: ['file-1', 'file-2'],
@@ -201,23 +176,13 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
expect(userCall![0].files).toEqual(['file-1', 'file-2']);
});
it('should attach the resolver-deduped fileIds (dedup lives in resolveAttachmentsByFileIds)', async () => {
// resolveAttachmentsByFileIds dedupes internally and returns orderedFileIds;
// execAgent attaches exactly what it returns (messagesFiles PK is fileId+messageId).
mockResolveAttachmentsByFileIds.mockResolvedValue({
...emptyResolvedAttachments,
orderedFileIds: ['file-1', 'file-2'],
});
it('should dedupe repeated fileIds (messagesFiles PK is fileId+messageId)', async () => {
await service.execAgent({
agentId: 'agent-1',
fileIds: ['file-1', 'file-1', 'file-2'],
prompt: 'Look at this image',
});
expect(mockResolveAttachmentsByFileIds).toHaveBeenCalledWith(
expect.objectContaining({ fileIds: ['file-1', 'file-1', 'file-2'] }),
);
const userCall = findUserMessageCreate();
expect(userCall![0].files).toEqual(['file-1', 'file-2']);
});
@@ -246,21 +211,22 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
describe('image delivery to the dispatched CLI', () => {
it('should resolve image attachments and pass imageList to the sandbox dispatch', async () => {
mockResolveAttachmentsByFileIds.mockResolvedValue({
...emptyResolvedAttachments,
fileList: [
{
content: '',
fileType: 'application/pdf',
id: 'file-2',
name: 'doc.pdf',
size: 200,
url: 'https://signed/file-2.pdf',
},
],
imageList: [{ alt: 'screenshot.png', id: 'file-1', url: 'https://signed/file-1.png' }],
orderedFileIds: ['file-1', 'file-2'],
});
mockResolveAttachmentMetadata.mockResolvedValue([
{
fileType: 'image/png',
id: 'file-1',
name: 'screenshot.png',
size: 100,
url: 'https://signed/file-1.png',
},
{
fileType: 'application/pdf',
id: 'file-2',
name: 'doc.pdf',
size: 200,
url: 'https://signed/file-2.pdf',
},
]);
await service.execAgent({
agentId: 'agent-1',
@@ -276,20 +242,15 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
});
it('should pass imageList undefined when attachments contain no images', async () => {
mockResolveAttachmentsByFileIds.mockResolvedValue({
...emptyResolvedAttachments,
fileList: [
{
content: '',
fileType: 'application/pdf',
id: 'file-2',
name: 'doc.pdf',
size: 200,
url: 'https://signed/file-2.pdf',
},
],
orderedFileIds: ['file-2'],
});
mockResolveAttachmentMetadata.mockResolvedValue([
{
fileType: 'application/pdf',
id: 'file-2',
name: 'doc.pdf',
size: 200,
url: 'https://signed/file-2.pdf',
},
]);
await service.execAgent({
agentId: 'agent-1',
@@ -303,7 +264,7 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
});
it('should not block the run when attachment resolution fails', async () => {
mockResolveAttachmentsByFileIds.mockRejectedValue(new Error('S3 down'));
mockResolveAttachmentMetadata.mockRejectedValue(new Error('S3 down'));
const result = await service.execAgent({
agentId: 'agent-1',
@@ -326,90 +287,7 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
prompt: 'No attachments here',
});
expect(mockResolveAttachmentsByFileIds).not.toHaveBeenCalled();
});
});
describe('raw bot/IM file ingestion (files param)', () => {
// regression: bot/IM channels deliver attachments as raw `files` buffers
// (not pre-uploaded `fileIds`). The hetero branch returns before the main
// ingestion block, so images sent through a bot were silently dropped and
// the CLI received text only.
it('should ingest raw files, attach them to the user message and forward images', async () => {
mockIngestAttachment.mockResolvedValue({
fileId: 'uploaded-1',
isImage: true,
isVideo: false,
resolvedUrl: 'https://signed/uploaded-1.png',
});
await service.execAgent({
agentId: 'agent-1',
files: [{ mimeType: 'image/png', name: 'shot.png', url: 'https://im/shot.png' }],
prompt: 'What is this image?',
});
expect(mockIngestAttachment).toHaveBeenCalledTimes(1);
const userCall = findUserMessageCreate();
expect(userCall![0].files).toEqual(['uploaded-1']);
expect(mockSpawnHeteroSandbox).toHaveBeenCalledWith(
expect.objectContaining({
imageList: [{ id: 'uploaded-1', url: 'https://signed/uploaded-1.png' }],
}),
);
});
it('should merge ingested files with pre-uploaded fileIds (both images forwarded)', async () => {
mockIngestAttachment.mockResolvedValue({
fileId: 'uploaded-1',
isImage: true,
isVideo: false,
resolvedUrl: 'https://signed/uploaded-1.png',
});
mockResolveAttachmentsByFileIds.mockResolvedValue({
...emptyResolvedAttachments,
imageList: [{ alt: 'pre.jpg', id: 'file-1', url: 'https://signed/file-1.jpg' }],
orderedFileIds: ['file-1'],
});
await service.execAgent({
agentId: 'agent-1',
fileIds: ['file-1'],
files: [{ mimeType: 'image/png', name: 'shot.png', url: 'https://im/shot.png' }],
prompt: 'Compare these images',
});
// Raw `files` are ingested first, then pre-uploaded `attachedFileIds`.
const userCall = findUserMessageCreate();
expect(userCall![0].files).toEqual(['uploaded-1', 'file-1']);
expect(mockSpawnHeteroSandbox).toHaveBeenCalledWith(
expect.objectContaining({
imageList: [
{ id: 'uploaded-1', url: 'https://signed/uploaded-1.png' },
{ id: 'file-1', url: 'https://signed/file-1.jpg' },
],
}),
);
});
it('should not block the run when a raw file fails to ingest', async () => {
mockIngestAttachment.mockRejectedValue(new Error('S3 down'));
const result = await service.execAgent({
agentId: 'agent-1',
files: [{ mimeType: 'image/png', name: 'shot.png', url: 'https://im/shot.png' }],
prompt: 'What is this image?',
});
expect(result.success).toBe(true);
const userCall = findUserMessageCreate();
expect(userCall![0].files).toBeUndefined();
expect(mockSpawnHeteroSandbox).toHaveBeenCalledWith(
expect.objectContaining({ imageList: undefined }),
);
expect(mockResolveAttachmentMetadata).not.toHaveBeenCalled();
});
});
});
File diff suppressed because it is too large Load Diff
@@ -33,7 +33,6 @@ export interface AttachmentSource {
export interface IngestResult {
fileId: string;
isAudio: boolean;
isImage: boolean;
isVideo: boolean;
key: string;
@@ -150,17 +149,12 @@ export async function ingestAttachment(
// MessageContentProcessor can pass the video to vision/video-capable models.
const isVideo = !isImage && mimeType.startsWith('video/');
// Audio is passed through untouched; audio-capable models (e.g. Gemini) receive
// it as an inline/file media part instead of being parsed into document text.
const isAudio = !isImage && !isVideo && mimeType.startsWith('audio/');
log(
'ingestAttachment: classified name=%s, finalMimeType=%s, isImage=%s, isVideo=%s, isAudio=%s, bufferSize=%d',
'ingestAttachment: classified name=%s, finalMimeType=%s, isImage=%s, isVideo=%s, bufferSize=%d',
source.name,
mimeType,
isImage,
isVideo,
isAudio,
buffer.length,
);
@@ -170,11 +164,9 @@ export async function ingestAttachment(
const pathname = `files/${userId}/${nanoid()}/${source.name || `file.${ext}`}`;
const { fileId, key } = await fileService.uploadFromBuffer(buffer, mimeType, pathname);
// 5. Resolve access URL for images, videos and audio.
// 5. Resolve access URL for images and videos.
const resolvedUrl =
isImage || isVideo || isAudio
? await fileService.getFileAccessUrl({ id: fileId, url: key })
: '';
isImage || isVideo ? await fileService.getFileAccessUrl({ id: fileId, url: key }) : '';
log(
'ingestAttachment: uploaded fileId=%s, key=%s, resolvedUrl=%s',
@@ -183,5 +175,5 @@ export async function ingestAttachment(
resolvedUrl ? 'set' : '(empty)',
);
return { fileId, isAudio, isImage, isVideo, key, resolvedUrl };
return { fileId, isImage, isVideo, key, resolvedUrl };
}
@@ -769,148 +769,6 @@ describe('DeviceGateway', () => {
});
});
describe('file mutation containment', () => {
const configure = () => {
mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com';
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
};
describe('writeProjectFile', () => {
it('invokes the rpc when the path is inside the workspace', async () => {
configure();
mockClient.invokeRpc.mockResolvedValue({ data: { success: true }, success: true });
const proxy = new DeviceGateway();
const result = await proxy.writeProjectFile({
content: 'next',
deviceId: 'dev-1',
path: '/proj/src/App.tsx',
userId: 'user-1',
workingDirectory: '/proj',
});
expect(result).toEqual({ success: true });
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
{ method: 'writeLocalFile', params: { content: 'next', path: '/proj/src/App.tsx' } },
);
});
it('throws without invoking the rpc when the path escapes the workspace', async () => {
configure();
const proxy = new DeviceGateway();
await expect(
proxy.writeProjectFile({
content: 'pwned',
deviceId: 'dev-1',
path: '/etc/passwd',
userId: 'user-1',
workingDirectory: '/proj',
}),
).rejects.toThrow(/outside the approved workspace/);
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
});
it('rejects a `..` traversal that resolves outside the workspace', async () => {
configure();
const proxy = new DeviceGateway();
await expect(
proxy.writeProjectFile({
content: 'pwned',
deviceId: 'dev-1',
path: '/proj/../secrets.env',
userId: 'user-1',
workingDirectory: '/proj',
}),
).rejects.toThrow(/outside the approved workspace/);
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
});
it('contains Windows device paths using Windows path semantics', async () => {
configure();
const proxy = new DeviceGateway();
await expect(
proxy.writeProjectFile({
content: 'pwned',
deviceId: 'dev-1',
path: 'C:\\Windows\\System32\\drivers\\etc\\hosts',
userId: 'user-1',
workingDirectory: 'C:\\proj',
}),
).rejects.toThrow(/outside the approved workspace/);
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
});
});
describe('renameProjectFile', () => {
it('throws without invoking the rpc when the path escapes the workspace', async () => {
configure();
const proxy = new DeviceGateway();
await expect(
proxy.renameProjectFile({
deviceId: 'dev-1',
newName: 'evil.ts',
path: '/etc/hosts',
userId: 'user-1',
workingDirectory: '/proj',
}),
).rejects.toThrow(/outside the approved workspace/);
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
});
});
describe('moveProjectFiles', () => {
it('throws when any item moves out of the workspace', async () => {
configure();
const proxy = new DeviceGateway();
await expect(
proxy.moveProjectFiles({
deviceId: 'dev-1',
items: [
{ newPath: '/proj/b.ts', oldPath: '/proj/a.ts' },
{ newPath: '/tmp/exfil.ts', oldPath: '/proj/c.ts' },
],
userId: 'user-1',
workingDirectory: '/proj',
}),
).rejects.toThrow(/outside the approved workspace/);
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
});
it('invokes the rpc when every item stays inside the workspace', async () => {
configure();
mockClient.invokeRpc.mockResolvedValue({
data: [{ newPath: '/proj/b.ts', sourcePath: '/proj/a.ts', success: true }],
success: true,
});
const proxy = new DeviceGateway();
const result = await proxy.moveProjectFiles({
deviceId: 'dev-1',
items: [{ newPath: '/proj/b.ts', oldPath: '/proj/a.ts' }],
userId: 'user-1',
workingDirectory: '/proj',
});
expect(result).toEqual([
{ newPath: '/proj/b.ts', sourcePath: '/proj/a.ts', success: true },
]);
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
{
method: 'moveLocalFiles',
params: { items: [{ newPath: '/proj/b.ts', oldPath: '/proj/a.ts' }] },
},
);
});
});
});
describe('getClient (lazy initialization)', () => {
it('should return null when URL is missing', async () => {
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
@@ -1,5 +1,3 @@
import path from 'node:path';
import { type DeviceAttachment } from '@lobechat/builtin-tool-remote-device';
import {
type DeviceMessageApiResult,
@@ -25,14 +23,9 @@ import type {
DeviceGitWorkingTreeFiles,
DeviceGitWorkingTreePatches,
DeviceGitWorkingTreeStatus,
DeviceGitWorktreeListItem,
DeviceListProjectSkillsResult,
DeviceLocalFilePreviewResult,
DeviceMoveProjectFileItem,
DeviceMoveProjectFileResultItem,
DeviceProjectFileIndexResult,
DeviceRenameProjectFileResult,
DeviceWriteProjectFileResult,
ProjectSkillMeta,
WorkspaceInitResult,
} from '@lobechat/types';
@@ -42,42 +35,6 @@ import { gatewayEnv } from '@/envs/gateway';
const log = debug('lobe-server:device-gateway');
/**
* Is `target` the same as, or nested inside, `root`?
*
* The device's working directory may be a POSIX path (`/Users/…`) or a Windows
* path (`C:\`) while this check runs on the cloud server (POSIX). We pick the
* path flavour from the root's shape so a Windows device path is still resolved
* with Windows semantics rather than being mangled by `path.posix`.
*/
export const isPathWithinRoot = (root: string, target: string): boolean => {
const p = /^[A-Z]:[/\\]/i.test(root) ? path.win32 : path.posix;
if (!p.isAbsolute(root) || !p.isAbsolute(target)) return false;
const relative = p.relative(p.resolve(root), p.resolve(target));
return relative === '' || (!relative.startsWith('..') && !p.isAbsolute(relative));
};
/**
* Guard the web/remote file mutations (move / rename / write) against escaping
* the project root. These routes accept absolute paths straight from an
* untrusted browser session, so before forwarding them to a device we confirm
* every path stays inside the workspace the UI is operating in otherwise a
* caller could bypass the Files tree and mutate arbitrary locations on the
* device. Mirrors the read path's `workspaceRoot` containment check.
*/
const assertPathsWithinWorkspace = (
workspaceRoot: string,
candidates: Array<string | undefined>,
): void => {
if (!workspaceRoot) throw new Error('A workspace root is required for file mutations');
for (const candidate of candidates) {
if (!candidate || !isPathWithinRoot(workspaceRoot, candidate)) {
throw new Error(`Path is outside the approved workspace: ${candidate ?? '(empty)'}`);
}
}
};
export type { DeviceAttachment, DeviceStatusResult, DeviceSystemInfo };
export class DeviceGateway {
@@ -250,13 +207,6 @@ export class DeviceGateway {
});
}
/** Git worktrees attached to the same repository as a directory on a remote device. */
listGitWorktrees(params: { deviceId: string; path: string; userId: string }) {
return this.invokeGitRead<DeviceGitWorktreeListItem[]>('listGitWorktrees', params, {
path: params.path,
});
}
/**
* List the local branches of a directory on a remote device via the
* `listGitBranches` device RPC, so the web/remote branch switcher can populate
@@ -725,108 +675,6 @@ export class DeviceGateway {
}
}
/**
* Move one or more files/folders within a directory on a remote device, via
* the device's `moveLocalFiles` RPC. Powers the Files tree's move in device
* mode. Unlike the read RPCs this is a user-initiated mutation, so a missing
* gateway / offline device / failed call throws rather than degrading to
* `undefined` the UI surfaces the error instead of silently no-op'ing.
*/
async moveProjectFiles(params: {
deviceId: string;
items: DeviceMoveProjectFileItem[];
timeout?: number;
userId: string;
workingDirectory: string;
}): Promise<DeviceMoveProjectFileResultItem[]> {
const { userId, deviceId, items, workingDirectory, timeout = 30_000 } = params;
const client = this.getClient();
if (!client) throw new Error('Device gateway not configured');
assertPathsWithinWorkspace(
workingDirectory,
items.flatMap((item) => [item.oldPath, item.newPath]),
);
const result = await client.invokeRpc<DeviceMoveProjectFileResultItem[]>(
{ deviceId, timeout, userId },
{ method: 'moveLocalFiles', params: { items } },
);
if (!result.success || !result.data) {
log('moveProjectFiles: failed for deviceId=%s — %s', deviceId, result.error);
throw new Error(result.error || 'Move failed');
}
return result.data;
}
/**
* Rename a single file/folder in a directory on a remote device, via the
* device's `renameLocalFile` RPC. Like `moveProjectFiles`, a transport failure
* throws rather than degrading silently.
*/
async renameProjectFile(params: {
deviceId: string;
newName: string;
path: string;
timeout?: number;
userId: string;
workingDirectory: string;
}): Promise<DeviceRenameProjectFileResult> {
const { userId, deviceId, path, newName, workingDirectory, timeout = 30_000 } = params;
const client = this.getClient();
if (!client) throw new Error('Device gateway not configured');
// The rename stays in the same directory (the device rejects separators in
// `newName`), so containing the source path also contains the target.
assertPathsWithinWorkspace(workingDirectory, [path]);
const result = await client.invokeRpc<DeviceRenameProjectFileResult>(
{ deviceId, timeout, userId },
{ method: 'renameLocalFile', params: { newName, path } },
);
if (!result.success || !result.data) {
log('renameProjectFile: failed for deviceId=%s — %s', deviceId, result.error);
throw new Error(result.error || 'Rename failed');
}
return result.data;
}
/**
* Save edited content back to a file on a remote device, via the device's
* `writeLocalFile` RPC. Powers remote save in the LocalFile editor. Like the
* other file mutations, a transport failure throws rather than degrading.
*/
async writeProjectFile(params: {
content: string;
deviceId: string;
path: string;
timeout?: number;
userId: string;
workingDirectory: string;
}): Promise<DeviceWriteProjectFileResult> {
const { userId, deviceId, path, content, workingDirectory, timeout = 30_000 } = params;
const client = this.getClient();
if (!client) throw new Error('Device gateway not configured');
assertPathsWithinWorkspace(workingDirectory, [path]);
const result = await client.invokeRpc<DeviceWriteProjectFileResult>(
{ deviceId, timeout, userId },
{ method: 'writeLocalFile', params: { content, path } },
);
if (!result.success || !result.data) {
log('writeProjectFile: failed for deviceId=%s — %s', deviceId, result.error);
throw new Error(result.error || 'Write failed');
}
return result.data;
}
/**
* Check whether a path exists on the device and is a directory, via the same
* generic `invokeRpc` channel as `gitInfo`. Lets a web / remote client

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