mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-19 22:00:34 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0cb91740e |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 / 1k–10k rows.** Pick the right
|
||||
mechanism per range — plain render → load-more / pagination → virtual scroll;
|
||||
add batch-select / bulk actions once counts get large. _(Certainty)_
|
||||
- [ ] **Co-design empty / loading / error with the data state** (see §1.1). A list
|
||||
isn't done until all four render well. _(Natural)_
|
||||
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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,31 +2,6 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## [Version 2.2.6](https://github.com/lobehub/lobe-chat/compare/v2.2.6-canary.8...v2.2.6)
|
||||
|
||||
<sup>Released on **2026-06-17**</sup>
|
||||
|
||||
#### ✨ Features
|
||||
|
||||
- **agent**: improve connector, document, and fleet workflows.
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Improvements and Fixes</kbd></summary>
|
||||
|
||||
#### What's improved
|
||||
|
||||
- **agent**: improve connector, document, and fleet workflows, closes [#15936](https://github.com/lobehub/lobe-chat/issues/15936) ([3f82033](https://github.com/lobehub/lobe-chat/commit/3f82033))
|
||||
|
||||
</details>
|
||||
|
||||
<div align="right">
|
||||
|
||||
[](#readme-top)
|
||||
|
||||
</div>
|
||||
|
||||
## [Version 2.2.1](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr15228.13999...v2.2.1)
|
||||
|
||||
<sup>Released on **2026-05-29**</sup>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -80,40 +77,6 @@ describe('lh file - E2E', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── upload (local file) ───────────────────────────────
|
||||
|
||||
describe('upload', () => {
|
||||
it('should upload a local file passed as a positional argument', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello from lh e2e upload');
|
||||
|
||||
try {
|
||||
const result = runJson<{ id: string }>(`file upload ${tmpFile} --json id`);
|
||||
expect(result).toHaveProperty('id');
|
||||
if (result.id) run(`file delete ${result.id} --yes`);
|
||||
} finally {
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should upload a local file passed via --file', () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-f-${Date.now()}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello from lh e2e --file upload');
|
||||
|
||||
try {
|
||||
const result = runJson<{ id: string }>(`file upload --file ${tmpFile} --json id`);
|
||||
expect(result).toHaveProperty('id');
|
||||
if (result.id) run(`file delete ${result.id} --yes`);
|
||||
} finally {
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should error when the local file does not exist', () => {
|
||||
expect(() => run('file upload -f /no/such/lh-file.txt')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── recent ────────────────────────────────────────────
|
||||
|
||||
describe('recent', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
|
||||
.\" Manual command details come from the Commander command tree.
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.32" "User Commands"
|
||||
.TH LH 1 "" "@lobehub/cli 0.0.29" "User Commands"
|
||||
.SH NAME
|
||||
lh \- LobeHub CLI \- manage and connect to LobeHub services
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.32",
|
||||
"version": "0.0.29",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -21,9 +17,6 @@ const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
removeFiles: { mutate: vi.fn() },
|
||||
updateFile: { mutate: vi.fn() },
|
||||
},
|
||||
upload: {
|
||||
createS3PreSignedUrl: { mutate: vi.fn() },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -45,11 +38,9 @@ describe('file command', () => {
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
|
||||
for (const group of [mockTrpcClient.file, mockTrpcClient.upload]) {
|
||||
for (const method of Object.values(group)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
for (const method of Object.values(mockTrpcClient.file)) {
|
||||
for (const fn of Object.values(method)) {
|
||||
(fn as ReturnType<typeof vi.fn>).mockReset();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -214,111 +205,6 @@ describe('file command', () => {
|
||||
expect(mockTrpcClient.file.createFile.mutate).not.toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
|
||||
});
|
||||
|
||||
it('should upload a local file passed as a positional argument', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-${process.pid}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'hello world');
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
|
||||
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({
|
||||
id: 'f-local',
|
||||
url: 'files/x.txt',
|
||||
});
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
|
||||
|
||||
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://s3/presigned',
|
||||
expect.objectContaining({ method: 'PUT' }),
|
||||
);
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileType: 'text/plain',
|
||||
name: path.basename(tmpFile),
|
||||
url: expect.stringContaining('.txt'),
|
||||
}),
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('File created'));
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should upload a local file passed via --file', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-f-${process.pid}.json`);
|
||||
fs.writeFileSync(tmpFile, '{}');
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
|
||||
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-json' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', '--file', tmpFile]);
|
||||
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileType: 'application/json' }),
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should skip the S3 upload when the local file hash already exists', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), `lh-upload-dedup-${process.pid}.txt`);
|
||||
fs.writeFileSync(tmpFile, 'dedup me');
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({
|
||||
isExist: true,
|
||||
url: 'files/2024-01-01/existing.txt',
|
||||
});
|
||||
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-dedup' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
|
||||
|
||||
// No pre-sign and no S3 PUT should happen
|
||||
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
// The record reuses the existing url
|
||||
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: 'files/2024-01-01/existing.txt' }),
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
fs.rmSync(tmpFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should error when local file does not exist', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload', '-f', '/no/such/file.txt']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('File not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should error when no source is provided', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'file', 'upload']);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Provide a local file path'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import pc from 'picocolors';
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
import { uploadLocalFile } from '../utils/uploadLocalFile';
|
||||
|
||||
export function registerFileCommand(program: Command) {
|
||||
const file = program.command('file').description('Manage files');
|
||||
@@ -114,20 +113,18 @@ export function registerFileCommand(program: Command) {
|
||||
// ── upload ───────────────────────────────────────────
|
||||
|
||||
file
|
||||
.command('upload [source]')
|
||||
.description('Upload a file from a local path or a URL')
|
||||
.option('-f, --file <path>', 'Local file path to upload')
|
||||
.option('--hash <hash>', 'File hash for deduplication check (URL mode)')
|
||||
.option('--name <name>', 'File name (URL mode)')
|
||||
.option('--type <type>', 'File MIME type (URL mode)')
|
||||
.option('--size <size>', 'File size in bytes (URL mode)')
|
||||
.command('upload <url>')
|
||||
.description('Upload a file by URL (checks hash first)')
|
||||
.option('--hash <hash>', 'File hash for deduplication check')
|
||||
.option('--name <name>', 'File name')
|
||||
.option('--type <type>', 'File MIME type')
|
||||
.option('--size <size>', 'File size in bytes')
|
||||
.option('--parent-id <id>', 'Parent folder ID')
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
source: string | undefined,
|
||||
url: string,
|
||||
options: {
|
||||
file?: string;
|
||||
hash?: string;
|
||||
json?: string | boolean;
|
||||
name?: string;
|
||||
@@ -136,47 +133,8 @@ export function registerFileCommand(program: Command) {
|
||||
type?: string;
|
||||
},
|
||||
) => {
|
||||
const isUrl = (value: string) =>
|
||||
value.startsWith('http://') || value.startsWith('https://');
|
||||
|
||||
// Resolve the local file path: explicit --file, or a positional that is
|
||||
// not a URL (e.g. `lh file upload ./games_list.txt`).
|
||||
const localPath = options.file ?? (source && !isUrl(source) ? source : undefined);
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// ── Local file upload ──
|
||||
if (localPath) {
|
||||
let result;
|
||||
try {
|
||||
result = await uploadLocalFile(client, localPath, { parentId: options.parentId });
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
const fields = typeof options.json === 'string' ? options.json : undefined;
|
||||
outputJson(result, fields);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = result as any;
|
||||
console.log(`${pc.green('✓')} File created: ${pc.bold(r.id || '')}`);
|
||||
if (r.url) console.log(` URL: ${pc.dim(r.url)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── URL upload ──
|
||||
if (!source) {
|
||||
log.error('Provide a local file path, --file <path>, or a URL to upload.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = source;
|
||||
|
||||
// Check hash first if provided
|
||||
if (options.hash) {
|
||||
const check = await client.file.checkFileHash.mutate({ hash: options.hash });
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { rm as fsRm, writeFile as fsWriteFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -10,9 +6,6 @@ import { registerGenerateCommand } from './generate';
|
||||
|
||||
const { mockTrpcClient } = vi.hoisted(() => ({
|
||||
mockTrpcClient: {
|
||||
asr: {
|
||||
transcribe: { mutate: vi.fn() },
|
||||
},
|
||||
generation: {
|
||||
deleteGeneration: { mutate: vi.fn() },
|
||||
getGenerationStatus: { query: vi.fn() },
|
||||
@@ -42,15 +35,6 @@ const { writeFileSync: mockWriteFileSync } = vi.hoisted(() => ({
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const { uploadLocalFile: mockUploadLocalFile } = vi.hoisted(() => ({
|
||||
uploadLocalFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/uploadLocalFile', async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
return { ...actual, uploadLocalFile: mockUploadLocalFile };
|
||||
});
|
||||
|
||||
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
|
||||
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
@@ -385,130 +369,6 @@ describe('generate command', () => {
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should upload large local audio and transcribe by fileId', async () => {
|
||||
// Real >3MB temp file so existsSync/statSync (unmocked) see it as large.
|
||||
const bigPath = path.join(os.tmpdir(), `lh-asr-test-${process.pid}-${Date.now()}.mp3`);
|
||||
await fsWriteFile(bigPath, Buffer.alloc(4 * 1024 * 1024));
|
||||
mockUploadLocalFile.mockResolvedValue({ id: 'file_999' });
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'big result' });
|
||||
|
||||
try {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'generate', 'asr', bigPath]);
|
||||
|
||||
expect(mockUploadLocalFile).toHaveBeenCalledWith(expect.anything(), bigPath);
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileId: 'file_999', model: 'whisper-1', provider: 'openai' }),
|
||||
);
|
||||
// never inlines bytes for the large file
|
||||
expect(mockTrpcClient.asr.transcribe.mutate.mock.calls[0][0]).not.toHaveProperty(
|
||||
'audioBase64',
|
||||
);
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('big result');
|
||||
} finally {
|
||||
await fsRm(bigPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should download and transcribe an audio URL', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers(),
|
||||
ok: true,
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'hello world' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/audio/sample.mp3',
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://example.com/audio/sample.mp3');
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audioBase64: Buffer.from('audio-bytes').toString('base64'),
|
||||
fileName: 'sample.mp3',
|
||||
model: 'whisper-1',
|
||||
provider: 'openai',
|
||||
}),
|
||||
);
|
||||
expect(stdoutSpy).toHaveBeenCalledWith('hello world');
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should derive an extension and mime type from Content-Type when the URL has none', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers({ 'content-type': 'audio/mpeg; charset=binary' }),
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'generate', 'asr', 'https://example.com/download']);
|
||||
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileName: 'download.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer the filename from Content-Disposition', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
|
||||
headers: new Headers({
|
||||
'content-disposition': 'attachment; filename="recording.wav"',
|
||||
}),
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/files/abc123?sig=xyz',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileName: 'recording.wav' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should exit when audio URL download fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }),
|
||||
);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'generate',
|
||||
'asr',
|
||||
'https://example.com/missing.mp3',
|
||||
]);
|
||||
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to download audio'));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { getAuthInfo } from '../../api/http';
|
||||
import { log } from '../../utils/logger';
|
||||
import { uploadLocalFile } from '../../utils/uploadLocalFile';
|
||||
|
||||
// Audio at or below this size is sent inline as base64; anything larger is
|
||||
// uploaded first and transcribed by `fileId`. Kept in sync with the server-side
|
||||
// inline cap in `apps/server/src/routers/lambda/asr.ts`.
|
||||
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
|
||||
|
||||
export function registerAsrCommand(parent: Command) {
|
||||
parent
|
||||
.command('asr <audio-file>')
|
||||
.description(
|
||||
'Convert speech to text (automatic speech recognition). Accepts a local path or a URL',
|
||||
)
|
||||
.description('Convert speech to text (automatic speech recognition)')
|
||||
.option('--model <model>', 'STT model', 'whisper-1')
|
||||
.option('--provider <provider>', 'AI provider', 'openai')
|
||||
.option('--language <lang>', 'Language code (e.g. en, zh)')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.action(
|
||||
@@ -31,175 +20,58 @@ export function registerAsrCommand(parent: Command) {
|
||||
json?: boolean;
|
||||
language?: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
},
|
||||
) => {
|
||||
const isUrl = audioFile.startsWith('http://') || audioFile.startsWith('https://');
|
||||
|
||||
if (!isUrl && !existsSync(audioFile)) {
|
||||
if (!existsSync(audioFile)) {
|
||||
log.error(`File not found: ${audioFile}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the input to a local file path (downloading URLs to a temp
|
||||
// file) so large audio can reuse the shared upload flow.
|
||||
let localPath: string;
|
||||
let fileName: string;
|
||||
let mimeType: string | undefined;
|
||||
let size: number;
|
||||
let tempPath: string | undefined;
|
||||
try {
|
||||
if (isUrl) {
|
||||
const downloaded = await fetchAudioFromUrl(audioFile);
|
||||
fileName = downloaded.name;
|
||||
mimeType = downloaded.mimeType;
|
||||
size = downloaded.bytes.byteLength;
|
||||
tempPath = path.join(os.tmpdir(), `lh-asr-${process.pid}-${Date.now()}-${fileName}`);
|
||||
await writeFile(tempPath, downloaded.bytes);
|
||||
localPath = tempPath;
|
||||
} else {
|
||||
localPath = audioFile;
|
||||
fileName = path.basename(audioFile);
|
||||
size = statSync(audioFile).size;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error));
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
|
||||
const sttOptions: Record<string, any> = { model: options.model };
|
||||
if (options.language) sttOptions.language = options.language;
|
||||
|
||||
const formData = new FormData();
|
||||
const fileBuffer = await readFileAsBlob(audioFile);
|
||||
formData.append('speech', fileBuffer, path.basename(audioFile));
|
||||
formData.append('options', JSON.stringify(sttOptions));
|
||||
|
||||
// Remove Content-Type for multipart/form-data (let fetch set it with boundary)
|
||||
const { 'Content-Type': _, ...formHeaders } = headers;
|
||||
|
||||
const res = await fetch(`${serverUrl}/webapi/stt/openai`, {
|
||||
body: formData,
|
||||
headers: formHeaders,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
log.error(`ASR failed: ${res.status} ${errText}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await getTrpcClient();
|
||||
const result = await res.json();
|
||||
|
||||
let result: { text: string };
|
||||
if (size > MAX_INLINE_AUDIO_BYTES) {
|
||||
// Large audio: upload to storage, then transcribe by fileId so the
|
||||
// bytes never travel inline through tRPC.
|
||||
process.stderr.write(
|
||||
`Audio is ${(size / 1024 / 1024).toFixed(1)}MB — uploading before transcription…\n`,
|
||||
);
|
||||
const record = (await uploadLocalFile(client, localPath)) as { id: string };
|
||||
result = await client.asr.transcribe.mutate({
|
||||
fileId: record.id,
|
||||
language: options.language,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
});
|
||||
} else {
|
||||
const bytes = await readFile(localPath);
|
||||
result = await client.asr.transcribe.mutate({
|
||||
audioBase64: Buffer.from(bytes).toString('base64'),
|
||||
fileName,
|
||||
language: options.language,
|
||||
mimeType,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
process.stdout.write(result.text);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`ASR failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (tempPath) {
|
||||
await rm(tempPath, { force: true }).catch(() => {});
|
||||
}
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
const text = (result as any).text || JSON.stringify(result);
|
||||
process.stdout.write(text);
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Common audio MIME types mapped to a file extension the transcription
|
||||
// provider can recognize. Keep the extensions within the set OpenAI's
|
||||
// /audio/transcriptions endpoint accepts.
|
||||
const AUDIO_MIME_TO_EXT: Record<string, string> = {
|
||||
'audio/aac': 'aac',
|
||||
'audio/flac': 'flac',
|
||||
'audio/m4a': 'm4a',
|
||||
'audio/mp3': 'mp3',
|
||||
'audio/mp4': 'm4a',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/mpga': 'mp3',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/opus': 'ogg',
|
||||
'audio/wav': 'wav',
|
||||
'audio/wave': 'wav',
|
||||
'audio/webm': 'webm',
|
||||
'audio/x-m4a': 'm4a',
|
||||
'audio/x-wav': 'wav',
|
||||
};
|
||||
|
||||
async function fetchAudioFromUrl(
|
||||
url: string,
|
||||
): Promise<{ bytes: Uint8Array; mimeType?: string; name: string }> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download audio: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
|
||||
// Strip any parameters from the Content-Type (e.g. `audio/mpeg; charset=...`).
|
||||
const contentType = res.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
|
||||
const mimeType = contentType?.startsWith('audio/') ? contentType : undefined;
|
||||
|
||||
// Prefer the name the server advertises, then the URL path, then a fallback.
|
||||
const name =
|
||||
fileNameFromContentDisposition(res.headers.get('content-disposition')) ||
|
||||
basenameFromUrl(url) ||
|
||||
'audio';
|
||||
|
||||
// Transcription providers infer the audio format from the file extension, so
|
||||
// make sure the name carries one. Signed URLs and /download endpoints often
|
||||
// have no extension in the path — in that case borrow it from the
|
||||
// Content-Type when we recognize it.
|
||||
const ext = contentType ? AUDIO_MIME_TO_EXT[contentType] : undefined;
|
||||
const finalName = path.extname(name) || !ext ? name : `${name}.${ext}`;
|
||||
|
||||
return { bytes, mimeType, name: finalName };
|
||||
}
|
||||
|
||||
// Extract a file name from a Content-Disposition header, handling both the
|
||||
// plain `filename="x"` form and the RFC 5987 extended `filename*=UTF-8''x` form.
|
||||
function fileNameFromContentDisposition(header: string | null): string | undefined {
|
||||
if (!header) return undefined;
|
||||
|
||||
// Extended form takes precedence and may be percent-encoded.
|
||||
const extended = /filename\*=\s*(?:UTF-8|ISO-8859-1)?''([^;]+)/i.exec(header);
|
||||
if (extended?.[1]) {
|
||||
try {
|
||||
return path.basename(decodeURIComponent(extended[1].trim()));
|
||||
} catch {
|
||||
// Malformed encoding — fall through to the plain form.
|
||||
}
|
||||
}
|
||||
|
||||
const plain = /filename=\s*"?([^";]+)"?/i.exec(header);
|
||||
const value = plain?.[1]?.trim();
|
||||
return value ? path.basename(value) : undefined;
|
||||
}
|
||||
|
||||
// Derive the (URL-decoded) last path segment of a URL, if any.
|
||||
function basenameFromUrl(url: string): string | undefined {
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(url).pathname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const base = path.basename(pathname);
|
||||
if (!base) return undefined;
|
||||
try {
|
||||
return decodeURIComponent(base);
|
||||
} catch {
|
||||
return base;
|
||||
async function readFileAsBlob(filePath: string): Promise<Blob> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const stream = createReadStream(filePath);
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk as Uint8Array);
|
||||
}
|
||||
return new Blob(chunks);
|
||||
}
|
||||
|
||||
+74
-13
@@ -1,12 +1,14 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { getAuthInfo } from '../api/http';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
import { uploadLocalFile } from '../utils/uploadLocalFile';
|
||||
|
||||
function formatFileType(fileType: string): string {
|
||||
if (!fileType) return '';
|
||||
@@ -322,22 +324,81 @@ export function registerKbCommand(program: Command) {
|
||||
.description('Upload a file to a knowledge base')
|
||||
.option('--parent <parentId>', 'Parent folder ID')
|
||||
.action(async (knowledgeBaseId: string, filePath: string, options: { parent?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await uploadLocalFile(client, filePath, {
|
||||
knowledgeBaseId,
|
||||
parentId: options.parent,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error));
|
||||
const resolved = path.resolve(filePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
log.error(`File not found: ${resolved}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(resolved);
|
||||
const fileName = path.basename(resolved);
|
||||
const fileBuffer = fs.readFileSync(resolved);
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
// Detect MIME type from extension
|
||||
const ext = path.extname(fileName).toLowerCase().slice(1);
|
||||
const mimeMap: Record<string, string> = {
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
gif: 'image/gif',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
json: 'application/json',
|
||||
md: 'text/markdown',
|
||||
mp3: 'audio/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
svg: 'image/svg+xml',
|
||||
txt: 'text/plain',
|
||||
webp: 'image/webp',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
const fileType = mimeMap[ext] || 'application/octet-stream';
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
|
||||
// 1. Get presigned URL
|
||||
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
const pathname = `files/${date}/${hash}.${ext}`;
|
||||
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
|
||||
|
||||
// 2. Upload to S3
|
||||
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
|
||||
const uploadRes = await fetch(presignedUrl, {
|
||||
body: fileBuffer,
|
||||
headers: { 'Content-Type': fileType },
|
||||
method: 'PUT',
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
log.error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Create file record
|
||||
const result = await client.file.createFile.mutate({
|
||||
fileType,
|
||||
hash,
|
||||
knowledgeBaseId,
|
||||
metadata: {
|
||||
date,
|
||||
dirname: '',
|
||||
filename: fileName,
|
||||
path: pathname,
|
||||
},
|
||||
name: fileName,
|
||||
parentId: options.parent,
|
||||
size: stat.size,
|
||||
url: pathname,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${pc.green('✓')} Uploaded ${pc.bold(path.basename(filePath))} → ${pc.bold((result as any).id)}`,
|
||||
`${pc.green('✓')} Uploaded ${pc.bold(fileName)} → ${pc.bold((result as any).id)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Command } from 'commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clearCredentials } from '../auth/credentials';
|
||||
import { stopDaemon } from '../daemon/manager';
|
||||
import { log } from '../utils/logger';
|
||||
import { registerLogoutCommand } from './logout';
|
||||
|
||||
@@ -10,10 +9,6 @@ vi.mock('../auth/credentials', () => ({
|
||||
clearCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../daemon/manager', () => ({
|
||||
stopDaemon: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
@@ -24,11 +19,6 @@ vi.mock('../utils/logger', () => ({
|
||||
}));
|
||||
|
||||
describe('logout command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(stopDaemon).mockReturnValue(false);
|
||||
});
|
||||
|
||||
function createProgram() {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
@@ -54,24 +44,4 @@ describe('logout command', () => {
|
||||
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Already logged out'));
|
||||
});
|
||||
|
||||
it('should stop the connect daemon before clearing credentials', async () => {
|
||||
vi.mocked(stopDaemon).mockReturnValue(true);
|
||||
vi.mocked(clearCredentials).mockReturnValue(true);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'logout']);
|
||||
|
||||
expect(stopDaemon).toHaveBeenCalled();
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Disconnected device daemon'));
|
||||
});
|
||||
|
||||
it('should still attempt daemon teardown when no credentials exist', async () => {
|
||||
vi.mocked(clearCredentials).mockReturnValue(false);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'logout']);
|
||||
|
||||
expect(stopDaemon).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { clearCredentials } from '../auth/credentials';
|
||||
import { stopDaemon } from '../daemon/manager';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerLogoutCommand(program: Command) {
|
||||
@@ -9,14 +8,6 @@ export function registerLogoutCommand(program: Command) {
|
||||
.command('logout')
|
||||
.description('Log out and remove stored credentials')
|
||||
.action(() => {
|
||||
// Tear down the connect daemon first — otherwise it keeps the device
|
||||
// online on the gateway with the cached token even after credentials are
|
||||
// gone, leaving the machine remotely driveable past "logout".
|
||||
const stopped = stopDaemon();
|
||||
if (stopped) {
|
||||
log.info('Disconnected device daemon.');
|
||||
}
|
||||
|
||||
const removed = clearCredentials();
|
||||
if (removed) {
|
||||
log.info('Logged out. Credentials removed.');
|
||||
|
||||
@@ -100,19 +100,6 @@ describe('model command', () => {
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(visibleModels, null, 2));
|
||||
});
|
||||
|
||||
it('should normalize the legacy `stt` type to `asr` when filtering', async () => {
|
||||
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([
|
||||
{ displayName: 'Whisper', enabled: true, id: 'whisper-1', type: 'asr' },
|
||||
]);
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'model', 'list', 'openai', '--type', 'stt']);
|
||||
|
||||
expect(mockTrpcClient.aiModel.getAiProviderModelList.query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'openai', type: 'asr' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('view', () => {
|
||||
@@ -170,28 +157,6 @@ describe('model command', () => {
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created model'));
|
||||
});
|
||||
|
||||
it('should normalize the legacy `stt` type to `asr`', async () => {
|
||||
mockTrpcClient.aiModel.createAiModel.mutate.mockResolvedValue('whisper-1');
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'create',
|
||||
'--id',
|
||||
'whisper-1',
|
||||
'--provider',
|
||||
'openai',
|
||||
'--type',
|
||||
'stt',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiModel.createAiModel.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'whisper-1', providerId: 'openai', type: 'asr' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
@@ -219,29 +184,6 @@ describe('model command', () => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated model'));
|
||||
});
|
||||
|
||||
it('should normalize the legacy `stt` type to `asr`', async () => {
|
||||
mockTrpcClient.aiModel.updateAiModel.mutate.mockResolvedValue({});
|
||||
|
||||
const program = createProgram();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'model',
|
||||
'edit',
|
||||
'whisper-1',
|
||||
'--provider',
|
||||
'openai',
|
||||
'--type',
|
||||
'stt',
|
||||
]);
|
||||
|
||||
expect(mockTrpcClient.aiModel.updateAiModel.mutate).toHaveBeenCalledWith({
|
||||
id: 'whisper-1',
|
||||
providerId: 'openai',
|
||||
value: expect.objectContaining({ type: 'asr' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should error when no changes specified', async () => {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', 'model', 'edit', 'gpt-4', '--provider', 'openai']);
|
||||
|
||||
@@ -7,11 +7,6 @@ import { log } from '../utils/logger';
|
||||
|
||||
const isVisibleModel = (model: { visible?: boolean }) => model.visible !== false;
|
||||
|
||||
// The model type `stt` was renamed to the standard `asr`. Accept the legacy
|
||||
// alias on CLI input and forward/compare `asr`, so existing scripts and muscle
|
||||
// memory keep working against the new router schema.
|
||||
const normalizeModelType = (type: string): string => (type === 'stt' ? 'asr' : type);
|
||||
|
||||
export function registerModelCommand(program: Command) {
|
||||
const model = program.command('model').description('Manage AI models');
|
||||
|
||||
@@ -24,7 +19,7 @@ export function registerModelCommand(program: Command) {
|
||||
.option('--enabled', 'Only show enabled models')
|
||||
.option(
|
||||
'--type <type>',
|
||||
'Filter by model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
|
||||
'Filter by model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
|
||||
)
|
||||
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
|
||||
.action(
|
||||
@@ -34,20 +29,18 @@ export function registerModelCommand(program: Command) {
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const typeFilter = options.type ? normalizeModelType(options.type) : undefined;
|
||||
|
||||
const input: Record<string, any> = { id: providerId };
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.enabled) input.enabled = true;
|
||||
if (typeFilter) input.type = typeFilter;
|
||||
if (options.type) input.type = options.type;
|
||||
|
||||
const result = await client.aiModel.getAiProviderModelList.query(input as any);
|
||||
let items = (Array.isArray(result) ? result : ((result as any).items ?? [])).filter(
|
||||
isVisibleModel,
|
||||
);
|
||||
|
||||
if (typeFilter) {
|
||||
items = items.filter((m: any) => m.type === typeFilter);
|
||||
if (options.type) {
|
||||
items = items.filter((m: any) => m.type === options.type);
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
@@ -113,7 +106,7 @@ export function registerModelCommand(program: Command) {
|
||||
.option('--display-name <name>', 'Display name')
|
||||
.option(
|
||||
'--type <type>',
|
||||
'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
|
||||
'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
|
||||
'chat',
|
||||
)
|
||||
.action(
|
||||
@@ -123,7 +116,7 @@ export function registerModelCommand(program: Command) {
|
||||
const input: Record<string, any> = {
|
||||
id: options.id,
|
||||
providerId: options.provider,
|
||||
type: normalizeModelType(options.type || 'chat'),
|
||||
type: options.type || 'chat',
|
||||
};
|
||||
if (options.displayName) input.displayName = options.displayName;
|
||||
|
||||
@@ -139,7 +132,7 @@ export function registerModelCommand(program: Command) {
|
||||
.description('Update model info')
|
||||
.requiredOption('--provider <providerId>', 'Provider ID')
|
||||
.option('--display-name <name>', 'Display name')
|
||||
.option('--type <type>', 'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)')
|
||||
.option('--type <type>', 'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)')
|
||||
.action(
|
||||
async (id: string, options: { displayName?: string; provider: string; type?: string }) => {
|
||||
if (!options.displayName && !options.type) {
|
||||
@@ -151,7 +144,7 @@ export function registerModelCommand(program: Command) {
|
||||
|
||||
const value: Record<string, any> = {};
|
||||
if (options.displayName) value.displayName = options.displayName;
|
||||
if (options.type) value.type = normalizeModelType(options.type);
|
||||
if (options.type) value.type = options.type;
|
||||
|
||||
await client.aiModel.updateAiModel.mutate({
|
||||
id,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -19,22 +19,11 @@ vi.mock('node:os', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock only `execFileSync` (used by isDaemonProcess to read a process command
|
||||
// line); keep the real `spawn` so nothing else changes.
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, any>>();
|
||||
return { ...actual, execFileSync: vi.fn() };
|
||||
});
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
// eslint-disable-next-line import-x/first
|
||||
import {
|
||||
appendLog,
|
||||
getLogPath,
|
||||
getRunningDaemonPid,
|
||||
isDaemonProcess,
|
||||
isProcessAlive,
|
||||
readPid,
|
||||
readStatus,
|
||||
@@ -46,15 +35,9 @@ import {
|
||||
writeStatus,
|
||||
} from './manager';
|
||||
|
||||
// A command line that matches the daemon signature (`connect … --daemon-child`).
|
||||
const DAEMON_COMMAND = '/usr/local/bin/node /path/to/cli.js connect --daemon-child';
|
||||
|
||||
describe('daemon manager', () => {
|
||||
beforeEach(async () => {
|
||||
await mkdir(mockDir, { recursive: true });
|
||||
// Default: any inspected PID looks like our daemon. Tests that need a
|
||||
// reused / unrelated PID override this per-case.
|
||||
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -97,36 +80,6 @@ describe('daemon manager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDaemonProcess', () => {
|
||||
it('should return true when the command line matches the daemon signature', () => {
|
||||
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
|
||||
expect(isDaemonProcess(12345)).toBe(true);
|
||||
expect(execFileSync).toHaveBeenCalledWith(
|
||||
'ps',
|
||||
['-ww', '-p', '12345', '-o', 'command='],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for an unrelated process command line', () => {
|
||||
vi.mocked(execFileSync).mockReturnValue('/usr/bin/vim notes.txt' as any);
|
||||
expect(isDaemonProcess(12345)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when the signature is only partially present', () => {
|
||||
// `connect` without the internal `--daemon-child` flag is not our daemon.
|
||||
vi.mocked(execFileSync).mockReturnValue('/usr/bin/node /path/cli connect' as any);
|
||||
expect(isDaemonProcess(12345)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when ps is unavailable / throws', () => {
|
||||
vi.mocked(execFileSync).mockImplementation(() => {
|
||||
throw new Error('ps: command not found');
|
||||
});
|
||||
expect(isDaemonProcess(12345)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunningDaemonPid', () => {
|
||||
it('should return null when no PID file', () => {
|
||||
expect(getRunningDaemonPid()).toBeNull();
|
||||
@@ -157,23 +110,6 @@ describe('daemon manager', () => {
|
||||
|
||||
expect(readStatus()).toBeNull();
|
||||
});
|
||||
|
||||
it('should treat a live but reused (non-daemon) PID as stale and clean up', () => {
|
||||
// process.pid is alive, but the inspected command line is not our daemon —
|
||||
// simulates the OS reusing a dead daemon's PID for an unrelated process.
|
||||
writePid(process.pid);
|
||||
writeStatus({
|
||||
connectionStatus: 'connected',
|
||||
gatewayUrl: 'https://test.com',
|
||||
pid: process.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
|
||||
|
||||
expect(getRunningDaemonPid()).toBeNull();
|
||||
expect(readPid()).toBeNull();
|
||||
expect(readStatus()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status file', () => {
|
||||
@@ -296,23 +232,5 @@ describe('daemon manager', () => {
|
||||
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should NOT SIGTERM a live PID that is not our daemon', () => {
|
||||
// Stale daemon.pid whose PID was reused by an unrelated, living process.
|
||||
writePid(process.pid);
|
||||
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
|
||||
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
|
||||
const result = stopDaemon();
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Only the liveness probe (signal 0) is allowed — never a real SIGTERM.
|
||||
expect(killSpy).not.toHaveBeenCalledWith(process.pid, 'SIGTERM');
|
||||
// Stale metadata is cleaned up so we don't keep re-checking it.
|
||||
expect(readPid()).toBeNull();
|
||||
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -70,34 +70,6 @@ export function isProcessAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a live PID actually belongs to a LobeHub connect daemon.
|
||||
*
|
||||
* A bare `isProcessAlive` check is not enough: if a daemon dies without cleaning
|
||||
* up `daemon.pid` (crash, `kill -9`, reboot), the OS can later reuse that PID
|
||||
* for an unrelated process. Acting on the stale PID would let `lh logout` /
|
||||
* `connect stop` SIGTERM a stranger. The daemon is always spawned as
|
||||
* `<node> … connect … --daemon-child`, so we confirm that signature in the
|
||||
* process command line before trusting the PID.
|
||||
*
|
||||
* Best-effort and deliberately conservative: if the command line can't be read
|
||||
* (e.g. `ps` is unavailable), we return `false` so callers never kill a process
|
||||
* we can't positively identify.
|
||||
*/
|
||||
export function isDaemonProcess(pid: number): boolean {
|
||||
try {
|
||||
// `-ww` disables column truncation so the trailing `--daemon-child` flag is
|
||||
// never cut off; stderr is silenced so a dead PID just yields an empty match.
|
||||
const command = execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
return command.includes('--daemon-child') && command.includes('connect');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PID of a running daemon, cleaning up stale PID files.
|
||||
* Returns null if no daemon is running.
|
||||
@@ -106,11 +78,9 @@ export function getRunningDaemonPid(): number | null {
|
||||
const pid = readPid();
|
||||
if (pid === null) return null;
|
||||
|
||||
// Require both liveness AND identity — a live-but-reused PID is treated as
|
||||
// stale so we never act on a process that isn't ours.
|
||||
if (isProcessAlive(pid) && isDaemonProcess(pid)) return pid;
|
||||
if (isProcessAlive(pid)) return pid;
|
||||
|
||||
// Stale PID file — process is dead or the PID now belongs to someone else.
|
||||
// Stale PID file — process is dead
|
||||
removePid();
|
||||
removeStatus();
|
||||
return null;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, clipboard, dialog, Menu, shell } from 'electron';
|
||||
import { app, BrowserWindow, clipboard, dialog, Menu, shell } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
|
||||
@@ -122,7 +122,16 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
|
||||
click: () => {
|
||||
const focused = BrowserWindow.getFocusedWindow();
|
||||
if (!focused) return;
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
if (focused === mainWindow.browserWindow) {
|
||||
mainWindow.broadcast('closeCurrentTabOrWindow');
|
||||
} else {
|
||||
focused.close();
|
||||
}
|
||||
},
|
||||
label: t('window.close'),
|
||||
},
|
||||
{ label: t('window.minimize'), role: 'minimize' },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, clipboard, Menu, shell } from 'electron';
|
||||
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
|
||||
@@ -164,7 +164,16 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ type: 'separator' },
|
||||
{
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
|
||||
click: () => {
|
||||
const focused = BrowserWindow.getFocusedWindow();
|
||||
if (!focused) return;
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
if (focused === mainWindow.browserWindow) {
|
||||
mainWindow.broadcast('closeCurrentTabOrWindow');
|
||||
} else {
|
||||
focused.close();
|
||||
}
|
||||
},
|
||||
label: t('window.close'),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, clipboard, Menu, shell } from 'electron';
|
||||
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
|
||||
|
||||
import { isDev } from '@/const/env';
|
||||
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
|
||||
@@ -185,7 +185,16 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
|
||||
{ label: t('window.minimize'), role: 'minimize' },
|
||||
{
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
|
||||
click: () => {
|
||||
const focused = BrowserWindow.getFocusedWindow();
|
||||
if (!focused) return;
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
if (focused === mainWindow.browserWindow) {
|
||||
mainWindow.broadcast('closeCurrentTabOrWindow');
|
||||
} else {
|
||||
focused.close();
|
||||
}
|
||||
},
|
||||
label: t('window.close'),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
-19
@@ -70,25 +70,6 @@ describe('serverMessagesEngine', () => {
|
||||
expect(result[0].content).toBe(systemRole + '\n\n' + getCurrentDateContent());
|
||||
});
|
||||
|
||||
it('should inject model knowledge cutoff when provided', async () => {
|
||||
const messages = createBasicMessages();
|
||||
|
||||
const result = await serverMessagesEngine({
|
||||
messages,
|
||||
model: 'gpt-4',
|
||||
modelKnowledgeCutoff: '2024-06',
|
||||
provider: 'openai',
|
||||
systemRole: 'You are a helpful assistant',
|
||||
});
|
||||
|
||||
expect(result[0].role).toBe('system');
|
||||
expect(result[0].content).toBe(
|
||||
'You are a helpful assistant\n\n' +
|
||||
getCurrentDateContent() +
|
||||
'\n\nModel knowledge cutoff: 2024-06',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty messages', async () => {
|
||||
const result = await serverMessagesEngine({
|
||||
messages: [],
|
||||
|
||||
@@ -51,7 +51,6 @@ const createServerVariableGenerators = (params: {
|
||||
export const serverMessagesEngine = async ({
|
||||
messages = [],
|
||||
model,
|
||||
modelKnowledgeCutoff,
|
||||
provider,
|
||||
systemRole,
|
||||
inputTemplate,
|
||||
@@ -84,7 +83,6 @@ export const serverMessagesEngine = async ({
|
||||
const engine = new MessagesEngine({
|
||||
// Capability injection
|
||||
capabilities: {
|
||||
isCanUseAudio: capabilities?.isCanUseAudio,
|
||||
isCanUseFC: capabilities?.isCanUseFC,
|
||||
isCanUseVideo: capabilities?.isCanUseVideo,
|
||||
isCanUseVision: capabilities?.isCanUseVision,
|
||||
@@ -122,7 +120,6 @@ export const serverMessagesEngine = async ({
|
||||
|
||||
// Model info
|
||||
model,
|
||||
modelKnowledgeCutoff,
|
||||
|
||||
provider,
|
||||
systemRole,
|
||||
|
||||
@@ -23,8 +23,6 @@ import type { RuntimeInitialContext, UIChatMessage } from '@lobechat/types';
|
||||
* Model capability checker functions for server-side
|
||||
*/
|
||||
export interface ServerModelCapabilities {
|
||||
/** Check if audio input is supported */
|
||||
isCanUseAudio?: (model: string, provider: string) => boolean;
|
||||
/** Check if function calling is supported */
|
||||
isCanUseFC?: (model: string, provider: string) => boolean;
|
||||
/** Check if video is supported */
|
||||
@@ -132,8 +130,6 @@ export interface ServerMessagesEngineParams {
|
||||
|
||||
/** Model ID */
|
||||
model: string;
|
||||
/** Model knowledge cutoff date, e.g. `2024-06`. Omit when unknown. */
|
||||
modelKnowledgeCutoff?: string;
|
||||
|
||||
/** Page content context (optional, for document editing) */
|
||||
pageContentContext?: PageContentContext;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
-289
@@ -1,289 +0,0 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* Integration test for the server `lobe-agent-management.callAgent` deferred
|
||||
* execution flow.
|
||||
*
|
||||
* Verifies the full lifecycle end-to-end on the in-memory runtime:
|
||||
* 1. Parent op LLM emits a `lobe-agent-management____callAgent` tool call.
|
||||
* 2. The real server executor parks the parent, creates a pending tool
|
||||
* placeholder, and forks the target agent as a child op.
|
||||
* 3. The child op completes.
|
||||
* 4. The completion bridge backfills the placeholder and resumes the parent.
|
||||
* 5. The parent reaches `done`.
|
||||
*/
|
||||
import { type LobeChatDatabase } from '@lobechat/database';
|
||||
import { agentOperations, agents, messagePlugins, messages } from '@lobechat/database/schemas';
|
||||
import { getTestDB } from '@lobechat/database/test-utils';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import OpenAI from 'openai';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { inMemoryAgentStateManager } from '@/server/modules/AgentRuntime/InMemoryAgentStateManager';
|
||||
import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager';
|
||||
|
||||
import { aiAgentRouter } from '../../../aiAgent';
|
||||
import { cleanupTestUser, createTestUser } from '../setup';
|
||||
import { createMockResponsesStream, waitForOperationComplete } from './helpers';
|
||||
|
||||
process.env.OPENAI_API_KEY = 'sk-test-fake-api-key-for-testing';
|
||||
|
||||
let testDB: LobeChatDatabase;
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
getServerDB: vi.fn(() => testDB),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/file', () => ({
|
||||
FileService: vi.fn().mockImplementation(() => ({
|
||||
getFullFileUrl: vi.fn().mockImplementation((path: string) => (path ? `/files${path}` : null)),
|
||||
})),
|
||||
}));
|
||||
|
||||
let mockResponsesCreate: any;
|
||||
let serverDB: LobeChatDatabase;
|
||||
let userId: string;
|
||||
let parentAgentId: string;
|
||||
let targetAgentId: string;
|
||||
|
||||
const TARGET_ANSWER = 'The target agent completed the delegated callAgent work.';
|
||||
const PARENT_FINAL = 'I received the target agent result and the delegated work is complete.';
|
||||
|
||||
const createTestContext = () => ({ jwtPayload: { userId }, userId });
|
||||
|
||||
const createCallAgentResponse = () => {
|
||||
const responseId = `resp_call_agent_${Date.now()}`;
|
||||
const msgItemId = `msg_call_agent_${Date.now()}`;
|
||||
const callId = 'call_agent_1';
|
||||
const fnCall = {
|
||||
arguments: JSON.stringify({
|
||||
agentId: targetAgentId,
|
||||
instruction: 'Handle the delegated backend integration task.',
|
||||
runAsTask: true,
|
||||
taskTitle: 'Delegated backend integration task',
|
||||
timeout: 30_000,
|
||||
}),
|
||||
call_id: callId,
|
||||
name: 'lobe-agent-management____callAgent',
|
||||
type: 'function_call',
|
||||
};
|
||||
|
||||
return createMockResponsesStream([
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [],
|
||||
status: 'in_progress',
|
||||
},
|
||||
type: 'response.created',
|
||||
},
|
||||
{
|
||||
item: {
|
||||
content: [],
|
||||
id: msgItemId,
|
||||
role: 'assistant',
|
||||
status: 'in_progress',
|
||||
type: 'message',
|
||||
},
|
||||
output_index: 0,
|
||||
type: 'response.output_item.added',
|
||||
},
|
||||
{
|
||||
content_index: 0,
|
||||
delta: 'I will delegate this to the target agent.',
|
||||
item_id: msgItemId,
|
||||
output_index: 0,
|
||||
type: 'response.output_text.delta',
|
||||
},
|
||||
{ item: fnCall, output_index: 1, type: 'response.output_item.added' },
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [
|
||||
{
|
||||
content: [{ text: 'I will delegate this to the target agent.', type: 'output_text' }],
|
||||
id: msgItemId,
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
type: 'message',
|
||||
},
|
||||
fnCall,
|
||||
],
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 30, output_tokens: 20, total_tokens: 50 },
|
||||
},
|
||||
type: 'response.completed',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const createFinalTextResponse = (content: string) => {
|
||||
const responseId = `resp_final_${Date.now()}_${content.length}`;
|
||||
const msgItemId = `msg_final_${Date.now()}_${content.length}`;
|
||||
|
||||
return createMockResponsesStream([
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [],
|
||||
status: 'in_progress',
|
||||
},
|
||||
type: 'response.created',
|
||||
},
|
||||
{
|
||||
content_index: 0,
|
||||
delta: content,
|
||||
item_id: msgItemId,
|
||||
output_index: 0,
|
||||
type: 'response.output_text.delta',
|
||||
},
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [
|
||||
{
|
||||
content: [{ text: content, type: 'output_text' }],
|
||||
id: msgItemId,
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
type: 'message',
|
||||
},
|
||||
],
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 40, output_tokens: 20, total_tokens: 60 },
|
||||
},
|
||||
type: 'response.completed',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
serverDB = await getTestDB();
|
||||
testDB = serverDB;
|
||||
userId = await createTestUser(serverDB);
|
||||
|
||||
const insertedAgents = await serverDB
|
||||
.insert(agents)
|
||||
.values([
|
||||
{
|
||||
chatConfig: {},
|
||||
model: 'gpt-5-pro',
|
||||
plugins: ['lobe-agent-management'],
|
||||
provider: 'openai',
|
||||
systemRole: 'You are a supervisor that delegates work to other agents.',
|
||||
title: 'callAgent Supervisor',
|
||||
userId,
|
||||
},
|
||||
{
|
||||
chatConfig: {},
|
||||
model: 'gpt-5-pro',
|
||||
plugins: [],
|
||||
provider: 'openai',
|
||||
systemRole: 'You are the target agent. Return a concise result.',
|
||||
title: 'callAgent Target',
|
||||
userId,
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
parentAgentId = insertedAgents[0].id;
|
||||
targetAgentId = insertedAgents[1].id;
|
||||
|
||||
// `create` is overloaded (streaming / non-streaming); its precise spy type
|
||||
// isn't assignable to the generic MockInstance fallback, so widen via unknown.
|
||||
mockResponsesCreate = vi.spyOn(
|
||||
OpenAI.Responses.prototype,
|
||||
'create',
|
||||
) as unknown as typeof mockResponsesCreate;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestUser(serverDB, userId);
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
inMemoryAgentStateManager.clear();
|
||||
inMemoryStreamEventManager.clear();
|
||||
});
|
||||
|
||||
describe('Server callAgent deferred execution', () => {
|
||||
it('parks the parent, runs the target agent, backfills the tool message and resumes', async () => {
|
||||
let callCount = 0;
|
||||
mockResponsesCreate.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return Promise.resolve(createCallAgentResponse() as any);
|
||||
if (callCount === 2) return Promise.resolve(createFinalTextResponse(TARGET_ANSWER) as any);
|
||||
return Promise.resolve(createFinalTextResponse(PARENT_FINAL) as any);
|
||||
});
|
||||
|
||||
const caller = aiAgentRouter.createCaller(createTestContext());
|
||||
|
||||
const createResult = await caller.execAgent({
|
||||
agentId: parentAgentId,
|
||||
prompt: 'Delegate this work to the target agent and report back.',
|
||||
userInterventionConfig: { approvalMode: 'headless' },
|
||||
});
|
||||
expect(createResult.success).toBe(true);
|
||||
|
||||
const finalState = await waitForOperationComplete(
|
||||
inMemoryAgentStateManager,
|
||||
createResult.operationId,
|
||||
{ maxWaitTime: 20_000 },
|
||||
);
|
||||
|
||||
expect(finalState.status).toBe('done');
|
||||
expect(finalState.pendingToolsCalling ?? []).toHaveLength(0);
|
||||
expect(mockResponsesCreate).toHaveBeenCalledTimes(3);
|
||||
|
||||
const childOps = await serverDB
|
||||
.select()
|
||||
.from(agentOperations)
|
||||
.where(eq(agentOperations.parentOperationId, createResult.operationId));
|
||||
expect(childOps).toHaveLength(1);
|
||||
expect(childOps[0]).toMatchObject({
|
||||
agentId: targetAgentId,
|
||||
status: 'done',
|
||||
});
|
||||
|
||||
const toolMessages = await serverDB
|
||||
.select({
|
||||
content: messages.content,
|
||||
role: messages.role,
|
||||
state: messagePlugins.state,
|
||||
identifier: messagePlugins.identifier,
|
||||
apiName: messagePlugins.apiName,
|
||||
toolCallId: messagePlugins.toolCallId,
|
||||
})
|
||||
.from(messages)
|
||||
.innerJoin(messagePlugins, eq(messagePlugins.id, messages.id))
|
||||
.where(
|
||||
and(
|
||||
eq(messages.userId, userId),
|
||||
eq(messagePlugins.identifier, 'lobe-agent-management'),
|
||||
eq(messagePlugins.apiName, 'callAgent'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(toolMessages).toHaveLength(1);
|
||||
expect(toolMessages[0]).toMatchObject({
|
||||
apiName: 'callAgent',
|
||||
content: TARGET_ANSWER,
|
||||
identifier: 'lobe-agent-management',
|
||||
role: 'tool',
|
||||
toolCallId: 'call_agent_1',
|
||||
});
|
||||
expect(toolMessages[0].state).toMatchObject({
|
||||
status: 'completed',
|
||||
threadId: childOps[0].threadId,
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
-33
@@ -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',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
-37
@@ -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,
|
||||
|
||||
+2
-19
@@ -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
-72
@@ -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', () => {
|
||||
|
||||
+3
-16
@@ -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,
|
||||
|
||||
+1
-2
@@ -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
Reference in New Issue
Block a user