Compare commits

...

31 Commits

Author SHA1 Message Date
Arvin Xu ddf058fb36 🐛 fix: forward serverUrl in WS auth for apiKey verification
The agent gateway verifies an apiKey by calling
\`\${serverUrl}/api/v1/users/me\` with the token, so \`serverUrl\` has to be
part of the WebSocket auth handshake. The device-gateway-client already
does this; \`lh agent run\` was missing it, producing
"Gateway auth failed: Missing serverUrl for apiKey auth".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:00:47 +08:00
Arvin Xu 78dda79f81 update cli version 2026-04-14 23:36:47 +08:00
Arvin Xu f6081c9914 🔨 chore: add headless approval and apiKey WS auth to lh agent run (#13819)
 feat: add headless approval and apiKey ws auth to `lh agent run`

Two fixes so `lh agent run` works end-to-end against the WebSocket agent
gateway when the user is authenticated via LOBEHUB_CLI_API_KEY.

- Default to `userInterventionConfig: { approvalMode: 'headless' }` when
  running the agent from the CLI. Without this flag the runtime waits
  for human tool-call approval and local-device commands hang forever.
  Users who want interactive approval can pass `--no-headless`.
- Pass `tokenType` (`jwt` | `apiKey`) in the WebSocket auth handshake so
  the gateway knows how to verify the token. Previously the CLI sent
  only the raw token value and the gateway assumed JWT, rejecting valid
  API keys.

Fixes LOBE-6939

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:28:01 +08:00
Arvin Xu d6f11f80b6 🐛 fix(agent-runtime): harden classifyLLMError so it never masks the original provider error (#13774)
* 🐛 fix(agent-runtime): harden classifyLLMError so it never masks the original provider error

Production traces across multiple providers (openrouter, openai, google)
surface a single opaque error — `e.trim is not a function` with
`errorType: 'unknown'` — hiding whatever the upstream actually returned.

Root cause: `normalizeCode` / `normalizeErrorType` assumed their input is
always `string | undefined` (matching the TypeScript signature), but real
provider error objects frequently carry a numeric `code` (HTTP status) or
a structured object in `errorType`. `value?.trim()` short-circuits only
on null/undefined, so a truthy non-string turns into a TypeError that
the outer catch records as the "final" error, erasing the upstream one.

Fixes:
- Guard `normalizeCode` / `normalizeErrorType` on `typeof value ===
  'string'`, widen parameter type to `unknown`.
- Wrap the whole `classifyLLMError` in a try/catch that falls back to a
  conservative `stop` decision and preserves the best-effort message of
  the ORIGINAL error. A classifier that throws is worse than a
  classifier that's wrong — it must never shadow the real failure.
- `bestEffortMessage` swallows property-access errors (hostile Proxy
  etc.) to guarantee the fallback itself can't throw.

Regression tests cover: numeric `code`, structured `errorType`, nested
OpenAI-SDK-shaped `error.error.code`, and a hostile Proxy that throws on
every property access.

This is a forcing function for root-cause diagnosis: after this lands,
the real upstream errors behind the 'e.trim' mask will finally surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Remove fallback warning in classifyLLMError

Removed console warning for classification failure.

* 🐛 fix(agent-runtime): treat numeric provider code as status fallback

Bare HTTP proxies sometimes surface the HTTP status ONLY as a numeric `code`
on the error object (no `status`/`statusCode`, no digits in the message).
After widening `normalizeCode` to require `typeof === 'string'`, those numeric
codes were dropped entirely and auth/permission failures fell through to
retry — wasting the full retry budget on permanent errors.

Forward numeric `raw.code` / `nested?.code` / `nestedError?.code` into the
status chain (after the real status/statusCode lookups, before the
message-digit extractor) so classifyKind still maps 401/403 → stop and
429/5xx → retry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:23:21 +08:00
Rdmclin2 1c75686b70 🐛 fix: gateway typing error (#13820)
fix: gateway typing error
2026-04-14 23:15:41 +08:00
Arvin Xu 7e89fa782d 🐛 fix: detect truncated tool_calls arguments in builtin tools (#13817)
* 🐛 fix: detect truncated tool_call arguments in builtin tools

When an LLM hits max_tokens mid tool_call, the arguments JSON is
truncated. The previous flow passed `{}` to the tool, which returned a
generic "required field missing" error; the model re-tried with the same
payload and the truncation repeated — one observed trace burned 17 min
and $2.46 on 5 blind retries.

Detect structural truncation (unclosed braces/brackets/strings) in
BuiltinToolsExecutor before schema validation, and return a dedicated
TRUNCATED_ARGUMENTS error telling the model to reduce payload size or
raise max_tokens instead of retrying.

Fixes LOBE-7148

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 💄 chore: echo raw arguments string and reject all unparseable JSON

Two improvements based on review:

- Append the received arguments string to the error content so the model
  can verify the payload is exactly what it produced (stops it from
  blaming upstream or guessing what went wrong).
- Treat ANY unparseable non-empty argsStr as an error (new code
  INVALID_JSON_ARGUMENTS), not just truncation. The previous fallback
  of passing `{}` to the tool produced generic "missing field" errors
  that hid the real cause. Empty argsStr still falls through to `{}`
  for tools that take no parameters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:50:49 +08:00
Arvin Xu 18bc2716b2 🔨 fix: wire Gateway-mode stop via direct tRPC interrupt (#13815)
*  feat: wire Gateway-mode stop button to WS interrupt

Frontend half of [LOBE-7142](https://linear.app/lobehub/issue/LOBE-7142)
— the stop button previously silently failed in Gateway mode because:

1. `stopGenerateMessage` only filtered `execAgentRuntime`, so
   `execServerAgentRuntime` ops (Gateway) were skipped.
2. Even if the local op got cancelled, nothing bridged the cancel to
   the server-side agent loop running behind the Agent Gateway WS.

## Changes

**`conversationControl.ts::stopGenerateMessage`** — extend the type
filter to include both op types so both client-side and Gateway-mode
runs are cancelled from the same entry point.

**`gateway.ts::executeGatewayAgent` + `reconnectToGatewayOperation`** —
register an `onOperationCancel` handler on the local `gatewayOpId` that
forwards the server-side operation id to `interruptGatewayAgent(...)`,
which sends `{ type: 'interrupt' }` over the Agent Gateway WS. The
closure cleanly resolves the "local op id vs server op id" mapping —
no metadata lookup needed.

**`operation/actions.ts::cancelOperation`** — `isAborting` flag was
gated on `execAgentRuntime`. Extend to `execServerAgentRuntime` too so
the UI loading state transitions out immediately on Gateway-mode stop,
without waiting for the round-trip `session_complete` from the server.

## What this doesn't do (follow-ups)

- **Backend**: new `POST /api/agent/interrupt` route + Redis LPUSH
  (LOBE-7145). Without it, the WS interrupt reaches Agent Gateway but
  never gets forwarded to cloud.
- **Agent loop**: `AgentRuntimeService.executeStep` LPOP polling of the
  interrupt key (LOBE-7146). Without it, the state never flips to
  `interrupted` server-side.
- **Agent Gateway DO** (external repo): `_forwardInterrupt` HTTP POST
  from the WS interrupt handler (LOBE-7147).

With only this PR merged, clicking stop will clear the local UI state
and send the WS frame correctly — the server-side loop keeps running
until those three are merged too.

## Tests

- `conversationControl.test.ts`: +1 — stopGenerateMessage cancels
  `execServerAgentRuntime`, invokes the onCancel handler, sets
  `isAborting: true`.
- `gateway.test.ts`: +1 — `executeGatewayAgent` registers a handler
  against the local opId, handler invokes `interruptGatewayAgent`
  with the server opId.

All 123 touched-slice tests pass; type-check clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🔨 chore: switch Gateway stop to direct tRPC instead of WS roundtrip

Rewiring only — no new behaviour on top of the previous commit. See
the discussion in PR #13815 for the full reasoning.

TL;DR the WS-based path (client → Agent Gateway WS → DO forwards
HTTP → cloud route → Redis LPUSH → loop LPOP) has the same end-effect
as the tRPC-direct path (client → tRPC → AgentRuntimeService
.interruptOperation → DB state flip), except:

- the tRPC path is one hop instead of three
- the tRPC path reuses infrastructure that's *already on canary* —
  `aiAgentService.interruptTask` → `AiAgentService.interruptTask` →
  `AgentRuntimeService.interruptOperation` → `coordinator.saveAgentState`
  with status='interrupted' — and the existing step-boundary polling
  in `executeStep` (AgentRuntimeService.ts:474, 565) already picks it up
- zero new server code required; zero Agent Gateway (external repo)
  coordination required

The only reason the WS path was in the original spec (LOBE-7142) was
symmetry with the Phase 6.4 tool_execute/tool_result path, but
`interrupt` is a one-shot control signal, not stream data — there's
no actual benefit to routing it through the same channel. Mid-step
abort would require threading an AbortSignal into `runtime.step(...)`,
which WS doesn't help with either.

Closes out the need for LOBE-7145 / LOBE-7146 / LOBE-7147.

Changes:
- `gateway.ts`: both `executeGatewayAgent` and
  `reconnectToGatewayOperation` register the cancel handler against
  the local op id, but the handler body now calls
  `aiAgentService.interruptTask({ operationId: serverOpId })` via
  tRPC instead of `this.interruptGatewayAgent(serverOpId)` (which sent
  the WS interrupt frame).
- `gateway.test.ts`: adjust the one new test case to verify the
  tRPC call rather than the WS-path spy; add `interruptTask` to the
  `aiAgentService` mock.

`AgentStreamClient.sendInterrupt()` and `interruptGatewayAgent()` are
kept as-is — public API, might be useful elsewhere. Just not called
from the cancel handler anymore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:41:45 +08:00
Rdmclin2 636a3b77c3 🐛 fix: message gateway queue error (#13816)
* fix: gateway sync

* fix: skip  error connection

* feat: add disconnect all &  MESSAGE_GATEWAY_ENABLED env vairable

* chore: add gateway test case

* chore: clean lobehub connnections when switch to message gateway

* chore: optimize disconnect all

* chore: disconnect gateway connnections when using lobehub gateway

* chore: clean up exsiting gateway connections after reconnect and avoid gateway callback when not enabled
2026-04-14 22:10:17 +08:00
Arvin Xu c70ac84da7 feat: support run client tools in agent gateway mode (#13792)
*  feat: receive and execute executor=client tools on desktop Electron

Frontend half of LOBE-7076 (Phase 6.4). Pairs with server PR #13790,
which adds the `clientRuntime` signal + `hasClientExecutor` gate so
`local-system` and stdio MCP can enter the manifest for desktop callers.

Data flow, client side:

  Agent Gateway WS
     └─ tool_execute event ──► AgentStreamClient
            └─ 'agent_event' ──► gatewayEventHandler (case 'tool_execute')
                    └─ internal_executeClientTool (fire-and-forget)
                          ├─ parse args → params
                          ├─ mark pendingClientToolExecutions[toolCallId]
                          ├─ dispatch: builtin → invokeExecutor,
                          │            else   → mcpService.invokeMcpToolCall
                          ├─ clear pending
                          └─ AgentStreamClient.sendToolResult(...)
                                └─ WS → /api/agent/tool-result → LPUSH
                                       → server BLPOP unblocks → loop continues

Key guarantees:

- `internal_executeClientTool` never throws; ALL error paths (parse
  failure, no executor match, thrown executor, missing connection, MCP
  error) still call `sendToolResult({ success: false, error })`. The
  server's BLPOP must never hang on a silent client.
- `case 'tool_execute'` uses `void`, not `await`. A long-running tool
  must not block subsequent `stream_chunk` / `tool_end` events on the
  same WebSocket.
- UI loading state is kept separate from `toolCallingStreamIds` (the
  LLM-streaming animation) via a dedicated
  `pendingClientToolExecutions: Record<toolCallId, true>` map, so a
  renderer can show a distinct "running on device" indicator without
  entangling existing selectors.

Client → server signal:

`executeGatewayAgent` now passes `clientRuntime: isDesktop ? 'desktop' : 'web'`
so the server knows this Electron caller can receive `tool_execute`.

Tests: 39 new cases across AgentStreamClient / internal_executeClientTool
/ gatewayEventHandler covering success, error, MCP fallback, pending
state lifecycle, and fire-and-forget semantics. 148 total in affected
suites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: pass server operationId to tool_result dispatch (operationId mismatch)

The gateway event handler received `tool_execute` events but the resulting
`internal_executeClientTool` call looked up `gatewayConnections` by the
*local* operation id (e.g. `op_8chrnd`) instead of the *server-side*
operation id (e.g. `op_1776171452938_...`) the WS connection is actually
keyed on. `conn` was therefore always `undefined`, the early-return in
`send(...)` swallowed the response, and the server's BLPOP waiter timed
out after 60 s.

This was reproducible on canary E2E: server logs showed
`dispatching client tool lobe-local-system/readLocalFile` followed by
`client tool ... timed out after 60027ms`, with no outbound `tool_result`
frame ever reaching the Agent Gateway.

Fix: thread a distinct `gatewayOperationId` through
`createGatewayEventHandler` and use it for the `case 'tool_execute'`
dispatch. The existing `operationId` (used for `dispatchContext` →
`internal_dispatchMessage` keying) is untouched. Both `executeGatewayAgent`
and `reconnectToGatewayOperation` now pass the server id explicitly; when
a caller omits it, it falls back to the local `operationId` for backwards
compatibility.

Verified live on canary: WS now shows
`[in] tool_execute` → `[out] tool_result success=true content=...` and
the agent returns the real local-file contents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:30:13 +08:00
LiJian 116495bd1e 🐛 fix: slove the execAgents tools exec types not correct (#13807)
* fix: slove the execAgents tools exec types not correct

* fix: should inject source:discovery when tools type is lost

* fix: delete the source inject test
2026-04-14 17:51:08 +08:00
LiJian 922f7ace41 🐛 fix: fixed the when call saveCreds the bad request problem (#13809)
* fix: fixed the when call saveCreds the bad request problem

* fix: add the empty kv checked
2026-04-14 17:51:00 +08:00
YuTengjing b369c53bda 🐛 fix(model-bank): disable GLM-5.1 built-in search in LobeHub (#13806) 2026-04-14 17:05:42 +08:00
René Wang 5ecccf4b9e 📝 docs: add April 13 weekly changelog (#13808) 2026-04-14 17:02:10 +08:00
Rdmclin2 f9fbd45fee feat: discord support slash commands and DM (#13805)
* fix: slack not respond to text commands

* feat: add slack slash commands instructions

* chore: add slack validate in test connections

* chore: update slack docs

* chore: remove text commands for slack
2026-04-14 16:48:16 +08:00
LiJian 0b490a7268 🐛 fix: execAgent should get builtin discoverable tools into manifests (#13804)
* fix: execAgent should get all tools manifests

* fix: should add the tools source into payload source

* fix: add the discoverable tools into tools enginer

* fix: update the test, should include the discoverable tools
2026-04-14 16:07:49 +08:00
Innei a9c5badb80 ♻️ refactor(navigation): stable navigate hook and imperative routing (#13795)
*  fix: implement stable navigation hook and refactor navigation handling

- Introduced `useStableNavigate` hook to provide a stable `navigate` function that can be used across the application.
- Refactored components to utilize the new stable navigation approach, replacing direct access to the navigation function from the global store.
- Updated `NavigatorRegistrar` to sync the `navigate` function into a ref for consistent access.
- Removed deprecated navigation handling from various components and actions, ensuring a cleaner and more maintainable codebase.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix: refactor navigation handling to prevent state mutation

- Updated navigation reference handling in the global store to use a dedicated function for creating navigation refs, ensuring that the initial state is not mutated by nested writes.
- Adjusted tests and components to utilize the new navigation ref creation method, enhancing stability and maintainability of navigation logic.

Signed-off-by: Innei <tukon479@gmail.com>

*  test: mock Electron's net.fetch in unit tests

- Added a mock for Electron's net.fetch in the AuthCtr and BackendProxyProtocolManager tests to ensure proper handling of remote server requests.
- This change allows tests to simulate network interactions without relying on the actual fetch implementation, improving test reliability.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-14 13:28:12 +08:00
LiJian cd0f65210c ♻️ refactor: update the codesandbox systemRole(preinstalled_software) (#13799)
refactor: update the codesandbox systemRole(preinstalled_software)
2026-04-14 12:11:44 +08:00
Arvin Xu 24be35fd84 🐛 fix(agent-runtime): resolve S3 image keys when refreshing messages (#13794)
messageModel.query() calls inside RuntimeExecutors were missing a
postProcessUrl callback, so imageList/videoList/fileList entries retained
raw S3 keys (e.g. `files/user_xxx/icon.png`). After the first tool batch,
the refreshed state fed those raw keys straight into the next LLM call,
and providers like Anthropic reject anything that isn't an absolute URL or
data URI ("Invalid image URL"). Wire a lazy FileService-backed
postProcessUrl into all three query sites (topic reference resolution,
compression, and post-batch refresh) so imageLists stay resolved across
multi-step operations.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:29:49 +08:00
Arvin Xu 46adf43453 🐛 fix: dispatch executor=client tools to desktop callers when DEVICE_GATEWAY is configured (#13793)
🐛 fix: dispatch executor=client tools to desktop caller even with DEVICE_GATEWAY configured

Two fixes to make Phase 6.4 (LOBE-7076) actually reach a desktop caller on
canary, where DEVICE_GATEWAY is configured and a separate remote device
may be registered.

### 1. AgentToolsEngine: suppress RemoteDevice for desktop callers

The `lobe-remote-device` tool is meant for the legacy "tunnel commands to
a separately registered desktop" flow. When the caller itself is a
desktop Electron client, that's redundant — and worse, the LLM was
picking `listOnlineDevices` + `activateDevice` *first*, then routing the
subsequent `readLocalFile` to a different registered host (a remote
Linux VM in our E2E trace, returning ENOENT for a path that only exists
on the caller).

Adds `&& !hasClientExecutor` to the RemoteDevice enable rule. Desktop
callers now see only `local-system` in their manifest.

### 2. aiAgent.execAgent: mark executor='client' for desktop callers

The existing gate was `if (!gatewayConfigured) { executorMap[...] = 'client' }`.
On canary, `gatewayConfigured === true` (DEVICE_GATEWAY set), so
`local-system` / stdio MCP stayed server-executed and were dispatched to
the Remote Device proxy instead of back to the caller's Agent Gateway WS.

Extends the gate to:
  `if (clientRuntime === 'desktop' || !gatewayConfigured)`

So a caller that explicitly signals it can receive `tool_execute` bypasses
the DEVICE_GATEWAY heuristic. Legacy behaviour unchanged for web callers
and for callers that don't send `clientRuntime`.

### Tests

- AgentToolsEngine: +1 case verifying RemoteDevice is suppressed when
  `clientRuntime === 'desktop'` even with `gatewayConfigured: true`
- execAgent.deviceToolPipeline: +3 cases
  - local-system gets executor='client' for desktop + DEVICE_GATEWAY
  - stdio MCP gets executor='client' for desktop + DEVICE_GATEWAY
  - web caller preserves legacy routing (executor unset)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:22:18 +08:00
Arvin Xu f0a811ef83 🐛 fix: enable executor=client tools for desktop Electron callers (#13790)
*  feat: enable executor=client tools for desktop Electron callers

Adds a `clientRuntime` signal to execAgent so the server knows the caller
itself can execute `executor: 'client'` tools (local-system, stdio MCP) over
its Agent Gateway WebSocket. This is the missing server piece for Phase 6.4
(LOBE-7076): previously `local-system` only entered the manifest when a
*separately registered* remote device was online & auto-activated, so a
desktop Electron caller sitting on the other end of the Gateway WS could
never actually be dispatched to via `tool_execute`.

The new signal is orthogonal to the legacy device-proxy `deviceContext` —
it describes the caller itself, not a third-party device. The enable rule
for LocalSystemManifest simply gets one extra OR branch:

  local && gatewayConfigured && (hasClientExecutor || legacy-device-online-activated)

`toolExecutorMap[LocalSystemManifest.identifier] = 'client'` (LOBE-7067)
then kicks in as soon as the manifest entry is present, so
`RuntimeExecutors.call_tool` (LOBE-7068) will push `tool_execute` over the
Agent Gateway WS to this caller.

Plumbing:
- packages/types: `ExecAgentParams.clientRuntime?: 'desktop' | 'web'`
- lambda router: accepts + forwards `clientRuntime`
- aiAgent service: forwards to `createServerAgentToolsEngine`
- AgentToolsEngine: +1 field, +1 OR branch in LocalSystem enable rule.
  Zero changes to `runtimeMode` / `platform` / `RemoteDeviceManifest` /
  `deviceContext` semantics.

Tests: 3 new cases in AgentToolsEngine covering desktop / web / gateway-off
branches; 3 new cases in execAgent.deviceToolPipeline verifying the
`clientRuntime` param is forwarded verbatim.

Follow-up (separate PR): frontend receives `tool_execute`, runs the tool
via Electron IPC, and sends `tool_result` back over the same WS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ♻️ refactor: untangle runtime / platform / device-proxy flags in AgentToolsEngine

Renames and separates two orthogonal concerns that used to share the
misleading `isDesktopClient` name:

- `hasClientExecutor` — caller itself can receive `tool_execute` over
  the Agent Gateway WS (Phase 6.4). Property of the caller.
- `hasDeviceProxy` — server has a device-proxy configured that tunnels
  to a separately registered device (legacy Remote Device). Property of
  the server.

`platform` is now derived from the caller (`clientRuntime`) first,
falling back to the device-proxy signal for backwards compat — it was
previously derived purely from the server's proxy config, which
conflated "server can reach a desktop" with "caller is a desktop".

LocalSystem enable rule restructured to read in natural order:
  runtimeMode === 'local'         // user opted in
  && hasDeviceProxy               // server has a Gateway path
  && (hasClientExecutor || ...)   // an execution target exists

Behavior is identical to the previous commit; this is a pure rename /
regrouping refactor. 38 existing tests still pass without changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: decouple hasClientExecutor from hasDeviceProxy in local-system gate

The previous rule required `hasDeviceProxy` as a shared prerequisite for
BOTH enable paths, which is wrong: `hasDeviceProxy` reflects the legacy
device-proxy (`deviceProxy.isConfigured`), while Phase 6.4's
`tool_execute` rides the Agent Gateway WebSocket that this request is
already on. The two systems are orthogonal — a desktop caller on the
Gateway WS can receive `tool_execute` without any device-proxy being
configured server-side.

Correct enable rule:

  runtimeMode === 'local'
  && (hasClientExecutor                              // Phase 6.4, self
      || (hasDeviceProxy && deviceOnline && autoActivated))  // legacy

Updated the `still requires gateway to be configured` test, which was
asserting the incorrect coupling, to instead verify that agent-level
`runtimeMode.desktop === 'none'` opt-out is respected for desktop
callers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 01:12:45 +08:00
Arvin Xu 10914ff015 🐛 fix: add image-to-video options to CLI generate video command (#13788)
*  feat: add image-to-video options to CLI generate video command

Why: CLI only supported text-to-video. Backend already accepts imageUrl/endImageUrl
for image-to-video, but the CLI had no way to pass them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* update cli version

* update cli version

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 01:12:10 +08:00
Adam Bellinson b857ae6c57 🐛 fix(desktop): use Electron net.fetch for remote server requests (#13400)
* use Electron's net.fetch() so system trusted certs are honored

* 🐛 fix(tests): mock netFetch in unit tests broken by net.fetch migration

Both LocalFileCtr and RemoteServerConfigCtr tests were patching
global.fetch / stubGlobal, which no longer intercepts calls now that
the controllers route through Electron's net.fetch via @/utils/net-fetch.
Hoist the fetch mock and point vi.mock('@/utils/net-fetch') at it directly.
2026-04-14 00:45:54 +08:00
Arvin Xu e11c89fc48 🐛 fix(agent-runtime): skip client-executor marking when gateway is configured (#13787)
Tools flagged as `executor: 'client'` are dispatched via `dispatchClientTool`
through the Agent Gateway WS path. In cloud deployments where the gateway is
configured but no desktop device is connected, this path 404s on
`/api/operations/tool-execute` and the tool fails with `dispatch_failed`.

Only mark local-system and stdio MCP plugins as `'client'` when the gateway
is NOT configured (standalone Electron). When deviceContext is available,
tool routing goes through the RemoteDevice proxy instead.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 00:25:19 +08:00
LiJian b9a136f9f1 🐛 fix: slove the execAgent not have lobehub skills & builtin tools (#13781)
fix: slove the execAgent not have lobehub skills & builtin tools
2026-04-13 18:15:54 +08:00
Rdmclin2 809e1e0716 🐛 fix: message gateway ensure running (#13780)
fix: message gateway ensure running
2026-04-13 17:43:18 +08:00
Octopus 7953cf5b5a fix(desktop): use low urgency for Linux notifications to prevent GNOME Shell freeze (#13767)
🐛 fix(desktop): use low urgency for Linux notifications to prevent GNOME Shell freeze

On Linux/GNOME Shell, desktop notifications with urgency 'normal' appear
as banner pop-ups. Clicking the dismiss (X) button on these banners can
cause the system to freeze for 30-45 seconds due to heavy gnome-shell
CPU and memory usage.

Setting urgency to 'low' on Linux routes notifications to the message
tray instead of displaying them as banners, which avoids the problematic
X button interaction. The urgency option is ignored on macOS and Windows.

Fixes #13538

Co-authored-by: octo-patch <octo-patch@github.com>
2026-04-13 16:19:44 +08:00
LobeHub Bot 012214205e 🌐 chore: translate non-English comments to English in database-tests (#13771)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:14:09 +08:00
Arvin Xu f0f2feb015 🔨 chore(task): add participants to task.list response (#13778)
*  feat(task): add participants array to task.list response

Return a participants array per task (id / type / avatar / name) so
clients can show avatar groups on task cards. For now participants
only contains the assignee agent; future iterations can aggregate
comment authors and topic executors.

Also extract TaskItem into @lobechat/types as an explicit type
definition so it no longer relies on drizzle schema inference.

* ♻️ refactor(task): extract NewTask to @lobechat/types

Remove the drizzle $inferInsert NewTask from schemas and define it
explicitly in @lobechat/types alongside TaskItem.

*  test(task): cover participants in task.list response
2026-04-13 16:09:53 +08:00
Innei f439fb913a 🐛 fix(editor): bump @lobehub/editor to 4.8.1 (#13756)
🐛 fix: bump @lobehub/editor to 4.8.1
2026-04-13 14:17:39 +08:00
Neko 6966d366d1 🐛 fix(userMemories): should trim way too long bm25 (#13744) 2026-04-13 13:45:37 +08:00
LiJian f89adb36b3 🐛 fix: slove the agent details pages not get the agent config always lo… (#13772)
fix: slove the agent details pages not get the agent config always loading problem
2026-04-13 12:46:10 +08:00
116 changed files with 3336 additions and 566 deletions
+4 -2
View File
@@ -413,7 +413,9 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# #### Message Gateway (IM Integration) ##
# #######################################
# URL of the message-gateway Cloudflare Worker for unified IM platform connection management
# When set, LobeHub delegates all platform connections to the external gateway
# External message-gateway for unified IM platform connection management.
# Set ENABLED=1 to activate. To migrate away, remove ENABLED first (keep URL/TOKEN)
# so LobeHub can automatically disconnect leftover gateway connections.
# MESSAGE_GATEWAY_ENABLED=1
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
+1 -1
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.4" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.6" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.4",
"version": "0.0.6",
"type": "module",
"bin": {
"lh": "./dist/index.js",
+27 -1
View File
@@ -37,7 +37,25 @@ export async function getAuthInfo(): Promise<AuthInfo> {
};
}
export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers' | 'serverUrl'>> {
export type AgentStreamTokenType = 'jwt' | 'apiKey';
export interface AgentStreamAuthInfo {
headers: Record<string, string>;
serverUrl: string;
/**
* Raw token value (without header prefix). Used for WebSocket auth messages
* where header-based auth is not available.
*/
token: string;
/**
* How the token should be verified by downstream services (agent gateway WS).
* jwt → validate with JWKS
* apiKey → validate by calling /api/v1/users/me
*/
tokenType: AgentStreamTokenType;
}
export async function getAgentStreamAuthInfo(): Promise<AgentStreamAuthInfo> {
const serverUrl = resolveServerUrl();
const envJwt = process.env.LOBEHUB_JWT;
@@ -45,6 +63,8 @@ export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers'
return {
headers: { 'Oidc-Auth': envJwt },
serverUrl,
token: envJwt,
tokenType: 'jwt',
};
}
@@ -53,6 +73,8 @@ export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers'
return {
headers: { 'X-API-Key': envApiKey },
serverUrl,
token: envApiKey,
tokenType: 'apiKey',
};
}
@@ -64,11 +86,15 @@ export async function getAgentStreamAuthInfo(): Promise<Pick<AuthInfo, 'headers'
return {
headers: {},
serverUrl,
token: '',
tokenType: 'jwt',
};
}
return {
headers: { 'Oidc-Auth': result.credentials.accessToken },
serverUrl,
token: result.credentials.accessToken,
tokenType: 'jwt',
};
}
+13 -2
View File
@@ -258,6 +258,10 @@ export function registerAgentCommand(program: Command) {
'--device <target>',
'Target device ID, or use "local" for the current connected device',
)
.option(
'--no-headless',
"Disable headless mode and wait for human approval on tool calls (default: headless — tools auto-run, matching the CLI's non-interactive nature)",
)
.option('--json', 'Output full JSON event stream')
.option('-v, --verbose', 'Show detailed tool call info')
.option('--replay <file>', 'Replay events from a saved JSON file (offline)')
@@ -267,6 +271,7 @@ export function registerAgentCommand(program: Command) {
agentId?: string;
autoStart?: boolean;
device?: string;
headless?: boolean;
json?: boolean;
prompt?: string;
replay?: string;
@@ -340,6 +345,11 @@ export function registerAgentCommand(program: Command) {
if (options.slug) input.slug = options.slug;
if (options.topicId) input.appContext = { topicId: options.topicId };
if (options.autoStart === false) input.autoStart = false;
// commander's --no-headless sets `headless` to false. Anything else
// (undefined, true) → headless mode is on and tool calls auto-execute.
if (options.headless !== false) {
input.userInterventionConfig = { approvalMode: 'headless' };
}
const result = await client.aiAgent.execAgent.mutate(input as any);
const r = result as any;
@@ -355,16 +365,17 @@ export function registerAgentCommand(program: Command) {
}
// 2. Connect to stream (WebSocket via Gateway, or fallback to SSE)
const { serverUrl, headers } = await getAgentStreamAuthInfo();
const { serverUrl, headers, token, tokenType } = await getAgentStreamAuthInfo();
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
if (agentGatewayUrl) {
const token = headers['Oidc-Auth'] || headers['X-API-Key'] || '';
await streamAgentEventsViaWebSocket({
gatewayUrl: agentGatewayUrl,
json: options.json,
operationId,
serverUrl,
token,
tokenType,
verbose: options.verbose,
});
} else {
+42
View File
@@ -270,6 +270,48 @@ describe('generate command', () => {
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Video generation started'));
});
it('should pass image-to-video params', async () => {
mockTrpcClient.generationTopic.createTopic.mutate.mockResolvedValue('topic-3');
mockTrpcClient.video.createVideo.mutate.mockResolvedValue({
data: { generationId: 'gen-v2' },
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'video',
'a cat waving',
'--model',
'cogvideox',
'--provider',
'zhipu',
'--image',
'https://example.com/first.png',
'--end-image',
'https://example.com/last.png',
'--images',
'https://example.com/a.png',
'https://example.com/b.png',
]);
expect(mockTrpcClient.video.createVideo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
generationTopicId: 'topic-3',
model: 'cogvideox',
params: {
endImageUrl: 'https://example.com/last.png',
imageUrl: 'https://example.com/first.png',
imageUrls: ['https://example.com/a.png', 'https://example.com/b.png'],
prompt: 'a cat waving',
},
provider: 'zhipu',
}),
);
});
});
describe('tts', () => {
+10 -1
View File
@@ -6,13 +6,16 @@ import { getTrpcClient } from '../../api/client';
export function registerVideoCommand(parent: Command) {
parent
.command('video <prompt>')
.description('Generate a video from text')
.description('Generate a video from text or image(s)')
.requiredOption('-m, --model <model>', 'Model ID')
.requiredOption('-p, --provider <provider>', 'Provider name')
.option('--aspect-ratio <ratio>', 'Aspect ratio (e.g. 16:9)')
.option('--duration <sec>', 'Duration in seconds')
.option('--resolution <res>', 'Resolution (e.g. 720p, 1080p)')
.option('--seed <n>', 'Random seed')
.option('--image <url>', 'First-frame image URL (image-to-video)')
.option('--images <urls...>', 'Multiple reference image URLs')
.option('--end-image <url>', 'Last-frame image URL')
.option('--json', 'Output raw JSON')
.action(
async (
@@ -20,6 +23,9 @@ export function registerVideoCommand(parent: Command) {
options: {
aspectRatio?: string;
duration?: string;
endImage?: string;
image?: string;
images?: string[];
json?: boolean;
model: string;
provider: string;
@@ -35,6 +41,9 @@ export function registerVideoCommand(parent: Command) {
if (options.duration) params.duration = Number.parseInt(options.duration, 10);
if (options.resolution) params.resolution = options.resolution;
if (options.seed) params.seed = Number.parseInt(options.seed, 10);
if (options.image) params.imageUrl = options.image;
if (options.images && options.images.length > 0) params.imageUrls = options.images;
if (options.endImage) params.endImageUrl = options.endImage;
const result = await client.video.createVideo.mutate({
generationTopicId: topicId as string,
+28 -1
View File
@@ -279,8 +279,10 @@ describe('streamAgentEventsViaWebSocket', () => {
await flush();
const ws = capturedWs!;
// Note: serverUrl is not set here, and JSON.stringify drops undefined keys,
// so the parsed auth message will not contain a `serverUrl` field.
expect(ws.sent.map((s) => JSON.parse(s))).toEqual([
{ token: 'test-token', type: 'auth' },
{ token: 'test-token', tokenType: 'jwt', type: 'auth' },
{ lastEventId: '', type: 'resume' },
]);
@@ -288,6 +290,31 @@ describe('streamAgentEventsViaWebSocket', () => {
await promise;
});
it('should send tokenType=apiKey and serverUrl when the caller uses an API key', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
serverUrl: 'https://app.lobehub.com',
token: 'lh_sk_abc',
tokenType: 'apiKey',
});
await flush();
const ws = capturedWs!;
// serverUrl is forwarded so the gateway can call back to /api/v1/users/me
// to verify the API key.
expect(ws.sent.map((s) => JSON.parse(s))[0]).toEqual({
serverUrl: 'https://app.lobehub.com',
token: 'lh_sk_abc',
tokenType: 'apiKey',
type: 'auth',
});
ws.simulateMessage({ id: '1', type: 'session_complete' });
await promise;
});
it('should render agent_event messages using existing renderEvent', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
+17 -3
View File
@@ -20,7 +20,18 @@ interface StreamOptions {
interface WebSocketStreamOptions extends StreamOptions {
gatewayUrl: string;
operationId: string;
/**
* LobeHub server URL the gateway should call back to when verifying
* an apiKey token (via `/api/v1/users/me`). Required when
* `tokenType === 'apiKey'`; ignored for JWT.
*/
serverUrl?: string;
token: string;
/**
* How the gateway should verify `token`. `jwt` is the default for
* backwards compatibility with existing callers.
*/
tokenType?: 'jwt' | 'apiKey';
}
/**
@@ -168,13 +179,13 @@ const HEARTBEAT_INTERVAL = 30_000;
export async function streamAgentEventsViaWebSocket(
options: WebSocketStreamOptions,
): Promise<void> {
const { gatewayUrl, operationId, token, ...streamOpts } = options;
const { gatewayUrl, operationId, serverUrl, token, tokenType = 'jwt', ...streamOpts } = options;
const wsUrl = urlJoin(
gatewayUrl.replace(/^http/, 'ws'),
`/ws?operationId=${encodeURIComponent(operationId)}`,
);
log.debug(`Connecting to gateway: ${wsUrl}`);
log.debug(`Connecting to gateway: ${wsUrl} (auth: ${tokenType})`);
return new Promise<void>((resolve, reject) => {
const ws = new WebSocket(wsUrl);
@@ -192,7 +203,10 @@ export async function streamAgentEventsViaWebSocket(
};
ws.onopen = () => {
ws.send(JSON.stringify({ token, type: 'auth' }));
// `serverUrl` is required so the gateway can call back to verify an
// apiKey token. Harmless (but unused) for JWT, so we always include it
// when available to match the device-gateway-client contract.
ws.send(JSON.stringify({ serverUrl, token, tokenType, type: 'auth' }));
};
ws.onmessage = (event) => {
+4 -3
View File
@@ -12,6 +12,7 @@ import { BrowserWindow, shell } from 'electron';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import { appendVercelCookie } from '@/utils/http-headers';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
import { ControllerModule, IpcMethod } from './index';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
@@ -360,10 +361,10 @@ export default class AuthCtr extends ControllerModule {
logger.debug(`Polling for credentials: ${url.toString()}`);
// Send HTTP request directly
// Use Electron net.fetch to respect system CA store (self-signed/private CA certs)
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
appendVercelCookie(headers);
const response = await fetch(url.toString(), { headers, method: 'GET' });
const response = await netFetch(url.toString(), { headers, method: 'GET' });
// Check response status
if (response.status === 404) {
@@ -481,7 +482,7 @@ export default class AuthCtr extends ControllerModule {
'Content-Type': 'application/x-www-form-urlencoded',
};
appendVercelCookie(tokenHeaders);
const response = await fetch(tokenUrl.toString(), {
const response = await netFetch(tokenUrl.toString(), {
body,
headers: tokenHeaders,
method: 'POST',
@@ -48,6 +48,7 @@ import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
import ContentSearchService from '@/services/contentSearchSrv';
import FileSearchService from '@/services/fileSearchSrv';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
import { ControllerModule, IpcMethod } from './index';
@@ -341,7 +342,7 @@ export default class LocalFileCtr extends ControllerModule {
}
try {
const response = await fetch(url);
const response = await netFetch(url);
if (!response.ok) {
throw new Error(
`Failed to download skill package: ${response.status} ${response.statusText}`,
@@ -3,7 +3,7 @@ import type {
ShowDesktopNotificationParams,
} from '@lobechat/electron-client-ipc';
import { app, Notification } from 'electron';
import { macOS, windows } from 'electron-is';
import { linux, macOS, windows } from 'electron-is';
import { getIpcContext } from '@/utils/ipc';
import { createLogger } from '@/utils/logger';
@@ -131,7 +131,12 @@ export default class NotificationCtr extends ControllerModule {
silent: params.silent || false,
timeoutType: 'default',
title: params.title,
urgency: 'normal',
// On Linux/GNOME Shell, urgency 'normal' causes notifications to appear as banners.
// Clicking the dismiss (X) button on such banners can freeze the system for 30-45 seconds
// due to heavy gnome-shell processing. Using 'low' urgency routes notifications to the
// message tray instead, preventing the banner's X button from being shown.
// The urgency option is ignored on macOS and Windows.
urgency: linux() ? 'low' : 'normal',
});
// Add more event listeners for debugging
@@ -9,6 +9,7 @@ import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import { appendVercelCookie } from '@/utils/http-headers';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
import { ControllerModule, IpcMethod } from './index';
@@ -485,7 +486,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
'Content-Type': 'application/x-www-form-urlencoded',
};
appendVercelCookie(headers);
const response = await fetch(tokenUrl.toString(), { body, headers, method: 'POST' });
const response = await netFetch(tokenUrl.toString(), { body, headers, method: 'POST' });
if (!response.ok) {
// Try to parse error response
@@ -29,6 +29,11 @@ vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
net: {
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
global.fetch(input as any, init as any),
),
},
shell: {
openExternal: vi.fn().mockResolvedValue(undefined),
},
@@ -5,11 +5,14 @@ import { type App } from '@/core/App';
import LocalFileCtr from '../LocalFileCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
const { ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
fetchMock: vi.fn(),
}));
const fetchMock = vi.fn();
vi.mock('@/utils/net-fetch', () => ({
netFetch: fetchMock,
}));
// Mock logger
vi.mock('@/utils/logger', () => ({
@@ -37,8 +40,6 @@ vi.mock('electron', () => ({
},
}));
vi.stubGlobal('fetch', fetchMock);
// Mock node:fs/promises and node:fs
vi.mock('node:fs/promises', () => ({
access: vi.fn(),
@@ -41,6 +41,7 @@ vi.mock('electron', () => {
// Mock electron-is
vi.mock('electron-is', () => ({
linux: vi.fn(() => false),
macOS: vi.fn(() => false),
windows: vi.fn(() => false),
}));
@@ -180,6 +181,26 @@ describe('NotificationCtr', () => {
expect(result).toEqual({ success: true });
});
it('should use low urgency on Linux to prevent GNOME Shell freeze', async () => {
const { linux } = await import('electron-is');
const { Notification } = await import('electron');
vi.mocked(linux).mockReturnValue(true);
vi.mocked(Notification.isSupported).mockReturnValue(true);
mockBrowserWindow.isVisible.mockReturnValue(false);
const promise = controller.showDesktopNotification(params);
vi.advanceTimersByTime(100);
await promise;
expect(Notification).toHaveBeenCalledWith(
expect.objectContaining({
urgency: 'low',
}),
);
vi.mocked(linux).mockReturnValue(false);
});
it('should show notification when window is minimized', async () => {
const { Notification } = await import('electron');
vi.mocked(Notification.isSupported).mockReturnValue(true);
@@ -5,8 +5,13 @@ import type { App } from '@/core/App';
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
const { ipcMainHandleMock, mockFetch } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
mockFetch: vi.fn(),
}));
vi.mock('@/utils/net-fetch', () => ({
netFetch: mockFetch,
}));
// Mock logger
@@ -420,13 +425,6 @@ describe('RemoteServerConfigCtr', () => {
});
describe('refreshAccessToken', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
it('should return error when remote server is not active', async () => {
mockStoreManager.get.mockImplementation((key) => {
if (key === 'dataSyncConfig') {
@@ -4,6 +4,7 @@ import { BrowserWindow, type Session } from 'electron';
import { isDev } from '@/const/env';
import { appendVercelCookie } from '@/utils/http-headers';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
interface BackendProxyProtocolManagerOptions {
getAccessToken: () => Promise<string | undefined | null>;
@@ -137,7 +138,7 @@ export class BackendProxyProtocolManager {
let upstreamResponse: Response;
try {
upstreamResponse = await fetch(rewrittenUrl, requestInit);
upstreamResponse = await netFetch(rewrittenUrl, requestInit);
} catch (error) {
this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
@@ -43,6 +43,11 @@ vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: vi.fn(),
},
net: {
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
global.fetch(input as any, init as any),
),
},
}));
describe('BackendProxyProtocolManager', () => {
+14
View File
@@ -0,0 +1,14 @@
import { net } from 'electron';
/**
* Fetch using Electron's net module (Chromium networking stack).
*
* Unlike Node.js `fetch`, `net.fetch` respects the OS certificate store
* (e.g. macOS Keychain, Windows Certificate Store), so self-signed or
* private-CA certificates trusted at the system level work automatically.
*
* This must be called only after `app.whenReady()` has resolved.
*/
export const netFetch: typeof globalThis.fetch = (input, init?) => {
return net.fetch(input as any, init as any);
};
+2 -1
View File
@@ -466,5 +466,6 @@
"https://github.com/user-attachments/assets/facdc83c-e789-4649-8060-7f7a10a1b1dd": "/blog/assets05b20e40c03ced0ec8707fed2e8e0f25.webp",
"https://github.com/user-attachments/assets/fcdfb9c5-819a-488f-b28d-0857fe861219": "/blog/assets8477415ecec1f37e38ab38ff1217d0a7.webp",
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp"
"https://file.rene.wang/clipboard-1775701725582-123f8f8cf73f8.png": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
"https://file.rene.wang/changlog-04-14.png": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp"
}
@@ -0,0 +1,34 @@
---
title: Agent Gateway & Customizable Sidebar
description: >-
Server-side agent execution via Gateway mode, customizable sidebar layout,
agent workspace with document management, and new model support.
tags:
- Gateway
- Sidebar
- Agent Workspace
- Task Manager
---
# Agent Gateway & Customizable Sidebar
Server-side agent execution over WebSocket, a fully customizable sidebar, and a new agent workspace for managing documents and tasks.
## Key Updates
- Gateway mode: agents now execute server-side and stream results back over WebSocket, with auto-reconnect when switching topics and seamless resume after disconnects
- Customizable sidebar: choose which items appear in the sidebar and reorder them through a new customize modal, plus a recents section with search, rename, and quick actions
- Agent workspace: a right-side panel for managing agent documents — browse, rename, delete files, and view document history all in one place
- Task manager: a dedicated task manager view with its own topic state, so running tasks no longer interfere with your main conversations
- Prompt rewrite & translate: rewrite or translate your prompt directly in the chat input before sending
- Desktop CLI: the LobeHub CLI is now embedded in the desktop app and can be installed to your PATH from settings
- Screen capture: capture your screen with an overlay picker and attach it directly to a conversation
- New models: GLM-5.1 from Zhipu, Seedance 2.0 video generation, and a new StreamLake provider
## Experience Improvements
- Desktop app now uses Electron's native fetch for remote requests, improving connection reliability
- Loading states during optimistic updates prevent flickering when the assistant is thinking
- Agent details pages load correctly on refresh instead of showing a perpetual spinner
- Improved error classification for insufficient balance and deactivated accounts shows clearer messages
- Fixed a context engine crash when non-string content was passed to document injection
@@ -0,0 +1,32 @@
---
title: Agent 网关与可自定义侧边栏
description: 通过网关模式实现服务端智能体执行、可自定义侧边栏布局、带文档管理的智能体工作区,以及新模型支持。
tags:
- 网关
- 侧边栏
- 智能体工作区
- 任务管理器
---
# Agent 网关与可自定义侧边栏
通过 WebSocket 实现服务端智能体执行、完全可自定义的侧边栏,以及用于管理文档和任务的全新智能体工作区。
## 重要更新
- 网关模式:智能体现在在服务端执行并通过 WebSocket 实时推送结果,切换话题时自动重连,断线后无缝恢复
- 可自定义侧边栏:通过新的自定义弹窗选择侧边栏显示哪些项目并调整排序,还新增了支持搜索、重命名和快捷操作的「最近」板块
- 智能体工作区:右侧面板用于管理智能体文档 —— 在同一界面中浏览、重命名、删除文件并查看文档历史
- 任务管理器:专属的任务管理视图拥有独立的话题状态,运行中的任务不再干扰你的主要对话
- 提示词改写与翻译:发送前可直接在聊天输入框中改写或翻译你的提示词
- 桌面端 CLILobeHub CLI 现已内嵌在桌面应用中,可从设置中安装到系统 PATH
- 屏幕截图:使用覆盖层选择器截取屏幕内容,直接附加到对话中
- 新模型:智谱 GLM-5.1、Seedance 2.0 视频生成,以及新的 StreamLake 提供商
## 体验优化
- 桌面应用现使用 Electron 原生 fetch 进行远程请求,提升连接稳定性
- 乐观更新时的加载状态防止了助手思考时的界面闪烁
- 智能体详情页在刷新后正确加载,不再显示无限加载动画
- 改进了余额不足和账户停用的错误分类,展示更清晰的提示信息
- 修复了非字符串内容传入文档注入时的上下文引擎崩溃问题
+8
View File
@@ -2,6 +2,14 @@
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
"cloud": [],
"community": [
{
"image": "/blog/assets300abe7e259d293da6c5ed4f642a1be6.webp",
"id": "2026-04-13-gateway-sidebar",
"date": "2026-04-13",
"versionRange": [
"2.1.46"
]
},
{
"image": "/blog/assets7ea204859aeb5aa9be5810a20ba1669a.webp",
"id": "2026-04-06-auto-completion",
+31 -1
View File
@@ -56,6 +56,13 @@ LobeHub supports two connection modes for Slack:
bot_user:
display_name: LobeHub Assistant
always_online: true
slash_commands:
- command: /new
description: Start a new conversation
should_escape: false
- command: /stop
description: Stop the current execution
should_escape: false
oauth_config:
scopes:
bot:
@@ -63,6 +70,7 @@ LobeHub supports two connection modes for Slack:
- channels:history
- channels:read
- chat:write
- commands
- groups:history
- groups:read
- im:history
@@ -84,12 +92,14 @@ LobeHub supports two connection modes for Slack:
- member_joined_channel
- assistant_thread_started
- assistant_thread_context_changed
interactivity:
is_enabled: true
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
```
> **Note:** `socket_mode_enabled: true` means no Request URL is needed. Events are delivered via WebSocket.
> **Note:** `socket_mode_enabled: true` means no Request URL is needed. Events (including Slash Commands) are delivered via WebSocket.
### Create the App
@@ -154,6 +164,8 @@ LobeHub supports two connection modes for Slack:
Click **Test Connection** in LobeHub, then go to Slack, invite the bot to a channel, and mention it with `@LobeHub Assistant` to confirm it responds.
> **Slash Commands:** If you used the manifest template above, the `/new` and `/stop` commands are automatically configured. Type `/new` in Slack to reset the conversation, or `/stop` to stop the current execution. You can also use these commands via `@bot /new`.
---
## Webhook Setup (Alternative)
@@ -177,11 +189,28 @@ Use this method if your Slack app already has Event Subscriptions configured wit
Enter **Application ID**, **Bot Token**, and **Signing Secret** in LobeHub's Slack channel settings. Set **Connection Mode** to **Webhook** in Advanced Settings. Save and copy the displayed **Webhook URL**.
### Enable App Home Messaging
In the Slack API Dashboard → **App Home**, find the **Show Tabs** section, enable **Messages Tab**, and make sure **"Allow users to send Slash commands and messages from the messages tab"** is checked. This allows users to chat with the bot via direct messages.
### Configure Event Subscriptions
In the Slack API Dashboard → **Event Subscriptions**, enable events, paste the Webhook URL as the **Request URL**, and subscribe to bot events: `app_mention`, `message.channels`, `message.groups`, `message.im`, `message.mpim`, `member_joined_channel`.
![](/blog/assets8f3657f3785fc04c42b0f53c17daa72e.webp)
### Configure Slash Commands (Optional)
In the Slack API Dashboard → **Slash Commands**, click **Create New Command** and add the following commands:
| Command | Request URL | Short Description |
| ------- | ------------------------- | -------------------------- |
| `/new` | Same Webhook URL as above | Start a new conversation |
| `/stop` | Same Webhook URL as above | Stop the current execution |
> **Note:** The Request URL is required for Webhook mode. If you are using Socket Mode, we recommend creating the app from the Manifest template above, which automatically configures Slash Commands without manual setup.
Also ensure you add the `commands` scope under **OAuth & Permissions** → **Bot Token Scopes**, and enable **Interactivity & Shortcuts** with the same Webhook URL as the Request URL.
</Steps>
## Configuration Reference
@@ -196,6 +225,7 @@ Use this method if your Slack app already has Event Subscriptions configured wit
## Troubleshooting
- **DM shows "Sending messages to this app has been turned off":** In the Slack API Dashboard → **App Home** → **Show Tabs**, make sure **Messages Tab** is enabled and "Allow users to send Slash commands and messages from the messages tab" is checked. This is already enabled if you created the app using the Manifest template.
- **Bot not responding:** Confirm the bot has been invited to the channel. For Socket Mode, ensure the App-Level Token is correct and Socket Mode is enabled in Slack app settings.
- **Test Connection failed:** Double-check the Application ID and Bot Token. Ensure the app is installed to the workspace.
- **Webhook verification failed (Webhook mode):** Make sure the Signing Secret matches and the Webhook URL is correct.
+31 -1
View File
@@ -53,6 +53,13 @@ LobeHub 支持两种 Slack 连接模式:
bot_user:
display_name: LobeHub Assistant
always_online: true
slash_commands:
- command: /new
description: Start a new conversation
should_escape: false
- command: /stop
description: Stop the current execution
should_escape: false
oauth_config:
scopes:
bot:
@@ -60,6 +67,7 @@ LobeHub 支持两种 Slack 连接模式:
- channels:history
- channels:read
- chat:write
- commands
- groups:history
- groups:read
- im:history
@@ -81,12 +89,14 @@ LobeHub 支持两种 Slack 连接模式:
- member_joined_channel
- assistant_thread_started
- assistant_thread_context_changed
interactivity:
is_enabled: true
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
```
> **注意:** `socket_mode_enabled: true` 表示无需配置 Request URL。事件通过 WebSocket 推送。
> **注意:** `socket_mode_enabled: true` 表示无需配置 Request URL。事件(包括 Slash Commands通过 WebSocket 推送。
### 创建应用
@@ -151,6 +161,8 @@ LobeHub 支持两种 Slack 连接模式:
在 LobeHub 点击 **测试连接**,然后进入 Slack,将机器人邀请到频道,通过 `@LobeHub Assistant` 提及它,确认是否正常响应。
> **Slash Commands** 如果您使用了上方的 Manifest 模板,`/new` 和 `/stop` 命令已自动配置。在 Slack 输入 `/new` 可以重置对话,输入 `/stop` 可以停止当前执行。您也可以通过 `@bot /new` 的方式使用这些命令。
---
## Webhook 设置(备选方案)
@@ -174,11 +186,28 @@ LobeHub 支持两种 Slack 连接模式:
在 LobeHub 的 Slack 渠道设置中输入 **应用 ID**、**Bot Token** 和 **签名密钥**。在高级设置中将 **连接模式** 设为 **Webhook**。保存后复制显示的 **Webhook URL**。
### 启用 App Home 消息功能
在 Slack API 控制台 → **App Home** 中,找到 **Show Tabs** 区域,勾选 **Messages Tab**,并确保 **"Allow users to send Slash commands and messages from the messages tab"** 已启用。这样用户才能在私信中与机器人对话。
### 配置事件订阅
在 Slack API 控制台 → **Event Subscriptions** 中,启用事件,将 Webhook URL 粘贴为 **Request URL**,订阅事件:`app_mention`、`message.channels`、`message.groups`、`message.im`、`message.mpim`、`member_joined_channel`。
![](/blog/assets8f3657f3785fc04c42b0f53c17daa72e.webp)
### 配置 Slash Commands(可选)
在 Slack API 控制台 → **Slash Commands** 中,点击 **Create New Command**,添加以下命令:
| Command | Request URL | Short Description |
| ------- | ------------------ | -------------------------- |
| `/new` | 与上方相同的 Webhook URL | Start a new conversation |
| `/stop` | 与上方相同的 Webhook URL | Stop the current execution |
> **注意:** Webhook 模式下 Request URL 为必填项。如果您使用 Socket Mode,推荐通过 Manifest 模板创建应用,Slash Commands 会自动配置,无需手动添加。
同时确保在 **OAuth & Permissions** → **Bot Token Scopes** 中添加 `commands` 权限,并在 **Interactivity & Shortcuts** 中启用 Interactivity,将 Request URL 设为相同的 Webhook URL。
</Steps>
## 配置参考
@@ -193,6 +222,7 @@ LobeHub 支持两种 Slack 连接模式:
## 故障排除
- **私信显示 "Sending messages to this app has been turned off"** 在 Slack API 控制台 → **App Home** → **Show Tabs** 中,确保 **Messages Tab** 已启用,并勾选 "Allow users to send Slash commands and messages from the messages tab"。如果使用 Manifest 模板创建应用则默认已开启。
- **机器人未响应:** 确认机器人已被邀请到频道。Socket Mode 下请确保应用级别 Token 正确且 Socket Mode 已在 Slack 应用设置中启用。
- **测试连接失败:** 仔细检查应用 ID 和 Bot Token 是否正确。确保应用已安装到工作区。
- **Webhook 验证失败(Webhook 模式):** 确保签名密钥匹配且 Webhook URL 正确。
+1 -1
View File
@@ -265,7 +265,7 @@
"@lobehub/analytics": "^1.6.0",
"@lobehub/charts": "^5.0.0",
"@lobehub/desktop-ipc-typings": "workspace:*",
"@lobehub/editor": "^4.5.0",
"@lobehub/editor": "^4.8.1",
"@lobehub/icons": "^5.0.0",
"@lobehub/market-sdk": "0.32.2",
"@lobehub/tts": "^5.1.2",
@@ -20,37 +20,40 @@ export const systemPrompt = `You have access to a Cloud Sandbox that provides a
**IMPORTANT: Prefer Pre-installed Software**
The sandbox comes with pre-installed software and libraries. **Always prioritize using these pre-installed tools** when they can solve the user's problem, rather than installing additional packages.
**Operating System:**
- Debian 12
**Base Image:** lobehubbot/python-node:latest (Debian-based)
**Programming Languages & Runtimes:**
- Python (with pip)
- Node.js (with npm)
- Bun
- Bash/Shell
**Build Tools:**
- build-essential 12.9
- gcc/g++ 12.2.0
**Package Managers:**
- pip (Python)
- npm / pnpm (Node.js)
**Python Libraries (Pre-installed):**
- numpy 2.4.1 - Numerical computing
- scipy 1.17.0 - Scientific computing
- pandas 2.3.3 - Data analysis
- matplotlib 3.10.8 - Static visualization
- plotly 6.5.2 - Interactive visualization
- scikit-learn 1.8.0 - Machine learning
- opencv-python 4.13.0.90 - Computer vision
- Pillow 12.1.0 - Image processing
- wheel 0.45.1 - Python package installer
**Document & Media Tools:**
**System Tools (apt):**
- curl, wget, unzip, jq - Common utilities
- build-essential - gcc/g++/make compilation toolchain
- FFmpeg - Audio/video processing
- LibreOffice - Office document processing
- Pandoc - Document format conversion
- pdftoppm - PDF to image conversion
- FFmpeg 5.1.8-0+deb12u1 - Audio/video processing
- poppler-utils - PDF tools (pdftotext, pdftoppm, etc.)
- GitHub CLI (gh)
**Browser Automation:**
**JS/TS Tools:**
- marp-cli - Markdown to PPT/PDF presentation
- Chromium (installed via Playwright, also used by marp-cli)
- Playwright - Browser automation
- marpc-cli - Browser-based PPTX generation
**Python Libraries (Pre-installed):**
- Data Science/ML: numpy, pandas, scipy, scikit-learn
- Visualization: matplotlib, plotly
- Data Processing: pyyaml, toml, python-dotenv, Pillow, opencv-python-headless
- File Processing: openpyxl, xlrd, python-docx, PyPDF2, reportlab
- Async: aiofiles, anyio
- Testing: pytest
- Server: fastapi, uvicorn, pydantic
**Fonts:**
- Noto Sans CJK - Chinese/Japanese/Korean sans-serif font
@@ -60,6 +63,7 @@ The sandbox comes with pre-installed software and libraries. **Always prioritize
- Tesseract (OCR) - Not installed
- Puppeteer - Not installed, use Playwright instead
- mermaid-cli - Not installed
- seaborn - Not installed
**Installation Guidelines:**
- Only install additional packages when pre-installed software cannot fulfill the requirement
@@ -173,7 +177,9 @@ When executing Python code:
**Using Pre-installed Libraries:**
- **Always check if required libraries are pre-installed** (see preinstalled_software section)
- numpy, scipy, pandas, matplotlib, plotly, scikit-learn, opencv-python, Pillow are already available
- Data Science/ML: numpy, pandas, scipy, scikit-learn, matplotlib, plotly are already available
- Data Processing: pyyaml, toml, python-dotenv, Pillow, opencv-python-headless are already available
- File Processing: openpyxl, xlrd, python-docx, PyPDF2, reportlab are already available
- **Skip pip install** for pre-installed libraries - use them directly
- Only use \`pip install\` for libraries NOT in the pre-installed list
@@ -188,12 +194,12 @@ When executing Python code:
**Generating Document Files:**
You MUST use the following libraries for each supported file format:
- **PDF**: Use \`reportlab\` - prioritize \`reportlab.platypus\` over canvas for text content
- **DOCX**: Use \`python-docx\`
- **XLSX**: Use \`openpyxl\`
- **PPTX**: Use \`python-pptx\`
- **CSV**: Use pre-installed \`pandas\` (no installation needed)
- **ODS/ODT/ODP**: Use \`odfpy\`
- **PDF**: Use \`reportlab\` (pre-installed) - prioritize \`reportlab.platypus\` over canvas for text content
- **DOCX**: Use \`python-docx\` (pre-installed)
- **XLSX**: Use \`openpyxl\` (pre-installed)
- **PPTX**: Use \`python-pptx\` (requires pip install)
- **CSV**: Use \`pandas\` (pre-installed)
- **ODS/ODT/ODP**: Use \`odfpy\` (requires pip install)
For libraries NOT pre-installed: Install with \`pip install <package-name>\` before use.
**After successful generation, automatically export the document file.**
@@ -370,21 +370,49 @@ class CredsExecutor extends BaseExecutor<typeof CredsApiName> {
_ctx?: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
try {
log('[CredsExecutor] saveCreds - key:', params.key, 'name:', params.name);
// Normalize params: AI may send `displayName` instead of `name`,
// or `value` (env-style string) instead of `values` (Record)
const raw = params as any;
const name: string = params.name || raw.displayName || params.key;
let values: Record<string, string> = params.values;
if (!values && typeof raw.value === 'string') {
values = {};
for (const line of (raw.value as string).split('\n')) {
const idx = line.indexOf('=');
if (idx > 0) {
values[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
}
}
if (!values || Object.keys(values).length === 0) {
return {
content:
'Failed to save credential: values must be a non-empty object of key-value pairs (e.g., { "API_KEY": "sk-xxx" }).',
error: {
message: 'values is empty or missing. Provide key-value pairs, not a raw string.',
type: 'InvalidParams',
},
success: false,
};
}
log('[CredsExecutor] saveCreds - key:', params.key, 'name:', name);
await lambdaClient.market.creds.createKV.mutate({
description: params.description,
key: params.key,
name: params.name,
name,
type: params.type as 'kv-env' | 'kv-header',
values: params.values,
values,
});
return {
content: `Credential "${params.name}" saved successfully with key "${params.key}"`,
content: `Credential "${name}" saved successfully with key "${params.key}"`,
state: {
key: params.key,
message: `Credential "${params.name}" saved successfully`,
message: `Credential "${name}" saved successfully`,
success: true,
},
success: true,
@@ -29,6 +29,10 @@ Sandbox mode: {{sandbox_enabled}}
- **getPlaintextCred**: Retrieve the plaintext value of a credential by key. Only use when you need to actually use the credential.
- **injectCredsToSandbox**: Inject credentials into the sandbox environment. Only available when sandbox mode is enabled.
- **saveCreds**: Save new credentials securely. Use when user wants to store sensitive information.
- Parameters: \`key\` (unique identifier, lowercase with hyphens), \`name\` (display name), \`type\` ("kv-env" or "kv-header"), \`values\` (object of key-value pairs, NOT a string), \`description\` (optional)
- Example: \`saveCreds({ key: "openai", name: "OpenAI API Key", type: "kv-env", values: { "OPENAI_API_KEY": "sk-xxx" } })\`
- For multiple env vars: \`saveCreds({ key: "my-config", name: "My Config", type: "kv-env", values: { "APP_URL": "http://localhost:3000", "DB_URL": "postgres://..." } })\`
- IMPORTANT: \`values\` must be a JSON object (Record<string, string>), NOT a raw string. Each environment variable should be a separate key-value pair in the object.
</tooling>
<oauth_providers>
@@ -61,7 +65,7 @@ Proactively suggest saving credentials when you detect:
When suggesting to save, always:
1. Explain that the credential will be encrypted and stored securely
2. Ask the user for a meaningful name and optional description
3. Use the \`saveCreds\` tool to store it
3. Use the \`saveCreds\` tool to store it with \`values\` as a JSON object (e.g., \`{ "API_KEY": "sk-xxx" }\`), NOT a raw string
</credential_saving_triggers>
<sandbox_integration>
@@ -101,7 +101,10 @@ export class ToolResolver {
tools.push(...newTools);
enabledToolIds.push(activation.id);
if (activation.source) {
// Only set source if not already present — the operation-level sourceMap
// may already have the correct routing source (e.g., 'lobehubSkill', 'klavis')
// and the activation source ('discovery') should not overwrite it.
if (activation.source && !sourceMap[activation.id]) {
sourceMap[activation.id] = this.mapSource(activation.source);
}
}
@@ -69,17 +69,17 @@ describe('ChunkModel', () => {
expect(createdChunks[1]).toMatchObject(params[1]);
});
// 测试空参数场景
// Test empty params scenario
it('should handle empty params array', async () => {
const result = await chunkModel.bulkCreate([], '1');
expect(result).toHaveLength(0);
});
// 测试事务回滚
// Test transaction rollback
it('should rollback transaction on error', async () => {
const invalidParams = [
{ text: 'Chunk 1', userId },
{ index: 'abc', userId }, // 这会导致错误
{ index: 'abc', userId }, // This will cause an error
] as any;
await expect(chunkModel.bulkCreate(invalidParams, '1')).rejects.toThrow();
@@ -203,7 +203,7 @@ describe('ChunkModel', () => {
expect(result[1].id).toBe(chunk2.id);
expect(result[0].similarity).toBeGreaterThan(result[1].similarity);
});
// 补充无文件 ID 的搜索场景
// Additional search scenario without file ID
it('should perform semantic search without fileIds', async () => {
const [chunk1, chunk2] = await serverDB
.insert(chunks)
@@ -228,7 +228,7 @@ describe('ChunkModel', () => {
expect(result).toHaveLength(2);
});
// 测试空结果场景
// Test empty result scenario
it('should return empty array when no matches found', async () => {
const result = await chunkModel.semanticSearch({
embedding: designThinkingQuery,
@@ -524,7 +524,7 @@ content in Table html is below:
});
describe('semanticSearchForChat', () => {
// 测试空文件 ID 列表场景
// Test empty file ID list scenario
it('should return empty array when fileIds is empty', async () => {
const result = await chunkModel.semanticSearchForChat({
embedding: designThinkingQuery,
@@ -535,7 +535,7 @@ content in Table html is below:
expect(result).toHaveLength(0);
});
// 测试结果限制
// Test result limit
it('should limit results to 15 items', async () => {
const fileId = '1';
// Create 24 chunks
@@ -631,7 +631,7 @@ describe('FileModel', () => {
describe('findByNames', () => {
it('should find files by names', async () => {
// 准备测试数据
// Prepare test data
const fileList = [
{
name: 'test1.txt',
@@ -658,7 +658,7 @@ describe('FileModel', () => {
await serverDB.insert(files).values(fileList);
// 测试查找文件
// Test finding files
const result = await fileModel.findByNames(['test1', 'test2']);
expect(result).toHaveLength(2);
expect(result.map((f) => f.name)).toContain('test1.txt');
@@ -671,7 +671,7 @@ describe('FileModel', () => {
});
it('should only find files belonging to current user', async () => {
// 准备测试数据
// Prepare test data
await serverDB.insert(files).values([
{
name: 'test1.txt',
@@ -685,7 +685,7 @@ describe('FileModel', () => {
url: 'https://example.com/test2.txt',
size: 200,
fileType: 'text/plain',
userId: 'user2', // 不同用户的文件
userId: 'user2', // file from a different user
},
]);
@@ -697,7 +697,7 @@ describe('FileModel', () => {
describe('deleteGlobalFile', () => {
it('should delete global file by hashId', async () => {
// 准备测试数据
// Prepare test data
const globalFile = {
hashId: 'test-hash',
fileType: 'text/plain',
@@ -709,10 +709,10 @@ describe('FileModel', () => {
await serverDB.insert(globalFiles).values(globalFile);
// 执行删除操作
// Execute delete operation
await fileModel.deleteGlobalFile('test-hash');
// 验证文件已被删除
// Verify file has been deleted
const result = await serverDB.query.globalFiles.findFirst({
where: eq(globalFiles.hashId, 'test-hash'),
});
@@ -720,12 +720,12 @@ describe('FileModel', () => {
});
it('should not throw error when deleting non-existent global file', async () => {
// 删除不存在的文件不应抛出错误
// Deleting a non-existent file should not throw an error
await expect(fileModel.deleteGlobalFile('non-existent-hash')).resolves.not.toThrow();
});
it('should only delete specified global file', async () => {
// 准备测试数据
// Prepare test data
const globalFiles1 = {
hashId: 'hash1',
fileType: 'text/plain',
@@ -743,10 +743,10 @@ describe('FileModel', () => {
await serverDB.insert(globalFiles).values([globalFiles1, globalFiles2]);
// 删除一个文件
// Delete one file
await fileModel.deleteGlobalFile('hash1');
// 验证只有指定文件被删除
// Verify only the specified file was deleted
const remainingFiles = await serverDB.query.globalFiles.findMany();
expect(remainingFiles).toHaveLength(1);
expect(remainingFiles[0].hashId).toBe('hash2');
@@ -764,22 +764,22 @@ describe('FileModel', () => {
fileHash: 'test-hash-txn',
};
// 在事务中创建文件
// Create file in transaction
const result = await serverDB.transaction(async (trx) => {
const { id } = await fileModel.create(params, true, trx);
// 在事务内验证文件已创建
// Verify file was created inside the transaction
const file = await trx.query.files.findFirst({ where: eq(files.id, id) });
expect(file).toMatchObject({ ...params, userId });
return { id };
});
// 事务提交后,验证文件仍然存在
// After transaction commit, verify file still exists
const file = await serverDB.query.files.findFirst({ where: eq(files.id, result.id) });
expect(file).toMatchObject({ ...params, userId });
// 验证全局文件也被创建
// Verify global file was also created
const globalFile = await serverDB.query.globalFiles.findFirst({
where: eq(globalFiles.hashId, params.fileHash),
});
@@ -797,22 +797,22 @@ describe('FileModel', () => {
let createdFileId: string | undefined;
// 故意让事务失败
// Intentionally fail the transaction
await expect(
serverDB.transaction(async (trx) => {
const { id } = await fileModel.create(params, true, trx);
createdFileId = id;
// 在事务内验证文件已创建
// Verify file was created inside the transaction
const file = await trx.query.files.findFirst({ where: eq(files.id, id) });
expect(file).toMatchObject({ ...params, userId });
// 抛出错误导致事务回滚
// Throw an error to cause transaction rollback
throw new Error('Intentional rollback');
}),
).rejects.toThrow('Intentional rollback');
// 验证文件创建被回滚
// Verify file creation was rolled back
if (createdFileId) {
const file = await serverDB.query.files.findFirst({
where: eq(files.id, createdFileId),
@@ -820,7 +820,7 @@ describe('FileModel', () => {
expect(file).toBeUndefined();
}
// 验证全局文件创建也被回滚
// Verify global file creation was also rolled back
const globalFile = await serverDB.query.globalFiles.findFirst({
where: eq(globalFiles.hashId, params.fileHash),
});
@@ -839,7 +839,7 @@ describe('FileModel', () => {
const result = await serverDB.transaction(async (trx) => {
const { id } = await fileModel.create(params, false, trx);
// 验证知识库文件关联已创建
// Verify knowledge base file association was created
const kbFile = await trx.query.knowledgeBaseFiles.findFirst({
where: eq(knowledgeBaseFiles.fileId, id),
});
@@ -848,7 +848,7 @@ describe('FileModel', () => {
return { id };
});
// 事务提交后验证
// Verify after transaction commit
const kbFile = await serverDB.query.knowledgeBaseFiles.findFirst({
where: eq(knowledgeBaseFiles.fileId, result.id),
});
@@ -862,7 +862,7 @@ describe('FileModel', () => {
describe('delete with transaction', () => {
it('should delete file within provided transaction', async () => {
// 先创建文件和全局文件
// First create the file and global file
await fileModel.createGlobalFile({
hashId: 'delete-txn-hash',
url: 'https://example.com/delete-txn.txt',
@@ -879,20 +879,20 @@ describe('FileModel', () => {
fileHash: 'delete-txn-hash',
});
// 在事务中删除文件
// Delete file in transaction
await serverDB.transaction(async (trx) => {
await fileModel.delete(id, true, trx);
// 在事务内验证文件已删除
// Verify file was deleted inside the transaction
const file = await trx.query.files.findFirst({ where: eq(files.id, id) });
expect(file).toBeUndefined();
});
// 事务提交后验证文件仍然被删除
// After transaction commit, verify file is still deleted
const file = await serverDB.query.files.findFirst({ where: eq(files.id, id) });
expect(file).toBeUndefined();
// 验证全局文件也被删除(因为没有其他引用)
// Verify global file was also deleted (no other references)
const globalFile = await serverDB.query.globalFiles.findFirst({
where: eq(globalFiles.hashId, 'delete-txn-hash'),
});
@@ -900,7 +900,7 @@ describe('FileModel', () => {
});
it('should rollback file deletion when transaction fails', async () => {
// 先创建文件和全局文件
// First create the file and global file
await fileModel.createGlobalFile({
hashId: 'rollback-delete-hash',
url: 'https://example.com/rollback-delete.txt',
@@ -917,26 +917,26 @@ describe('FileModel', () => {
fileHash: 'rollback-delete-hash',
});
// 故意让事务失败
// Intentionally fail the transaction
await expect(
serverDB.transaction(async (trx) => {
await fileModel.delete(id, true, trx);
// 在事务内验证文件已删除
// Verify file was deleted inside the transaction
const file = await trx.query.files.findFirst({ where: eq(files.id, id) });
expect(file).toBeUndefined();
// 抛出错误导致事务回滚
// Throw an error to cause transaction rollback
throw new Error('Intentional rollback for delete');
}),
).rejects.toThrow('Intentional rollback for delete');
// 验证文件删除被回滚,文件仍然存在
// Verify file deletion was rolled back, file still exists
const file = await serverDB.query.files.findFirst({ where: eq(files.id, id) });
expect(file).toBeDefined();
expect(file?.name).toBe('rollback-delete-file.txt');
// 验证全局文件也被回滚,仍然存在
// Verify global file was also rolled back, still exists
const globalFile = await serverDB.query.globalFiles.findFirst({
where: eq(globalFiles.hashId, 'rollback-delete-hash'),
});
@@ -944,7 +944,7 @@ describe('FileModel', () => {
});
it('should delete file but preserve global file when removeGlobalFile=false in transaction', async () => {
// 先创建文件和全局文件
// First create the file and global file
await fileModel.createGlobalFile({
hashId: 'preserve-global-hash',
url: 'https://example.com/preserve-global.txt',
@@ -961,16 +961,16 @@ describe('FileModel', () => {
fileHash: 'preserve-global-hash',
});
// 在事务中删除文件,但不删除全局文件
// Delete file in transaction, but keep global file
await serverDB.transaction(async (trx) => {
await fileModel.delete(id, false, trx);
});
// 验证文件被删除
// Verify file was deleted
const file = await serverDB.query.files.findFirst({ where: eq(files.id, id) });
expect(file).toBeUndefined();
// 验证全局文件被保留
// Verify global file was retained
const globalFile = await serverDB.query.globalFiles.findFirst({
where: eq(globalFiles.hashId, 'preserve-global-hash'),
});
@@ -980,7 +980,7 @@ describe('FileModel', () => {
describe('mixed operations in transaction', () => {
it('should support create and delete operations in same transaction', async () => {
// 先创建一个要删除的文件
// First create a file to be deleted
await fileModel.createGlobalFile({
hashId: 'mixed-delete-hash',
url: 'https://example.com/mixed-delete.txt',
@@ -997,12 +997,12 @@ describe('FileModel', () => {
fileHash: 'mixed-delete-hash',
});
// 在同一个事务中删除旧文件并创建新文件
// Delete old file and create new file in the same transaction
const result = await serverDB.transaction(async (trx) => {
// 删除旧文件
// Delete old file
await fileModel.delete(deleteFileId, true, trx);
// 创建新文件
// Create new file
const { id: newFileId } = await fileModel.create(
{
name: 'mixed-create-file.txt',
@@ -1018,20 +1018,20 @@ describe('FileModel', () => {
return { newFileId };
});
// 验证旧文件被删除
// Verify old file was deleted
const deletedFile = await serverDB.query.files.findFirst({
where: eq(files.id, deleteFileId),
});
expect(deletedFile).toBeUndefined();
// 验证新文件被创建
// Verify new file was created
const newFile = await serverDB.query.files.findFirst({
where: eq(files.id, result.newFileId),
});
expect(newFile).toBeDefined();
expect(newFile?.name).toBe('mixed-create-file.txt');
// 验证新的全局文件被创建
// Verify new global file was created
const newGlobalFile = await serverDB.query.globalFiles.findFirst({
where: eq(globalFiles.hashId, 'mixed-create-hash'),
});
@@ -1152,7 +1152,7 @@ describe('FileModel', () => {
});
it('should delete file even when chunks deletion fails', async () => {
// 创建测试文件
// Create test file
const testFile = {
name: 'error-test-file.txt',
url: 'https://example.com/error-test-file.txt',
@@ -1163,52 +1163,52 @@ describe('FileModel', () => {
const { id: fileId } = await fileModel.create(testFile, true);
// 创建一些测试数据来模拟chunks关联
// Create some test data to simulate chunk associations
const chunkId1 = '550e8400-e29b-41d4-a716-446655440001';
const chunkId2 = '550e8400-e29b-41d4-a716-446655440002';
// 插入chunks
// Insert chunks
await serverDB.insert(chunks).values([
{ id: chunkId1, text: 'chunk 1', userId, type: 'text' },
{ id: chunkId2, text: 'chunk 2', userId, type: 'text' },
]);
// 插入fileChunks关联
// Insert fileChunks associations
await serverDB.insert(fileChunks).values([
{ fileId, chunkId: chunkId1, userId },
{ fileId, chunkId: chunkId2, userId },
]);
// 插入embeddings (1024维向量)
// Insert embeddings (1024-dimensional vectors)
const testEmbedding = Array.from({ length: 1024 }).fill(0.1) as number[];
await serverDB
.insert(embeddings)
.values([{ chunkId: chunkId1, embeddings: testEmbedding, model: 'test-model', userId }]);
// 跳过 documentChunks 测试,因为需要先创建 documents 记录
// Skip documentChunks test, requires creating documents records first
// 删除文件,应该会清理所有相关数据
// Delete file, should clean up all related data
const result = await fileModel.delete(fileId, true);
// 验证文件被删除
// Verify file was deleted
const deletedFile = await serverDB.query.files.findFirst({
where: eq(files.id, fileId),
});
expect(deletedFile).toBeUndefined();
// 验证chunks被删除
// Verify chunks were deleted
const remainingChunks = await serverDB.query.chunks.findMany({
where: inArray(chunks.id, [chunkId1, chunkId2]),
});
expect(remainingChunks).toHaveLength(0);
// 验证embeddings被删除
// Verify embeddings were deleted
const remainingEmbeddings = await serverDB.query.embeddings.findMany({
where: inArray(embeddings.chunkId, [chunkId1, chunkId2]),
});
expect(remainingEmbeddings).toHaveLength(0);
// 验证fileChunks被删除
// Verify fileChunks were deleted
const remainingFileChunks = await serverDB.query.fileChunks.findMany({
where: eq(fileChunks.fileId, fileId),
});
@@ -1218,7 +1218,7 @@ describe('FileModel', () => {
});
it('should successfully delete file with all related chunks and embeddings', async () => {
// 简化测试:只验证正常的完整删除流程(移除知识库保护后)
// Simplified test: only verify the normal full deletion flow (after removing knowledge base protection)
const testFile = {
name: 'complete-deletion-test.txt',
url: 'https://example.com/complete-deletion-test.txt',
@@ -1231,42 +1231,42 @@ describe('FileModel', () => {
const chunkId = '550e8400-e29b-41d4-a716-446655440003';
// 插入chunk
// Insert chunk
await serverDB
.insert(chunks)
.values([{ id: chunkId, text: 'complete test chunk', userId, type: 'text' }]);
// 插入fileChunks关联
// Insert fileChunks associations
await serverDB.insert(fileChunks).values([{ fileId, chunkId, userId }]);
// 插入embeddings
// Insert embeddings
const testEmbedding = Array.from({ length: 1024 }).fill(0.1) as number[];
await serverDB
.insert(embeddings)
.values([{ chunkId, embeddings: testEmbedding, model: 'test-model', userId }]);
// 删除文件
// Delete file
await fileModel.delete(fileId, true);
// 验证文件被删除
// Verify file was deleted
const deletedFile = await serverDB.query.files.findFirst({
where: eq(files.id, fileId),
});
expect(deletedFile).toBeUndefined();
// 验证chunks被删除
// Verify chunks were deleted
const remainingChunks = await serverDB.query.chunks.findMany({
where: eq(chunks.id, chunkId),
});
expect(remainingChunks).toHaveLength(0);
// 验证embeddings被删除
// Verify embeddings were deleted
const remainingEmbeddings = await serverDB.query.embeddings.findMany({
where: eq(embeddings.chunkId, chunkId),
});
expect(remainingEmbeddings).toHaveLength(0);
// 验证fileChunks被删除
// Verify fileChunks were deleted
const remainingFileChunks = await serverDB.query.fileChunks.findMany({
where: eq(fileChunks.fileId, fileId),
});
@@ -1274,7 +1274,7 @@ describe('FileModel', () => {
});
it('should delete files that are in knowledge bases (removed protection)', async () => {
// 测试修复后的逻辑:知识库中的文件也应该被删除
// Test the fixed logic: files in knowledge bases should also be deleted
const testFile = {
name: 'knowledge-base-file.txt',
url: 'https://example.com/knowledge-base-file.txt',
@@ -1288,47 +1288,47 @@ describe('FileModel', () => {
const chunkId = '550e8400-e29b-41d4-a716-446655440007';
// 插入chunk和关联数据
// Insert chunk and association data
await serverDB
.insert(chunks)
.values([{ id: chunkId, text: 'knowledge base chunk', userId, type: 'text' }]);
await serverDB.insert(fileChunks).values([{ fileId, chunkId, userId }]);
// 插入embeddings (1024维向量)
// Insert embeddings (1024-dimensional vectors)
const testEmbedding = Array.from({ length: 1024 }).fill(0.1) as number[];
await serverDB
.insert(embeddings)
.values([{ chunkId, embeddings: testEmbedding, model: 'test-model', userId }]);
// 验证文件确实在知识库中
// Verify file is indeed in the knowledge base
const kbFile = await serverDB.query.knowledgeBaseFiles.findFirst({
where: eq(knowledgeBaseFiles.fileId, fileId),
});
expect(kbFile).toBeDefined();
// 删除文件
// Delete file
await fileModel.delete(fileId, true);
// 验证知识库中的文件也被完全删除
// Verify files in knowledge base were also completely deleted
const deletedFile = await serverDB.query.files.findFirst({
where: eq(files.id, fileId),
});
expect(deletedFile).toBeUndefined();
// 验证chunks被删除(这是修复的核心:之前知识库文件的chunks不会被删除)
// Verify chunks were deleted (this is the core of the fix: previously chunks of knowledge base files would not be deleted)
const remainingChunks = await serverDB.query.chunks.findMany({
where: eq(chunks.id, chunkId),
});
expect(remainingChunks).toHaveLength(0);
// 验证embeddings被删除
// Verify embeddings were deleted
const remainingEmbeddings = await serverDB.query.embeddings.findMany({
where: eq(embeddings.chunkId, chunkId),
});
expect(remainingEmbeddings).toHaveLength(0);
// 验证fileChunks被删除
// Verify fileChunks were deleted
const remainingFileChunks = await serverDB.query.fileChunks.findMany({
where: eq(fileChunks.fileId, fileId),
});
@@ -25,19 +25,19 @@ const sessionModel = new SessionModel(serverDB, userId);
beforeEach(async () => {
await serverDB.delete(users);
// 并创建初始用户
// and create the initial user
await serverDB.insert(users).values({ id: userId });
});
afterEach(async () => {
// 在每个测试用例之后, 清空用户表 (应该会自动级联删除所有数据)
// After each test case, clear the users table (should auto-cascade delete all data)
await serverDB.delete(users);
});
describe('SessionModel', () => {
describe('query', () => {
it('should query sessions by user ID', async () => {
// 创建一些测试数据
// Create some test data
await serverDB.insert(users).values([{ id: '456' }]);
await serverDB.insert(sessions).values([
@@ -46,10 +46,10 @@ describe('SessionModel', () => {
{ id: '3', userId: '456', updatedAt: new Date('2023-03-01') },
]);
// 调用 query 方法
// Call the query method
const result = await sessionModel.query();
// 断言结果
// Assert results
expect(result).toHaveLength(2);
expect(result[0].id).toBe('2');
expect(result[1].id).toBe('1');
@@ -76,7 +76,7 @@ describe('SessionModel', () => {
describe('queryWithGroups', () => {
it('should return sessions grouped by group', async () => {
// 创建测试数据
// Create test data
await serverDB.transaction(async (trx) => {
await trx.insert(users).values([{ id: '456' }]);
await trx.insert(sessionGroups).values([
@@ -94,10 +94,10 @@ describe('SessionModel', () => {
]);
});
// 调用 queryWithGroups 方法
// Call the queryWithGroups method
const result = await sessionModel.queryWithGroups();
// 断言结果
// Assert results
expect(result.sessions).toHaveLength(6);
expect(result.sessionGroups).toHaveLength(2);
expect(result.sessionGroups[0].id).toBe('group1');
@@ -107,10 +107,10 @@ describe('SessionModel', () => {
});
it('should return empty groups if no sessions', async () => {
// 调用 queryWithGroups 方法
// Call the queryWithGroups method
const result = await sessionModel.queryWithGroups();
// 断言结果
// Assert results
expect(result.sessions).toHaveLength(0);
expect(result.sessionGroups).toHaveLength(0);
});
@@ -236,7 +236,7 @@ describe('SessionModel', () => {
describe('count', () => {
it('should return the count of sessions for the user', async () => {
// 创建测试数据
// Create test data
await serverDB.insert(users).values([{ id: '456' }]);
await serverDB.insert(sessions).values([
{ id: '1', userId },
@@ -244,22 +244,22 @@ describe('SessionModel', () => {
{ id: '3', userId: '456' },
]);
// 调用 count 方法
// Call the count method
const result = await sessionModel.count();
// 断言结果
// Assert results
expect(result).toBe(2);
});
it('should return 0 if no sessions exist for the user', async () => {
// 创建测试数据
// Create test data
await serverDB.insert(users).values([{ id: '456' }]);
await serverDB.insert(sessions).values([{ id: '3', userId: '456' }]);
// 调用 count 方法
// Call the count method
const result = await sessionModel.count();
// 断言结果
// Assert results
expect(result).toBe(0);
});
@@ -366,7 +366,7 @@ describe('SessionModel', () => {
describe('create', () => {
it('should create a new session', async () => {
// 调用 create 方法
// Call the create method
const result = await sessionModel.create({
type: 'agent',
session: {
@@ -375,7 +375,7 @@ describe('SessionModel', () => {
config: { model: 'gpt-3.5-turbo' },
});
// 断言结果
// Assert results
const sessionId = result.id;
expect(sessionId).toBeDefined();
expect(sessionId.startsWith('ssn_')).toBeTruthy();
@@ -390,7 +390,7 @@ describe('SessionModel', () => {
});
it('should create a new session with custom ID', async () => {
// 调用 create 方法,传入自定义 ID
// Call the create method with a custom ID
const customId = 'custom-id';
const result = await sessionModel.create({
type: 'agent',
@@ -399,7 +399,7 @@ describe('SessionModel', () => {
id: customId,
});
// 断言结果
// Assert results
expect(result.id).toBe(customId);
});
@@ -471,7 +471,7 @@ describe('SessionModel', () => {
describe('batchCreate', () => {
it('should batch create sessions', async () => {
// 调用 batchCreate 方法
// Call the batchCreate method
const sessions: NewSession[] = [
{
id: '1',
@@ -490,13 +490,13 @@ describe('SessionModel', () => {
];
const result = await sessionModel.batchCreate(sessions);
// 断言结果
// Assert results
// pglite return affectedRows while postgres return rowCount
expect((result as any).affectedRows || result.rowCount).toEqual(2);
});
it.skip('should set group to default if group does not exist', async () => {
// 调用 batchCreate 方法,传入不存在的 group
// Call the batchCreate method with a non-existent group
const sessions: NewSession[] = [
{
id: '1',
@@ -509,14 +509,14 @@ describe('SessionModel', () => {
];
const result = await sessionModel.batchCreate(sessions);
// 断言结果
// Assert results
// expect(result[0].group).toBe('default');
});
});
describe('duplicate', () => {
it('should duplicate a session', async () => {
// 创建一个用户和一个 session
// Create a user and a session
await serverDB.transaction(async (trx) => {
await trx
.insert(sessions)
@@ -525,10 +525,10 @@ describe('SessionModel', () => {
await trx.insert(agentsToSessions).values({ agentId: 'agent-1', sessionId: '1', userId });
});
// 调用 duplicate 方法
// Call the duplicate method
const result = (await sessionModel.duplicate('1', 'Duplicated Session')) as SessionItem;
// 断言结果
// Assert results
expect(result.id).not.toBe('1');
expect(result.userId).toBe(userId);
expect(result.type).toBe('agent');
@@ -542,34 +542,34 @@ describe('SessionModel', () => {
});
it('should return undefined if session does not exist', async () => {
// 调用 duplicate 方法,传入不存在的 session ID
// Call the duplicate method with a non-existent session ID
const result = await sessionModel.duplicate('non-existent-id');
// 断言结果
// Assert results
expect(result).toBeUndefined();
});
});
describe('update', () => {
it('should update a session', async () => {
// 创建一个测试 session
// Create a test session
const sessionId = '123';
await serverDB.insert(sessions).values({ userId, id: sessionId, title: 'Test Session' });
// 调用 update 方法更新 session
// Call the update method to update the session
const updatedSessions = await sessionModel.update(sessionId, {
title: 'Updated Test Session',
description: 'This is an updated test session',
});
// 断言更新后的结果
// Assert the updated results
expect(updatedSessions).toHaveLength(1);
expect(updatedSessions[0].title).toBe('Updated Test Session');
expect(updatedSessions[0].description).toBe('This is an updated test session');
});
it('should not update a session if user ID does not match', async () => {
// 创建一个测试 session,但使用不同的 user ID
// Create a test session with a different user ID
await serverDB.insert(users).values([{ id: '777' }]);
const sessionId = '123';
@@ -578,7 +578,7 @@ describe('SessionModel', () => {
.insert(sessions)
.values({ userId: '777', id: sessionId, title: 'Test Session' });
// 尝试更新这个 session,应该不会有任何更新
// Attempt to update this session — should produce no updates
const updatedSessions = await sessionModel.update(sessionId, {
title: 'Updated Test Session',
});
@@ -589,26 +589,26 @@ describe('SessionModel', () => {
describe('delete', () => {
it('should handle deleting a session with no associated messages or topics', async () => {
// 创建测试数据
// Create test data
await serverDB.insert(sessions).values({ id: '1', userId });
// 调用 delete 方法
// Call the delete method
await sessionModel.delete('1');
// 断言删除结果
// Assert deletion results
const result = await serverDB.select({ id: sessions.id }).from(sessions);
expect(result).toHaveLength(0);
});
it('should handle concurrent deletions gracefully', async () => {
// 创建测试数据
// Create test data
await serverDB.insert(sessions).values({ id: '1', userId });
// 并发调用 delete 方法
// Concurrently call the delete method
await Promise.all([sessionModel.delete('1'), sessionModel.delete('1')]);
// 断言删除结果
// Assert deletion results
const result = await serverDB.select({ id: sessions.id }).from(sessions);
expect(result).toHaveLength(0);
@@ -673,35 +673,35 @@ describe('SessionModel', () => {
describe('batchDelete', () => {
it('should handle deleting sessions with no associated messages or topics', async () => {
// 创建测试数据
// Create test data
await serverDB.insert(sessions).values([
{ id: '1', userId },
{ id: '2', userId },
]);
// 调用 batchDelete 方法
// Call the batchDelete method
await sessionModel.batchDelete(['1', '2']);
// 断言删除结果
// Assert deletion results
const result = await serverDB.select({ id: sessions.id }).from(sessions);
expect(result).toHaveLength(0);
});
it('should handle concurrent batch deletions gracefully', async () => {
// 创建测试数据
// Create test data
await serverDB.insert(sessions).values([
{ id: '1', userId },
{ id: '2', userId },
]);
// 并发调用 batchDelete 方法
// Concurrently call the batchDelete method
await Promise.all([
sessionModel.batchDelete(['1', '2']),
sessionModel.batchDelete(['1', '2']),
]);
// 断言删除结果
// Assert deletion results
const result = await serverDB.select({ id: sessions.id }).from(sessions);
expect(result).toHaveLength(0);
@@ -1519,7 +1519,7 @@ describe('SessionModel', () => {
describe('findSessionsByKeywords', () => {
it('should handle errors gracefully and return empty array', async () => {
// 这个测试旨在覆盖 findSessionsByKeywords 中的错误处理逻辑
// This test aims to cover the error-handling logic in findSessionsByKeywords
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock the database query to throw an error
@@ -1528,7 +1528,7 @@ describe('SessionModel', () => {
const result = await sessionModel.findSessionsByKeywords({ keyword: 'test' });
// 即使发生错误,方法也应该返回一个空数组
// Even when an error occurs, the method should return an empty array
expect(Array.isArray(result)).toBe(true);
expect(result).toEqual([]);
expect(consoleSpy).toHaveBeenCalledWith('findSessionsByKeywords error:', expect.any(Error), {
@@ -225,7 +225,7 @@ describe('AgentEvalBenchmarkModel', () => {
it('should order by createdAt descending', async () => {
const results = await benchmarkModel.query(true);
// 最新的应该在前面
// The newest should come first
// Order may vary in PGlite due to timing
expect(results.length).toBeGreaterThanOrEqual(3);
});
@@ -195,7 +195,7 @@ describe('AgentEvalDatasetModel', () => {
it('should order by createdAt descending', async () => {
const results = await datasetModel.query();
// 最新的应该在前面
// The newest should come first
// Order may vary, just check we got results
expect(results.length).toBeGreaterThanOrEqual(2);
});
+4 -2
View File
@@ -1,5 +1,7 @@
import type {
CheckpointConfig,
NewTask,
TaskItem,
WorkspaceData,
WorkspaceDocNode,
WorkspaceTreeNode,
@@ -8,7 +10,7 @@ import { and, desc, eq, inArray, isNotNull, isNull, ne, sql } from 'drizzle-orm'
import { merge } from '@/utils/merge';
import type { NewTask, NewTaskComment, TaskCommentItem, TaskItem } from '../schemas/task';
import type { NewTaskComment, TaskCommentItem } from '../schemas/task';
import { taskComments, taskDependencies, taskDocuments, tasks } from '../schemas/task';
import type { LobeChatDatabase } from '../type';
@@ -307,7 +309,7 @@ export class TaskModel {
SELECT * FROM task_tree
`);
return result.rows as TaskItem[];
return result.rows as unknown as TaskItem[];
}
/**
@@ -5,7 +5,7 @@ import { and, asc, desc, eq, inArray, or, sql } from 'drizzle-orm';
import type { NewUserMemoryActivity, UserMemoryActivity } from '../../schemas';
import { userMemories, userMemoriesActivities } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { sanitizeBm25Query } from '../../utils/bm25';
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from '../../utils/bm25';
export class UserMemoryActivityModel {
private userId: string;
@@ -69,7 +69,9 @@ export class UserMemoryActivityModel {
const normalizedPageSize = Math.min(Math.max(pageSize, 1), 100);
const offset = (normalizedPage - 1) * normalizedPageSize;
const normalizedQuery = typeof q === 'string' ? q.trim() : '';
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
const bm25Query = normalizedQuery
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
: '';
const conditions: Array<SQL | undefined> = [
eq(userMemoriesActivities.userId, this.userId),
@@ -5,7 +5,7 @@ import { and, asc, desc, eq, inArray, or, sql } from 'drizzle-orm';
import type { NewUserMemoryExperience, UserMemoryExperience } from '../../schemas';
import { userMemories, userMemoriesExperiences } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { sanitizeBm25Query } from '../../utils/bm25';
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from '../../utils/bm25';
export class UserMemoryExperienceModel {
private userId: string;
@@ -74,7 +74,9 @@ export class UserMemoryExperienceModel {
const normalizedPageSize = Math.min(Math.max(pageSize, 1), 100);
const offset = (normalizedPage - 1) * normalizedPageSize;
const normalizedQuery = typeof q === 'string' ? q.trim() : '';
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
const bm25Query = normalizedQuery
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
: '';
// Build WHERE conditions
const conditions: Array<SQL | undefined> = [
@@ -6,7 +6,7 @@ import { and, asc, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import type { NewUserMemoryIdentity, UserMemoryIdentity } from '../../schemas';
import { userMemories, userMemoriesIdentities } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { sanitizeBm25Query } from '../../utils/bm25';
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from '../../utils/bm25';
export class UserMemoryIdentityModel {
private userId: string;
@@ -75,7 +75,9 @@ export class UserMemoryIdentityModel {
const normalizedPageSize = Math.min(Math.max(pageSize, 1), 100);
const offset = (normalizedPage - 1) * normalizedPageSize;
const normalizedQuery = typeof q === 'string' ? q.trim() : '';
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
const bm25Query = normalizedQuery
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
: '';
// Build WHERE conditions
const conditions: Array<SQL | undefined> = [
@@ -46,7 +46,7 @@ import {
userMemoriesPreferences,
} from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { sanitizeBm25Query } from '../../utils/bm25';
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from '../../utils/bm25';
import { selectNonVectorColumns } from '../../utils/columns';
import { TopicModel } from '../topic';
import type { UserMemoryHybridSearchAggregatedResult } from './query';
@@ -900,7 +900,9 @@ export class UserMemoryModel {
const normalizedQuery = typeof q === 'string' ? q.trim() : '';
const resolvedLayer = layer ?? LayersEnum.Context;
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
const bm25Query = normalizedQuery
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
: '';
const conditions: Array<SQL | undefined> = [
eq(userMemories.userId, this.userId),
@@ -35,7 +35,7 @@ import {
userMemoriesPreferences,
} from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { sanitizeBm25Query } from '../../utils/bm25';
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from '../../utils/bm25';
const DEFAULT_HYBRID_SEARCH_LIMIT = 5;
const HYBRID_SEARCH_OVERFETCH_MULTIPLIER = 3;
@@ -2059,7 +2059,9 @@ export class UserMemoryQueryModel {
params: SearchMemoryParams,
) {
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
const bm25Query = normalizedQuery
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
: '';
const conditions = [
eq(userMemoriesActivities.userId, this.userId),
eq(userMemories.userId, this.userId),
@@ -2122,7 +2124,9 @@ export class UserMemoryQueryModel {
params: SearchMemoryParams,
) {
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
const bm25Query = normalizedQuery
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
: '';
const conditions = [
eq(userMemoriesContexts.userId, this.userId),
eq(userMemories.userId, this.userId),
@@ -2221,7 +2225,9 @@ export class UserMemoryQueryModel {
params: SearchMemoryParams,
) {
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
const bm25Query = normalizedQuery
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
: '';
const conditions = [
eq(userMemoriesExperiences.userId, this.userId),
eq(userMemories.userId, this.userId),
@@ -2277,7 +2283,9 @@ export class UserMemoryQueryModel {
params: SearchMemoryParams,
) {
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
const bm25Query = normalizedQuery
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
: '';
const conditions = [
eq(userMemoriesPreferences.userId, this.userId),
eq(userMemories.userId, this.userId),
@@ -2330,7 +2338,9 @@ export class UserMemoryQueryModel {
params: SearchMemoryParams,
) {
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
const bm25Query = normalizedQuery ? sanitizeBm25Query(normalizedQuery) : '';
const bm25Query = normalizedQuery
? sanitizeBm25Query(normalizedQuery, SAFE_BM25_QUERY_OPTIONS)
: '';
const conditions = [
eq(userMemoriesIdentities.userId, this.userId),
eq(userMemories.userId, this.userId),
@@ -267,7 +267,7 @@ describe('AiInfraRepos', () => {
expect(merged.settings).toEqual({ searchImpl: 'params' });
});
// 测试场景:用户模型 abilitie 为空(Empty),而基础模型有搜索能力和设置
// Test scenario: user model abilities is empty (Empty) while the base model has search capability and settings
it('should retain builtin abilities and settings when user model has no abilities (empty) and builtin has settings', async () => {
const providerId = 'openai';
@@ -285,7 +285,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: false }, // 使用 builtin abilities
abilities: { search: false }, // Use builtin abilities
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
},
];
@@ -297,9 +297,9 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 使用 builtin abilities
// Use builtin abilities
expect(merged?.abilities?.search).toEqual(false);
// 保留 builtin settings
// Retain builtin settings
expect(merged?.settings).toBeUndefined();
});
@@ -320,7 +320,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: true }, // 使用 builtin abilities
abilities: { search: true }, // Use builtin abilities
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
},
];
@@ -332,13 +332,13 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 使用 builtin abilities
// Use builtin abilities
expect(merged?.abilities?.search).toEqual(true);
// 保留 builtin settings
// Retain builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
// 测试场景:用户模型未启用搜索(abilities.search undefined),而基础模型有搜索能力和设置
// Test scenario: user model has search disabled (abilities.search is undefined) while the base model has search capability and settings
it('should retain builtin settings when user model has no abilities (empty) and builtin has settings', async () => {
const providerId = 'openai';
@@ -347,7 +347,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { vision: true }, // 启用 vision 能力, no search
abilities: { vision: true }, // Enable vision ability, no search
},
];
@@ -368,9 +368,9 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// abilities.search 会被 merge false,此处和 getEnabledAiModel 不同
// abilities.search will be merged as false, differs from getEnabledAiModel
expect(merged?.abilities?.search).toEqual(false);
// 删去 builtin settings
// Remove builtin settings
expect(merged?.settings).toBeUndefined();
});
@@ -382,7 +382,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { vision: true }, // 启用 vision 能力, no search
abilities: { vision: true }, // Enable vision ability, no search
},
];
@@ -391,7 +391,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: true }, // builtin abilities 会被 merge
abilities: { search: true }, // builtin abilities will be merged
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
},
];
@@ -403,13 +403,13 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// abilities.search 会被 merge true,此处和 getEnabledAiModel 不同
// abilities.search will be merged as true, differs from getEnabledAiModel
expect(merged?.abilities?.search).toEqual(true);
// 保留 builtin settings
// Retain builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
// 测试:用户模型无 abilities.searchundefined),保留 builtin settingsmergeArrayById 优先用户,但用户无则 builtin
// Test: user model has no abilities.search (undefined), retains builtin settings (mergeArrayById prefers user, falls back to builtin when absent)
it('should retain builtin settings when user model has no abilities.search (undefined) and builtin has settings', async () => {
const providerId = 'openai';
@@ -418,7 +418,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: {}, // search
abilities: {}, // no search
},
];
@@ -440,7 +440,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities?.search).toBeUndefined();
// 保留 builtin settings
// Retain builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
@@ -452,7 +452,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: {}, // search
abilities: {}, // no search
},
];
@@ -461,7 +461,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
// settings
// no settings
},
];
@@ -473,11 +473,11 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities?.search).toBeUndefined();
// settings
// no settings
expect(merged?.settings).toBeUndefined();
});
// 测试:用户模型有 abilities.search: true
// Test: user model has abilities.search: true
it('should inject defaults when user has search: true, no existing settings (builtin none)', async () => {
const providerId = 'openai';
@@ -486,7 +486,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: true }, // 用户启用
abilities: { search: true }, // user-enabled
},
];
@@ -495,7 +495,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
// settings
// no settings
},
];
@@ -507,7 +507,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: true });
// 注入 defaults
// Inject defaults
expect(merged?.settings).toEqual({ searchImpl: 'params' });
});
@@ -540,11 +540,11 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: true });
// 使用 builtin settings
// Use builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'tool' });
});
// 测试:用户模型有 abilities.search: false
// Test: user model has abilities.search: false
it('should remove settings when user has search: false and builtin has settings', async () => {
const providerId = 'openai';
@@ -553,7 +553,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: false }, // 用户禁用
abilities: { search: false }, // user-disabled
},
];
@@ -574,7 +574,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: false });
// 移除 search 相关,保留其他
// Remove search-related settings, retain others
expect(merged?.settings).toEqual({ extendParams: [] });
});
@@ -595,7 +595,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
// settings
// no settings
},
];
@@ -607,7 +607,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: false });
// settings
// no settings
expect(merged?.settings).toBeUndefined();
});
@@ -640,7 +640,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 应该使用用户的 settings
// Should use user settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'user-provider' });
});
@@ -653,7 +653,7 @@ describe('AiInfraRepos', () => {
type: 'chat',
enabled: true,
abilities: { vision: true },
// 用户未设置 settings
// user has not set settings
},
];
@@ -673,7 +673,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 应该使用内置的 settings
// Should use builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'tool', searchProvider: 'google' });
});
@@ -686,7 +686,7 @@ describe('AiInfraRepos', () => {
type: 'chat',
enabled: true,
abilities: { vision: true },
// 用户未设置 settings
// user has not set settings
},
];
@@ -695,7 +695,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
type: 'chat',
enabled: true,
// 内置也无 settings
// builtin also has no settings
},
];
@@ -706,7 +706,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// settings
// no settings
expect(merged?.settings).toBeUndefined();
});
@@ -328,7 +328,7 @@ describe('AiInfraRepos', () => {
expect(merged?.settings).toEqual({ searchImpl: 'params' });
});
// 测试场景:用户模型 abilitie 为空(Empty),而基础模型有搜索能力和设置
// Test scenario: user model abilities is empty (Empty) while the base model has search capability and settings
it('should retain builtin abilities and settings when user model has no abilities (empty) and builtin has settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
@@ -346,7 +346,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: { search: false }, // 使用 builtin abilities
abilities: { search: false }, // Use builtin abilities
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
};
@@ -358,9 +358,9 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 使用 builtin abilities
// Use builtin abilities
expect(merged?.abilities?.search).toEqual(false);
// 删去 builtin settings
// Remove builtin settings
expect(merged?.settings).toBeUndefined();
});
@@ -381,7 +381,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: { search: true }, // 使用 builtin abilities
abilities: { search: true }, // Use builtin abilities
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
};
@@ -393,13 +393,13 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 使用 builtin abilities
// Use builtin abilities
expect(merged?.abilities?.search).toEqual(true);
// 保留 builtin settings
// Retain builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
// 测试场景:用户模型未启用搜索(abilities.search undefined),而基础模型有搜索能力和设置
// Test scenario: user model has search disabled (abilities.search is undefined) while the base model has search capability and settings
it('should retain builtin settings when user model has no abilities.search (undefined) and builtin has settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
@@ -410,14 +410,14 @@ describe('AiInfraRepos', () => {
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { vision: true }, // 启用 vision 能力, no search
abilities: { vision: true }, // Enable vision ability, no search
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: { search: false }, // builtin abilities 不生效
abilities: { search: false }, // builtin abilities have no effect
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
};
@@ -429,9 +429,9 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// abilities.search undefined(兼容老版本)
// abilities.search remains undefined (backward compatible)
expect(merged?.abilities?.search).toBeUndefined();
// 保留 builtin settings
// Retain builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
@@ -445,14 +445,14 @@ describe('AiInfraRepos', () => {
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { vision: true }, // 启用 vision 能力, no search
abilities: { vision: true }, // Enable vision ability, no search
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: { search: true }, // builtin abilities 不生效
abilities: { search: true }, // builtin abilities have no effect
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
};
@@ -464,13 +464,13 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// abilities.search undefined(兼容老版本)
// abilities.search remains undefined (backward compatible)
expect(merged?.abilities?.search).toBeUndefined();
// 保留 builtin settings
// Retain builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
// 测试场景:用户模型未启用搜索(abilities.search undefined),而基础模型也无搜索能力和设置
// Test scenario: user model has search disabled (abilities.search is undefined) and the base model also has no search capability or settings
it('should retain no settings when user model has no abilities.search (undefined) and builtin has no settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
@@ -481,7 +481,7 @@ describe('AiInfraRepos', () => {
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: {}, // search
abilities: {}, // no search
};
const builtinModel = {
@@ -489,7 +489,7 @@ describe('AiInfraRepos', () => {
enabled: true,
type: 'chat' as const,
abilities: {},
// builtin settings
// builtin has no settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
@@ -501,11 +501,11 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities?.search).toBeUndefined();
// settings
// no settings
expect(merged?.settings).toBeUndefined();
});
// 测试:用户模型有 abilities.search: true
// Test: user model has abilities.search: true
it('should inject defaults when user has search: true, no existing settings (builtin none)', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
@@ -516,7 +516,7 @@ describe('AiInfraRepos', () => {
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { search: true }, // 用户启用 search
abilities: { search: true }, // user-enabled search
};
const builtinModel = {
@@ -524,7 +524,7 @@ describe('AiInfraRepos', () => {
enabled: true,
type: 'chat' as const,
abilities: {},
// settings
// no settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
@@ -536,7 +536,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: true });
// 注入 defaults (openai: params)
// Inject defaults (openai: params)
expect(merged?.settings).toEqual({ searchImpl: 'params' });
});
@@ -557,7 +557,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
settings: { searchImpl: 'tool' }, // builtin settings
settings: { searchImpl: 'tool' }, // builtin has settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
@@ -569,11 +569,11 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: true });
// 使用 builtin settings
// Use builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'tool' });
});
// 测试:用户模型有 abilities.search: false
// Test: user model has abilities.search: false
it('should remove settings when user has search: false and builtin has settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
@@ -584,14 +584,14 @@ describe('AiInfraRepos', () => {
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { search: false }, // 用户禁用 search
abilities: { search: false }, // user-disabled search
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
settings: { searchImpl: 'tool', extendParams: [] }, // builtin settings
settings: { searchImpl: 'tool', extendParams: [] }, // builtin has settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
@@ -603,7 +603,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: false });
// 移除 search 相关,保留其他
// Remove search-related settings, retain others
expect(merged?.settings).toEqual({ extendParams: [] });
});
@@ -624,7 +624,7 @@ describe('AiInfraRepos', () => {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
// settings
// no settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
@@ -636,7 +636,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: false });
// settings
// no settings
expect(merged?.settings).toBeUndefined();
});
@@ -669,7 +669,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 应该使用用户的 settings,不是内置的
// Should use user settings, not builtin
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'user-provider' });
});
@@ -684,7 +684,7 @@ describe('AiInfraRepos', () => {
enabled: true,
type: 'chat',
abilities: { vision: true },
// 用户未设置 settings
// user has not set settings
};
const builtinModel = {
@@ -702,7 +702,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 应该使用内置的 settings
// Should use builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'tool', searchProvider: 'google' });
});
@@ -757,14 +757,14 @@ describe('AiInfraRepos', () => {
enabled: true,
type: 'chat',
abilities: { vision: true },
// 用户未设置 settings
// user has not set settings
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
// 内置也无 settings
// builtin also has no settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
@@ -775,7 +775,7 @@ describe('AiInfraRepos', () => {
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// settings
// no settings
expect(merged?.settings).toBeUndefined();
});
@@ -22,9 +22,9 @@ import { DATA_EXPORT_CONFIG, DataExporterRepos } from './index';
let db: LobeChatDatabase;
// 设置测试数据
// Set up test data
describe('DataExporterRepos', () => {
// 测试数据 ID
// Test data IDs
const testIds = {
userId: 'test-user-id',
fileId: 'test-file-id',
@@ -36,7 +36,7 @@ describe('DataExporterRepos', () => {
knowledgeBaseId: 'test-kb-id',
};
// 设置测试环境
// Set up test environment
const userId: string = testIds.userId;
beforeAll(async () => {
@@ -45,20 +45,20 @@ describe('DataExporterRepos', () => {
const setupTestData = async () => {
await db.transaction(async (trx) => {
// 用户数据
// User data
await trx.insert(users).values({
id: testIds.userId,
username: 'testuser',
email: 'test@example.com',
});
// 用户设置
// User settings
await trx.insert(userSettings).values({
id: testIds.userId,
general: { theme: 'light' },
});
// 全局文件
// Global files
await trx.insert(globalFiles).values({
hashId: testIds.fileHash,
fileType: 'text/plain',
@@ -67,7 +67,7 @@ describe('DataExporterRepos', () => {
creator: testIds.userId,
});
// 文件数据
// File data
await trx.insert(files).values({
id: testIds.fileId,
userId: testIds.userId,
@@ -78,13 +78,13 @@ describe('DataExporterRepos', () => {
url: 'https://example.com/test-file.txt',
});
// 会话组
// Session groups
await trx.insert(sessionGroups).values({
name: 'Test Group',
userId: testIds.userId,
});
// 会话
// Sessions
await trx.insert(sessions).values({
id: testIds.sessionId,
slug: 'test-session',
@@ -92,7 +92,7 @@ describe('DataExporterRepos', () => {
userId: testIds.userId,
});
// 主题
// Topics
await trx.insert(topics).values({
id: testIds.topicId,
title: 'Test Topic',
@@ -100,7 +100,7 @@ describe('DataExporterRepos', () => {
userId: testIds.userId,
});
// 消息
// Messages
await trx.insert(messages).values({
id: testIds.messageId,
role: 'user',
@@ -110,42 +110,42 @@ describe('DataExporterRepos', () => {
topicId: testIds.topicId,
});
// 代理
// Agents
await trx.insert(agents).values({
id: testIds.agentId,
title: 'Test Agent',
userId: testIds.userId,
});
// 代理到会话的关联
// Agent-to-session associations
await trx.insert(agentsToSessions).values({
agentId: testIds.agentId,
sessionId: testIds.sessionId,
userId: testIds.userId,
});
// 文件到会话的关联
// File-to-session associations
await trx.insert(filesToSessions).values({
fileId: testIds.fileId,
sessionId: testIds.sessionId,
userId: testIds.userId,
});
// 知识库
// Knowledge bases
await trx.insert(knowledgeBases).values({
id: testIds.knowledgeBaseId,
name: 'Test Knowledge Base',
userId: testIds.userId,
});
// 知识库文件
// Knowledge base files
await trx.insert(knowledgeBaseFiles).values({
knowledgeBaseId: testIds.knowledgeBaseId,
fileId: testIds.fileId,
userId: testIds.userId,
});
// 代理知识库
// Agent knowledge bases
await trx.insert(agentsKnowledgeBases).values({
agentId: testIds.agentId,
knowledgeBaseId: testIds.knowledgeBaseId,
@@ -155,7 +155,7 @@ describe('DataExporterRepos', () => {
};
beforeEach(async () => {
// 清理并插入测试数据
// Clean up and insert test data
await db.delete(users);
await db.delete(globalFiles);
await setupTestData();
@@ -170,17 +170,17 @@ describe('DataExporterRepos', () => {
describe('export', () => {
it('should export all user data correctly', async () => {
// 创建导出器实例
// Create exporter instance
const dataExporter = new DataExporterRepos(db, userId);
// 执行导出
// Execute export
const result = await dataExporter.export();
// 验证基础表导出结果
// Verify base table export results
// expect(result).toHaveProperty('users');
// expect(result.users).toHaveLength(1);
// expect(result.users[0]).toHaveProperty('id', testIds.userId);
// expect(result.users[0]).not.toHaveProperty('userId'); // userId 字段应该被移除
// expect(result.users[0]).not.toHaveProperty('userId'); // the userId field should be removed
expect(result).toHaveProperty('userSettings');
expect(result.userSettings).toHaveLength(1);
@@ -212,7 +212,7 @@ describe('DataExporterRepos', () => {
// expect(result.knowledgeBases).toHaveLength(1);
// expect(result.knowledgeBases[0]).toHaveProperty('id', testIds.knowledgeBaseId);
// 验证关联表导出结果
// Verify relation table export results
// expect(result).toHaveProperty('globalFiles');
// expect(result.globalFiles).toHaveLength(1);
// expect(result.globalFiles[0]).toHaveProperty('hashId', testIds.fileHash);
@@ -237,18 +237,18 @@ describe('DataExporterRepos', () => {
});
it('should handle empty database gracefully', async () => {
// 清空数据库
// Clear the database
await db.delete(users);
await db.delete(globalFiles);
// 创建导出器实例
// Create exporter instance
const dataExporter = new DataExporterRepos(db, userId);
// 执行导出
// Execute export
const result = await dataExporter.export();
// 验证所有表都返回空数组
// Verify all tables return empty arrays
DATA_EXPORT_CONFIG.baseTables.forEach(({ table }) => {
expect(result).toHaveProperty(table);
expect(result[table]).toEqual([]);
@@ -261,17 +261,17 @@ describe('DataExporterRepos', () => {
});
it('should handle database query errors', async () => {
// 模拟查询错误
// Simulate a query error
// @ts-ignore
vi.spyOn(db.query.users, 'findMany').mockRejectedValueOnce(new Error('Database error'));
// 创建导出器实例
// Create exporter instance
const dataExporter = new DataExporterRepos(db, userId);
// 执行导出
// Execute export
const result = await dataExporter.export();
// 验证其他表仍然被导出
// Verify other tables are still exported
expect(result).toHaveProperty('sessions');
expect(result.sessions).toHaveLength(1);
});
@@ -329,7 +329,7 @@ describe('DataExporterRepos', () => {
});
it('should export data for a different user', async () => {
// 创建另一个用户
// Create another user
const anotherUserId = 'another-user-id';
await db.transaction(async (trx) => {
await trx.insert(users).values({
@@ -345,13 +345,13 @@ describe('DataExporterRepos', () => {
});
});
// 创建导出器实例,使用另一个用户 ID
// Create exporter instance using another user ID
const dataExporter = new DataExporterRepos(db, anotherUserId);
// 执行导出
// Execute export
const result = await dataExporter.export();
// 验证只导出了另一个用户的数据
// Verify only the other user's data was exported
// expect(result).toHaveProperty('users');
// expect(result.users).toHaveLength(1);
// expect(result.users[0]).toHaveProperty('id', anotherUserId);
@@ -18,7 +18,7 @@ let importer: DataImporterRepos;
beforeEach(async () => {
await clientDB.delete(Schema.users);
// 创建测试数据
// Create test data
await clientDB.transaction(async (tx) => {
await tx.insert(Schema.users).values({ id: userId });
});
@@ -27,7 +27,7 @@ let importer: DataImporterRepos;
beforeEach(async () => {
await serverDB.delete(users);
// 创建测试数据
// Create test data
await serverDB.transaction(async (tx) => {
await tx.insert(users).values({ id: userId });
});
@@ -323,13 +323,13 @@ describe('DataImporter', () => {
await importer.importData(data);
// 验证是否为每个 session 创建了对应的 agent
// Verify that a corresponding agent was created for each session
const agentCount = await serverDB.query.agents.findMany({
where: eq(agents.userId, userId),
});
expect(agentCount).toHaveLength(2);
// 验证 agent 的属性是否正确设置
// Verify that agent attributes are correctly set
const agent1 = await serverDB.query.agents.findFirst({
where: eq(agents.systemRole, 'Test Agent 1'),
});
@@ -340,7 +340,7 @@ describe('DataImporter', () => {
});
expect(agent2?.model).toBe('def');
// 验证 agentsToSessions 关联是否正确建立
// Verify that the agentsToSessions association is correctly established
const session1 = await serverDB.query.sessions.findFirst({
where: eq(sessions.clientId, 'session1'),
});
@@ -363,7 +363,7 @@ describe('DataImporter', () => {
});
it('should not create duplicate agents for existing sessions', async () => {
// 先导入一些 sessions
// First import some sessions
await importer.importData({
sessions: [
{
@@ -387,7 +387,7 @@ describe('DataImporter', () => {
version: CURRENT_CONFIG_VERSION,
});
// 再次导入相同的 sessions
// Import the same sessions again
await importer.importData({
sessions: [
{
@@ -411,7 +411,7 @@ describe('DataImporter', () => {
version: CURRENT_CONFIG_VERSION,
});
// 验证只创建了一个 agent
// Verify that only one agent was created
const agentCount = await serverDB.query.agents.findMany({
where: eq(agents.userId, userId),
});
-3
View File
@@ -101,9 +101,6 @@ export const tasks = pgTable(
],
);
export type NewTask = typeof tasks.$inferInsert;
export type TaskItem = typeof tasks.$inferSelect;
// ── Task Dependencies ────────────────────────────────────
export const taskDependencies = pgTable(
@@ -254,7 +254,7 @@ describe('UserModel', () => {
it('should handle decrypt failure and return empty object', async () => {
const userId = 'user-api-test-id';
// 模拟解密失败的情况
// Simulate decrypt failure scenario
const invalidEncryptedData = 'invalid:-encrypted-:data';
await serverDB.insert(users).values({ id: userId });
await serverDB.insert(userSettings).values({
@@ -306,7 +306,7 @@ describe('UserModel', () => {
});
});
// 补充一些边界情况的测试
// Additional edge case tests
describe('edge cases', () => {
describe('updatePreference', () => {
it('should handle undefined preference', async () => {
+58 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { sanitizeBm25Query } from './bm25';
import { SAFE_BM25_QUERY_OPTIONS, sanitizeBm25Query } from './bm25';
describe('sanitizeBm25Query', () => {
it('should join multiple words with AND', () => {
@@ -52,4 +52,61 @@ describe('sanitizeBm25Query', () => {
expect(sanitizeBm25Query('你好世界')).toBe('你好世界');
expect(sanitizeBm25Query('こんにちは')).toBe('こんにちは');
});
// NOTICE:
// These safeguards document the production failure mode where lexical search
// received extremely long, tool-injected text and generated parser-hostile
// BM25 query expressions.
it('should keep boolean-like tokens by default', () => {
expect(sanitizeBm25Query('alpha AND beta')).toBe('alpha AND AND AND beta');
});
it('should drop boolean operator tokens from user input', () => {
expect(sanitizeBm25Query('alpha AND beta or NOT gamma', SAFE_BM25_QUERY_OPTIONS)).toBe(
'alpha AND beta AND gamma',
);
});
it('should cap the number of terms to avoid pathological long queries', () => {
const longQuery = Array.from({ length: 80 }, (_, index) => `term${index + 1}`).join(' ');
const result = sanitizeBm25Query(longQuery, SAFE_BM25_QUERY_OPTIONS);
expect(result.split(' AND ')).toHaveLength(48);
});
// NOTICE:
// Public util behavior is configurable by design; these tests protect the
// options contract for call sites with different needs.
it('should allow overriding safe behavior when configured', () => {
expect(sanitizeBm25Query('alpha AND beta', { ...SAFE_BM25_QUERY_OPTIONS, maxTerms: 2 })).toBe(
'alpha AND beta',
);
});
it('should allow overriding max terms', () => {
expect(sanitizeBm25Query('a b c d', { maxTerms: 2 })).toBe('a AND b');
});
it('should sanitize long tool-result-like payloads safely', () => {
const payload = `
TOOL: <searchResults>
<item title="No. 12 Gonzaga carves up Maryland defense in 100-61 rout"
url="https://gozags.com/news/2025/11/25/mens-basketball-no-12-gonzaga-carves-up-maryland.aspx" />
<item title="Men's Basketball Falls in Tight Matchup Against Brown"
url="https://goblackbears.com/news/2025/11/23/mens-basketball-falls-in-tight-matchup-against-brown.aspx" />
</searchResults>
ASSISTANT: Let me search for "lost by 30 points" "November 2023" "college basketball"
AND OR NOT AND OR NOT AND OR NOT
`
.repeat(30)
.trim();
const result = sanitizeBm25Query(payload, SAFE_BM25_QUERY_OPTIONS);
const terms = result.split(' AND ');
expect(terms).toHaveLength(48);
expect(terms).not.toContain('AND');
expect(terms).not.toContain('OR');
expect(terms).not.toContain('NOT');
});
});
+26 -1
View File
@@ -1,16 +1,41 @@
const BM25_BOOLEAN_OPERATORS = new Set(['AND', 'OR', 'NOT']);
const BM25_MAX_TERMS = 48;
// NOTICE:
// This utility is used by multiple lexical search paths. We keep safe defaults
// to prevent parser-hostile queries (for example, huge tool-output payloads
// containing many boolean-like tokens), while exposing options so specific
// call sites can tune behavior if they have stricter/looser requirements.
export interface SanitizeBm25QueryOptions {
dropBooleanOperators?: boolean;
maxTerms?: number;
}
export const SAFE_BM25_QUERY_OPTIONS: Required<SanitizeBm25QueryOptions> = {
dropBooleanOperators: true,
maxTerms: BM25_MAX_TERMS,
};
/**
* Escape special tantivy query syntax characters and join terms with AND
* so all words must match (instead of Tantivy's default OR behavior).
*/
export function sanitizeBm25Query(query: string): string {
export function sanitizeBm25Query(query: string, options: SanitizeBm25QueryOptions = {}): string {
const { dropBooleanOperators = false, maxTerms } = options;
const terms = query
.trim()
.replaceAll('-', ' ') // treat hyphens as word separators (ICU tokenizer does the same)
.split(/\s+/)
.map((word) => word.trim())
.filter((word) => !dropBooleanOperators || !BM25_BOOLEAN_OPERATORS.has(word.toUpperCase()))
.map((word) => word.replaceAll(/[+&|!(){}[\]^"~*?:\\/]/g, '\\$&'))
.filter(Boolean);
if (terms.length === 0) throw new Error('Query is empty after sanitization');
if (typeof maxTerms === 'number') {
return terms.slice(0, Math.max(1, maxTerms)).join(' AND ');
}
return terms.join(' AND ');
}
@@ -5,7 +5,7 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
abilities: {
functionCall: true,
reasoning: true,
search: true,
search: false,
},
contextWindowTokens: 1_000_000,
description:
@@ -51,7 +51,6 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
releasedAt: '2026-03-18',
settings: {
extendParams: ['enableReasoning'],
searchImpl: 'params',
},
type: 'chat',
},
@@ -59,7 +58,7 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
abilities: {
functionCall: true,
reasoning: true,
search: true,
search: false,
video: true,
vision: true,
},
@@ -83,7 +82,6 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
releasedAt: '2026-03-18',
settings: {
extendParams: ['enableReasoning'],
searchImpl: 'params',
},
type: 'chat',
},
@@ -91,7 +89,7 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
abilities: {
functionCall: true,
reasoning: true,
search: true,
search: false,
},
contextWindowTokens: 262_144,
description:
@@ -113,7 +111,6 @@ export const xiaomimimoChatModels: AIChatModelCard[] = [
releasedAt: '2026-03-03',
settings: {
extendParams: ['enableReasoning'],
searchImpl: 'params',
},
type: 'chat',
},
@@ -8,7 +8,7 @@ export const zhipuChatModels: AIChatModelCard[] = [
abilities: {
functionCall: true,
reasoning: true,
search: true,
search: false,
},
contextWindowTokens: 200_000,
description:
@@ -27,7 +27,6 @@ export const zhipuChatModels: AIChatModelCard[] = [
releasedAt: '2026-03-27',
settings: {
extendParams: ['enableReasoning'],
searchImpl: 'params',
},
type: 'chat',
},
@@ -28,6 +28,13 @@ export interface ExecAgentParams {
appContext?: ExecAgentAppContext;
/** Whether to auto-start execution after creating operation (default: true) */
autoStart?: boolean;
/**
* Runtime of the client initiating this request. Used by the server to
* enable `executor: 'client'` tools (e.g. local-system) when the caller
* is a desktop Electron client that will receive `tool_execute` events
* over the same Agent Gateway WebSocket.
*/
clientRuntime?: 'desktop' | 'web';
/** Explicit device ID to bind to the topic and activate for this run */
deviceId?: string;
/** Optional existing message IDs to include in context */
+79
View File
@@ -38,6 +38,85 @@ export interface TaskTopicHandoff {
title?: string;
}
// ── Task list item (shared between router response and client) ──
export interface TaskParticipant {
avatar: string | null;
id: string;
name: string;
type: 'user' | 'agent';
}
export interface TaskItem {
accessedAt: Date;
assigneeAgentId: string | null;
assigneeUserId: string | null;
completedAt: Date | null;
config: unknown;
context: unknown;
createdAt: Date;
createdByAgentId: string | null;
createdByUserId: string;
currentTopicId: string | null;
description: string | null;
error: string | null;
heartbeatInterval: number | null;
heartbeatTimeout: number | null;
id: string;
identifier: string;
instruction: string;
lastHeartbeatAt: Date | null;
maxTopics: number | null;
name: string | null;
parentTaskId: string | null;
priority: number | null;
schedulePattern: string | null;
scheduleTimezone: string | null;
seq: number;
sortOrder: number | null;
startedAt: Date | null;
status: string;
totalTopics: number | null;
updatedAt: Date;
}
export type TaskListItem = TaskItem & {
participants: TaskParticipant[];
};
export interface NewTask {
accessedAt?: Date;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
completedAt?: Date | null;
config?: unknown;
context?: unknown;
createdAt?: Date;
createdByAgentId?: string | null;
createdByUserId: string;
currentTopicId?: string | null;
description?: string | null;
error?: string | null;
heartbeatInterval?: number | null;
heartbeatTimeout?: number | null;
id?: string;
identifier: string;
instruction: string;
lastHeartbeatAt?: Date | null;
maxTopics?: number | null;
name?: string | null;
parentTaskId?: string | null;
priority?: number | null;
schedulePattern?: string | null;
scheduleTimezone?: string | null;
seq: number;
sortOrder?: number | null;
startedAt?: Date | null;
status?: string;
totalTopics?: number | null;
updatedAt?: Date;
}
// ── Task Detail (shared across CLI, viewTask tool, task.detail router) ──
export interface TaskDetailSubtask {
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { detectTruncatedJSON } from './detectTruncatedJSON';
describe('detectTruncatedJSON', () => {
it('returns null for a balanced object', () => {
expect(detectTruncatedJSON('{"a": 1}')).toBeNull();
});
it('returns null for a balanced nested structure', () => {
expect(detectTruncatedJSON('{"a": {"b": [1, 2]}}')).toBeNull();
});
it('returns null for empty string', () => {
expect(detectTruncatedJSON('')).toBeNull();
});
it('flags an object with an unclosed brace (typical LLM cutoff)', () => {
const truncated = '{"title": "foo", "description": "bar", "type": "report"';
expect(detectTruncatedJSON(truncated)).toMatch(/unclosed '\{'/);
});
it('flags an unterminated string value', () => {
const truncated = '{"title": "foo", "content": "this got cut';
expect(detectTruncatedJSON(truncated)).toBe('unterminated string');
});
it('flags an unclosed array', () => {
const truncated = '[1, 2, 3';
expect(detectTruncatedJSON(truncated)).toMatch(/unclosed '\['/);
});
it('flags structure with both unclosed braces and brackets', () => {
const truncated = '{"items": [1, 2, 3';
// Any of the unclosed-bracket/brace reasons is acceptable — both are present.
expect(detectTruncatedJSON(truncated)).toMatch(/unclosed/);
});
it('returns null for malformed-but-balanced JSON (not a truncation signal)', () => {
// invalid JSON but brackets balanced — should NOT be flagged as truncated
expect(detectTruncatedJSON('{name: "foo"}')).toBeNull();
});
it('ignores braces and quotes inside string values', () => {
expect(detectTruncatedJSON('{"code": "if (a) { return \\"x\\"; }"}')).toBeNull();
});
it('flags deeply nested object truncation', () => {
const truncated = '{"a": {"b": {"c": "d"';
expect(detectTruncatedJSON(truncated)).toMatch(/unclosed '\{'/);
});
it('flags truncation mid-string inside nested objects', () => {
const truncated = '{"a": {"b": {"c": "still writing';
expect(detectTruncatedJSON(truncated)).toBe('unterminated string');
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Detect whether a JSON string looks structurally truncated typical when an
* LLM's `max_tokens` budget runs out mid-generation of a tool call payload.
*
* Returns a short reason string when truncation is suspected, or `null` when
* the structure looks balanced (in which case any parse failure is more likely
* a plain syntax error rather than truncation).
*
* Intended to be called AFTER `JSON.parse` has already failed, to distinguish
* "truncated by max_tokens" from "malformed but complete".
*/
export const detectTruncatedJSON = (text: string): string | null => {
if (!text) return null;
let braces = 0;
let brackets = 0;
let inString = false;
let escape = false;
for (const ch of text) {
if (escape) {
escape = false;
continue;
}
if (inString) {
if (ch === '\\') escape = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') braces++;
else if (ch === '}') braces--;
else if (ch === '[') brackets++;
else if (ch === ']') brackets--;
}
if (inString) return 'unterminated string';
if (braces > 0) return `${braces} unclosed '{'`;
if (brackets > 0) return `${brackets} unclosed '['`;
return null;
};
+2 -1
View File
@@ -1,8 +1,9 @@
export * from './base64';
export * from './dedupeBy';
export * from './chunkers';
export * from './client/cookie';
export * from './dedupeBy';
export * from './detectChinese';
export * from './detectTruncatedJSON';
export * from './env';
export * from './error';
export * from './folderStructure';
@@ -0,0 +1,93 @@
import debug from 'debug';
import { type NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { gatewayEnv } from '@/envs/gateway';
import {
BOT_RUNTIME_STATUSES,
type BotRuntimeStatus,
updateBotRuntimeStatus,
} from '@/server/services/gateway/runtimeStatus';
const log = debug('api-route:agent:gateway:callback');
const StateChangeSchema = z.object({
applicationId: z.string().optional(),
connectionId: z.string(),
platform: z.string(),
state: z.object({
error: z.string().optional(),
status: z.enum(['connected', 'connecting', 'disconnected', 'error']),
}),
});
/**
* Receive connection state-change callbacks from the external message gateway.
* When a persistent connection (e.g. Discord WebSocket) transitions to
* "connected" or "error" asynchronously, the gateway POSTs here so LobeHub
* can update the bot runtime status visible to users.
*
* Authenticated with MESSAGE_GATEWAY_SERVICE_TOKEN.
*/
export async function POST(request: NextRequest) {
// Ignore callbacks when gateway is disabled — connections are managed locally,
// and stale gateway callbacks (e.g. from disconnectAll during migration) could
// overwrite locally-managed status.
if (gatewayEnv.MESSAGE_GATEWAY_ENABLED !== '1') {
return new NextResponse(null, { status: 204 });
}
const serviceToken = gatewayEnv.MESSAGE_GATEWAY_SERVICE_TOKEN;
if (!serviceToken) {
return NextResponse.json({ error: 'Service not configured' }, { status: 503 });
}
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${serviceToken}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let parsed;
try {
const body = await request.json();
parsed = StateChangeSchema.safeParse(body);
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid body', issues: parsed.error.issues },
{ status: 400 },
);
}
const { applicationId, platform, state } = parsed.data;
if (!applicationId) {
return new NextResponse(null, { status: 204 });
}
const statusMap: Partial<Record<string, BotRuntimeStatus>> = {
connected: BOT_RUNTIME_STATUSES.connected,
disconnected: BOT_RUNTIME_STATUSES.disconnected,
error: BOT_RUNTIME_STATUSES.failed,
};
const runtimeStatus = statusMap[state.status];
if (!runtimeStatus) {
// "connecting" — no status update needed
return new NextResponse(null, { status: 204 });
}
await updateBotRuntimeStatus({
applicationId,
errorMessage: state.error,
platform,
status: runtimeStatus,
});
log('Updated %s:%s → %s', platform, applicationId, runtimeStatus);
return new NextResponse(null, { status: 204 });
}
+8 -4
View File
@@ -123,11 +123,15 @@ export async function GET(request: NextRequest) {
return new Response('Unauthorized', { status: 401 });
}
// When an external message gateway is fully configured (both URL and service
// token), it manages all persistent connections. Skip in-process bot startup
// to avoid duplicate connections.
// When the external message gateway is enabled, sync connections via gateway.
if (process.env.MESSAGE_GATEWAY_URL && process.env.MESSAGE_GATEWAY_SERVICE_TOKEN) {
return Response.json({ skipped: true, reason: 'using external message gateway' });
const { GatewayService } = await import('@/server/services/gateway');
const service = new GatewayService();
if (service.useMessageGateway) {
await service.ensureRunning();
return Response.json({ ensureRunning: true });
}
}
const platforms = platformRegistry.listPlatforms();
+2
View File
@@ -6,6 +6,7 @@ export const getGatewayConfig = () => {
runtimeEnv: {
DEVICE_GATEWAY_SERVICE_TOKEN: process.env.DEVICE_GATEWAY_SERVICE_TOKEN,
DEVICE_GATEWAY_URL: process.env.DEVICE_GATEWAY_URL,
MESSAGE_GATEWAY_ENABLED: process.env.MESSAGE_GATEWAY_ENABLED,
MESSAGE_GATEWAY_SERVICE_TOKEN: process.env.MESSAGE_GATEWAY_SERVICE_TOKEN,
MESSAGE_GATEWAY_URL: process.env.MESSAGE_GATEWAY_URL,
},
@@ -13,6 +14,7 @@ export const getGatewayConfig = () => {
server: {
DEVICE_GATEWAY_SERVICE_TOKEN: z.string().optional(),
DEVICE_GATEWAY_URL: z.string().url().optional(),
MESSAGE_GATEWAY_ENABLED: z.string().optional(),
MESSAGE_GATEWAY_SERVICE_TOKEN: z.string().optional(),
MESSAGE_GATEWAY_URL: z.string().url().optional(),
},
+2 -1
View File
@@ -18,7 +18,8 @@ export const useInitAgentConfig = (agentId?: string) => {
const params = useParams<{ aid?: string }>();
const id = agentId || activeAgentId || params.aid || '';
// Prioritize URL params over store's activeAgentId to avoid stale ID from previous navigation
const id = agentId || params.aid || activeAgentId || '';
const data = useFetchAgentConfig(isLogin, id);
+23
View File
@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import type { NavigateFunction } from 'react-router-dom';
import { getStableNavigate } from '@/utils/stableNavigate';
/**
* Stable `navigate` that forwards to the live ref on each call (see `NavigatorRegistrar`).
* Prefer over subscribing to `navigationRef` from `useGlobalStore` in components.
*/
export function useStableNavigate(): NavigateFunction {
return useCallback(
((to, options) => {
const navigate = getStableNavigate();
if (!navigate) return;
if (typeof to === 'number') {
navigate(to);
} else {
navigate(to, options);
}
}) as NavigateFunction,
[],
);
}
+4 -4
View File
@@ -4,13 +4,13 @@ export async function register() {
await import('./libs/debug-file-logger');
}
// Auto-start GatewayManager / sync message-gateway connections on server start.
// - Non-Vercel (Docker, local): always run — persistent bots need reconnection after restart.
// - Vercel: only run when an external message gateway is configured, to sync connection state.
// Auto-start GatewayManager on server start for non-Vercel environments (Docker, local).
// Persistent bots need reconnection after restart.
// On Vercel, the cron job at /api/agent/gateway handles this reliably instead.
if (
process.env.NEXT_RUNTIME === 'nodejs' &&
process.env.DATABASE_URL &&
(!process.env.VERCEL_ENV || process.env.MESSAGE_GATEWAY_URL)
!process.env.VERCEL_ENV
) {
const { GatewayService } = await import('./server/services/gateway');
const service = new GatewayService();
+54
View File
@@ -401,6 +401,60 @@ describe('AgentStreamClient', () => {
});
});
describe('sendToolResult', () => {
it('should send a successful tool_result message', async () => {
const client = createClient();
const ws = await connectAndAuth(client);
const ok = client.sendToolResult({
content: '{"files":["a.txt"]}',
success: true,
toolCallId: 'call_1',
});
expect(ok).toBe(true);
const toolResult = ws.sent.find((s) => JSON.parse(s).type === 'tool_result');
expect(toolResult).toBeDefined();
expect(JSON.parse(toolResult!)).toEqual({
content: '{"files":["a.txt"]}',
success: true,
toolCallId: 'call_1',
type: 'tool_result',
});
});
it('should send an error tool_result message', async () => {
const client = createClient();
const ws = await connectAndAuth(client);
client.sendToolResult({
content: null,
error: { message: 'ipc failed', type: 'ipc_error' },
success: false,
toolCallId: 'call_2',
});
const toolResult = ws.sent.find((s) => JSON.parse(s).type === 'tool_result');
expect(JSON.parse(toolResult!)).toEqual({
content: null,
error: { message: 'ipc failed', type: 'ipc_error' },
success: false,
toolCallId: 'call_2',
type: 'tool_result',
});
});
it('should return false when socket is not open', () => {
const client = createClient();
const ok = client.sendToolResult({
content: null,
success: false,
toolCallId: 'call_3',
});
expect(ok).toBe(false);
});
});
describe('disconnect', () => {
it('should clean up timers on disconnect', async () => {
const client = createClient();
+14 -1
View File
@@ -5,6 +5,7 @@ import type {
ClientMessage,
ConnectionStatus,
ServerMessage,
ToolResultMessage,
} from './types';
// ─── Constants ───
@@ -146,6 +147,16 @@ export class AgentStreamClient extends TypedEmitter {
this.sendMessage({ type: 'interrupt' });
}
/**
* Send a tool execution result back to the server.
* Correlated by toolCallId; the server's agent loop is blocked on BLPOP until this arrives.
* Returns true when the payload was handed off to the WebSocket, false when no live socket
* is available (caller should fall back to server-side BLPOP timeout).
*/
sendToolResult(result: Omit<ToolResultMessage, 'type'>): boolean {
return this.sendMessage({ ...result, type: 'tool_result' });
}
/**
* Update the auth token (e.g. after JWT refresh). Call connect() or wait for auto-reconnect.
*/
@@ -418,10 +429,12 @@ export class AgentStreamClient extends TypedEmitter {
// ─── Helpers ───
private sendMessage(data: ClientMessage): void {
private sendMessage(data: ClientMessage): boolean {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
return true;
}
return false;
}
private closeWebSocket(): void {
+2
View File
@@ -10,5 +10,7 @@ export type {
StreamChunkType,
StreamStartData,
ToolEndData,
ToolExecuteData,
ToolResultMessage,
ToolStartData,
} from './types';
+2 -10
View File
@@ -1,11 +1,9 @@
import { Flexbox } from '@lobehub/ui';
import { useTheme } from 'antd-style';
import { type FC, type ReactNode } from 'react';
import { Activity, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Activity, type FC, type ReactNode, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useIsDark } from '@/hooks/useIsDark';
import { useHomeStore } from '@/store/home';
import HomeAgentIdSync from './HomeAgentIdSync';
import RecentHydration from './RecentHydration';
@@ -19,17 +17,11 @@ interface LayoutProps {
const Layout: FC<LayoutProps> = ({ children }) => {
const isDarkMode = useIsDark();
const theme = useTheme(); // Keep for colorBgContainerSecondary (not in cssVar)
const navigate = useNavigate();
const { pathname } = useLocation();
const isHomeRoute = pathname === '/';
const [hasActivated, setHasActivated] = useState(isHomeRoute);
const setNavigate = useHomeStore((s) => s.setNavigate);
const content = children ?? <Outlet />;
useEffect(() => {
setNavigate(navigate);
}, [navigate, setNavigate]);
useEffect(() => {
if (isHomeRoute) setHasActivated(true);
}, [isHomeRoute]);
@@ -8,6 +8,7 @@ import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useInitBuiltinAgent } from '@/hooks/useInitBuiltinAgent';
import { useStableNavigate } from '@/hooks/useStableNavigate';
import { type StarterMode } from '@/store/home';
import { useHomeStore } from '@/store/home';
@@ -52,10 +53,10 @@ const StarterList = memo(() => {
useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.groupAgentBuilder);
useInitBuiltinAgent(BUILTIN_AGENT_SLUGS.pageAgent);
const [inputActiveMode, setInputActiveMode, navigate] = useHomeStore((s) => [
const navigate = useStableNavigate();
const [inputActiveMode, setInputActiveMode] = useHomeStore((s) => [
s.inputActiveMode,
s.setInputActiveMode,
s.navigate,
]);
const items: StarterItem[] = useMemo(
@@ -99,12 +100,12 @@ const StarterList = memo(() => {
const handleClick = useCallback(
(key: StarterMode) => {
if (key === 'video') {
navigate?.('/video?model=doubao-seedance-2-0-260128');
navigate('/video?model=doubao-seedance-2-0-260128');
return;
}
if (key === 'image') {
navigate?.('/image');
navigate('/image');
return;
}
@@ -115,7 +116,7 @@ const StarterList = memo(() => {
setInputActiveMode(key);
}
},
[inputActiveMode, setInputActiveMode, navigate],
[inputActiveMode, navigate, setInputActiveMode],
);
return (
-2
View File
@@ -8,7 +8,6 @@ import Loading from '@/components/Loading/BrandTextLoading';
import { MarketAuthProvider } from '@/layout/AuthProvider/MarketAuth';
import dynamic from '@/libs/next/dynamic';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { NavigatorRegistrar } from '@/utils/router';
import NavBar from './NavBar';
@@ -31,7 +30,6 @@ const MobileMainLayout: FC = () => {
const showNav = MOBILE_NAV_ROUTES.has(pathname);
return (
<>
<NavigatorRegistrar />
<Suspense fallback={null}>{showCloudPromotion && <CloudBanner mobile />}</Suspense>
<MarketAuthProvider isDesktop={false}>
<Suspense fallback={<Loading debugId="MobileMainLayout > Outlet" />}>
@@ -39,6 +39,7 @@ import { serverMessagesEngine } from '@/server/modules/Mecha/ContextEngineering'
import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/types';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import { FileService } from '@/server/services/file';
import { MessageService } from '@/server/services/message';
import { OnboardingService } from '@/server/services/onboarding';
import {
@@ -89,6 +90,26 @@ const getToolFailureKind = (result: ToolExecutionResultResponse): ToolFailureKin
const shouldRetryTool = (kind: ToolFailureKind | undefined, attempt: number, maxRetries: number) =>
kind === 'retry' && attempt <= maxRetries;
// Builds a postProcessUrl callback that resolves S3 keys in file-backed fields
// (imageList, videoList, fileList) to absolute URLs. Must be passed to every
// messageModel.query() call whose output is later fed to the LLM — otherwise
// the provider layer receives raw keys like `files/user_xxx/icon.png` and
// rejects them (see anthropic contextBuilder `Invalid image URL`).
//
// FileService is constructed lazily so environments without S3 config (unit
// tests) don't fail at context-build time; failure returns undefined, which
// leaves URLs as raw keys — same behavior as before this helper existed.
const buildPostProcessUrl = (ctx: Pick<RuntimeExecutorContext, 'serverDB' | 'userId'>) => {
if (!ctx.userId || !ctx.serverDB) return undefined;
let fileService: FileService | undefined;
try {
fileService = new FileService(ctx.serverDB, ctx.userId);
} catch {
return undefined;
}
return (path: string | null) => fileService!.getFullFileUrl(path);
};
const shouldRetryLLM = (kind: LLMErrorKind, attempt: number, maxRetries: number) =>
kind === 'retry' && attempt <= maxRetries;
@@ -217,7 +238,6 @@ export interface RuntimeExecutorContext {
botPlatformContext?: any;
discordContext?: any;
evalContext?: EvalContext;
fileService?: any;
loadAgentState?: (operationId: string) => Promise<AgentState | null>;
messageModel: MessageModel;
operationId: string;
@@ -373,11 +393,14 @@ export const createRuntimeExecutors = (
async (topicId) => topicModel.findById(topicId),
async (topicId) => {
const topic = await topicModel.findById(topicId);
return messageModel.query({
agentId: topic?.agentId ?? undefined,
groupId: topic?.groupId ?? undefined,
topicId,
});
return messageModel.query(
{
agentId: topic?.agentId ?? undefined,
groupId: topic?.groupId ?? undefined,
topicId,
},
{ postProcessUrl: buildPostProcessUrl(ctx) },
);
},
);
}
@@ -1060,11 +1083,14 @@ export const createRuntimeExecutors = (
}
try {
const dbMessages = await ctx.messageModel.query({
agentId: state.metadata?.agentId,
threadId: state.metadata?.threadId,
topicId,
});
const dbMessages = await ctx.messageModel.query(
{
agentId: state.metadata?.agentId,
threadId: state.metadata?.threadId,
topicId,
},
{ postProcessUrl: buildPostProcessUrl(ctx) },
);
const messageIds = dbMessages
.filter(
@@ -1363,6 +1389,12 @@ export const createRuntimeExecutors = (
});
execution = { attempts: 1, result: dispatchResult };
} else {
// Inject source from sourceMap so BuiltinToolsExecutor can route
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
if (toolSource && !chatToolPayload.source) {
chatToolPayload.source = toolSource;
}
// Execute tool using ToolExecutionService
log(`[${operationLogId}] Executing tool ${toolName} ...`);
execution = await executeToolWithRetry(
@@ -1657,6 +1689,15 @@ export const createRuntimeExecutors = (
});
execution = { attempts: 1, result: dispatchResult };
} else {
// Inject source from sourceMap so BuiltinToolsExecutor can route
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
const batchToolSource =
state.operationToolSet?.sourceMap?.[chatToolPayload.identifier] ??
state.toolSourceMap?.[chatToolPayload.identifier];
if (batchToolSource && !chatToolPayload.source) {
chatToolPayload.source = batchToolSource;
}
execution = await executeToolWithRetry(
() =>
toolExecutionService.executeTool(chatToolPayload, {
@@ -1816,11 +1857,17 @@ export const createRuntimeExecutors = (
// Query latest messages from database
// Must pass agentId to ensure correct query scope, otherwise when topicId is undefined,
// the query will use isNull(topicId) condition which won't find messages with actual topicId
const latestMessages = await ctx.messageModel.query({
agentId: state.metadata?.agentId,
threadId: state.metadata?.threadId,
topicId: state.metadata?.topicId,
});
//
// postProcessUrl resolves S3 keys in imageList/videoList/fileList to absolute URLs;
// without it the next LLM call sees raw keys and providers reject them.
const latestMessages = await ctx.messageModel.query(
{
agentId: state.metadata?.agentId,
threadId: state.metadata?.threadId,
topicId: state.metadata?.topicId,
},
{ postProcessUrl: buildPostProcessUrl(ctx) },
);
// Use conversation-flow parse to resolve branching into linear flat list
// parse() handles assistantGroup, compare, supervisor, etc. virtual message types
@@ -1771,11 +1771,14 @@ describe('RuntimeExecutors', () => {
const result = await executors.call_tools_batch!(instruction, state);
// Should query messages from database with agentId, threadId, and topicId
expect(mockMessageModel.query).toHaveBeenCalledWith({
agentId: 'agent-123',
threadId: 'thread-123',
topicId: 'topic-123',
});
expect(mockMessageModel.query).toHaveBeenCalledWith(
{
agentId: 'agent-123',
threadId: 'thread-123',
topicId: 'topic-123',
},
expect.any(Object),
);
// Messages should be refreshed from database (4 messages from mock)
expect(result.newState.messages).toHaveLength(4);
@@ -2099,11 +2102,14 @@ describe('RuntimeExecutors', () => {
await executors.call_tools_batch!(instruction, state);
// Should query messages with agentId, threadId, and topicId from state.metadata
expect(mockMessageModel.query).toHaveBeenCalledWith({
agentId: 'agent-abc',
threadId: 'thread-xyz',
topicId: 'topic-abc-123',
});
expect(mockMessageModel.query).toHaveBeenCalledWith(
{
agentId: 'agent-abc',
threadId: 'thread-xyz',
topicId: 'topic-abc-123',
},
expect.any(Object),
);
});
// LOBE-5143: After DB refresh, state.messages stores raw UIChatMessage[]
@@ -2235,11 +2241,14 @@ describe('RuntimeExecutors', () => {
const result = await executors.call_tools_batch!(instruction, state);
// Verify agentId is passed in the query
expect(mockMessageModel.query).toHaveBeenCalledWith({
agentId: 'agent-123',
threadId: 'thread-123',
topicId: undefined,
});
expect(mockMessageModel.query).toHaveBeenCalledWith(
{
agentId: 'agent-123',
threadId: 'thread-123',
topicId: undefined,
},
expect.any(Object),
);
// Expected: newState.messages should NOT be empty
// The next call_llm step needs messages to work properly
@@ -49,4 +49,86 @@ describe('classifyLLMError', () => {
it('should default unknown errors to retry', () => {
expect(classifyLLMError(new Error('unexpected upstream issue')).kind).toBe('retry');
});
describe('non-string code / errorType defensive handling', () => {
// Regression: real-world provider errors sometimes carry numeric `code`
// (HTTP status) or a structured object in the error fields. Earlier versions
// called `.trim()` on these and threw TypeError, which masked the original
// provider error behind "e.trim is not a function".
it('does not throw when error.code is a number', () => {
const result = classifyLLMError({ code: 429, message: 'rate limit' });
expect(result.message).toBe('rate limit');
// Classifier should still land on a valid kind, not crash.
expect(['retry', 'stop']).toContain(result.kind);
});
it('does not throw when errorType is an object', () => {
const result = classifyLLMError({
errorType: { nested: 'structured error' },
message: 'upstream returned structured type',
});
expect(result.message).toBe('upstream returned structured type');
expect(['retry', 'stop']).toContain(result.kind);
});
it('does not throw when nested error.code is a number (OpenAI SDK shape)', () => {
const result = classifyLLMError({
error: { error: { code: 402, message: 'payment required' } },
errorType: 'ProviderBizError',
});
expect(result.message).toBe('payment required');
expect(['retry', 'stop']).toContain(result.kind);
});
// Regression: some third-party proxies surface HTTP status ONLY as a
// numeric `code` (no `status`/`statusCode`, no status digits in the
// message). Previously these fell through to `retry`, causing wasteful
// retry loops on permanent auth/permission failures.
it('treats numeric code=401 as stop when no status field is present', () => {
const result = classifyLLMError({ code: 401, message: 'upstream refused' });
expect(result.kind).toBe('stop');
});
it('treats numeric code=403 as stop when no status field is present', () => {
const result = classifyLLMError({ code: 403, message: 'upstream refused' });
expect(result.kind).toBe('stop');
});
it('treats numeric code=429 as retry when no status field is present', () => {
const result = classifyLLMError({ code: 429, message: 'upstream refused' });
expect(result.kind).toBe('retry');
});
it('treats nested numeric code as stop (proxy-wrapped auth failure)', () => {
const result = classifyLLMError({
error: { error: { code: 401, message: 'proxy refused upstream' } },
});
expect(result.kind).toBe('stop');
});
it('prefers explicit status over numeric code fallback', () => {
// status says 500 (retry), code says 401 (stop) — status wins.
const result = classifyLLMError({ code: 401, message: 'oops', status: 500 });
expect(result.kind).toBe('retry');
});
it('preserves the original error message when normalizeSignal itself would throw', () => {
// Force a normalizeSignal crash by making `.toLowerCase()` blow up on a
// non-string message (via a Proxy that throws on Symbol.toPrimitive).
const hostile = new Proxy(
{},
{
get: () => {
throw new Error('property access explodes');
},
},
);
const result = classifyLLMError(hostile);
// Falls back to 'stop' when classifier throws; message is best-effort.
expect(result.kind).toBe('stop');
expect(typeof result.message).toBe('string');
});
});
});
@@ -64,8 +64,8 @@ const STOP_KEYWORDS = [
const hasAnyKeyword = (text: string, keywords: string[]) =>
keywords.some((keyword) => text.includes(keyword));
const normalizeCode = (value?: string) => {
if (!value) return;
const normalizeCode = (value?: unknown): string | undefined => {
if (typeof value !== 'string' || !value) return;
return value
.trim()
@@ -73,7 +73,11 @@ const normalizeCode = (value?: string) => {
.replaceAll(/[\s-]+/g, '_');
};
const normalizeErrorType = (value?: string) => value?.trim();
const normalizeErrorType = (value?: unknown): string | undefined => {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
};
const tryExtractStatus = (message: string) => {
const matches = message.match(/\b([45]\d{2})\b/);
@@ -83,6 +87,17 @@ const tryExtractStatus = (message: string) => {
return Number.isNaN(status) ? undefined : status;
};
// Some providers (notably bare HTTP proxies) only surface the HTTP status as a
// numeric `code` on the error object, with no `status`/`statusCode`. Treat
// those numeric codes as status so classifyKind can still map 401/403 to stop
// and 429/5xx to retry without falling through to message-keyword matching.
const numericStatusFromCode = (...codes: unknown[]): number | undefined => {
for (const code of codes) {
if (typeof code === 'number' && Number.isFinite(code)) return code;
}
return undefined;
};
const normalizeSignal = (error: unknown): LLMErrorSignal => {
if (typeof error === 'string') {
const message = error.toLowerCase();
@@ -91,11 +106,11 @@ const normalizeSignal = (error: unknown): LLMErrorSignal => {
if (error instanceof Error) {
const raw = error as Error & {
code?: string;
errorType?: string;
code?: unknown;
errorType?: unknown;
status?: number;
statusCode?: number;
type?: string;
type?: unknown;
};
const message = (raw.message || raw.name || 'unknown error').toLowerCase();
@@ -108,26 +123,26 @@ const normalizeSignal = (error: unknown): LLMErrorSignal => {
? raw.status
: typeof raw.statusCode === 'number'
? raw.statusCode
: tryExtractStatus(message),
: (numericStatusFromCode(raw.code) ?? tryExtractStatus(message)),
};
}
if (error && typeof error === 'object') {
const raw = error as {
code?: string;
code?: unknown;
error?: {
code?: string;
error?: { code?: string; message?: string; status?: number; type?: string };
errorType?: string;
code?: unknown;
error?: { code?: unknown; message?: string; status?: number; type?: unknown };
errorType?: unknown;
message?: string;
status?: number;
type?: string;
type?: unknown;
};
errorType?: string;
errorType?: unknown;
message?: string;
status?: number;
statusCode?: number;
type?: string;
type?: unknown;
};
const nested = raw.error;
const nestedError = nested?.error;
@@ -153,7 +168,8 @@ const normalizeSignal = (error: unknown): LLMErrorSignal => {
? nested.status
: typeof nestedError?.status === 'number'
? nestedError.status
: tryExtractStatus(message),
: (numericStatusFromCode(raw.code, nested?.code, nestedError?.code) ??
tryExtractStatus(message)),
};
}
@@ -198,14 +214,47 @@ const classifyKind = ({ code, errorType, message, status }: LLMErrorSignal): LLM
return 'retry';
};
export const classifyLLMError = (error: unknown): ClassifiedLLMError => {
const signal = normalizeSignal(error);
/**
* Extract a human-readable message for the fallback path without relying on
* normalizeSignal (which might be the thing that just threw).
*/
const bestEffortMessage = (error: unknown): string => {
try {
if (error instanceof Error && typeof error.message === 'string' && error.message.length > 0) {
return error.message;
}
if (typeof error === 'string' && error.length > 0) return error;
if (error && typeof error === 'object') {
const e = error as { message?: unknown; error?: { message?: unknown } };
if (typeof e.message === 'string' && e.message.length > 0) return e.message;
const nested = e.error?.message;
if (typeof nested === 'string' && nested.length > 0) return nested;
}
} catch {
// Property access itself can throw (e.g. hostile Proxy). Fall through to default.
}
return 'unknown error';
};
return {
code: signal.code || signal.errorType,
kind: classifyKind(signal),
message: signal.message,
};
export const classifyLLMError = (error: unknown): ClassifiedLLMError => {
// Defensive: a classifier that throws would mask the original provider error
// behind the classifier's own TypeError (e.g. `e.trim is not a function`),
// making prod debugging impossible. If anything below throws, fall back to a
// conservative "stop" decision that preserves the original error message.
try {
const signal = normalizeSignal(error);
return {
code: signal.code || signal.errorType,
kind: classifyKind(signal),
message: signal.message,
};
} catch (classificationError) {
return {
kind: 'stop',
message: bestEffortMessage(error),
};
}
};
export type { ClassifiedLLMError, LLMErrorKind };
@@ -520,4 +520,97 @@ describe('createServerAgentToolsEngine', () => {
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
});
describe('clientRuntime === "desktop" (Phase 6.4)', () => {
it('enables LocalSystem when caller is desktop, regardless of device-proxy config', () => {
// The Agent Gateway WS used to push `tool_execute` is orthogonal to
// the legacy device-proxy. A desktop Electron caller is already the
// execution target — no device-proxy prerequisite required.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [LocalSystemManifest.identifier] },
clientRuntime: 'desktop',
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [LocalSystemManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).toContain(LocalSystemManifest.identifier);
});
it('respects agent-level runtimeMode opt-out for desktop callers', () => {
// User has configured the agent to NOT use local runtime on desktop.
// Even though the caller is a desktop client, local-system stays off.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: {
chatConfig: {
runtimeEnv: { runtimeMode: { desktop: 'none' } },
},
plugins: [LocalSystemManifest.identifier],
},
clientRuntime: 'desktop',
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [LocalSystemManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(LocalSystemManifest.identifier);
});
it('does not enable LocalSystem for web callers even when gateway is configured', () => {
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [LocalSystemManifest.identifier] },
clientRuntime: 'web',
deviceContext: { gatewayConfigured: true },
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [LocalSystemManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(LocalSystemManifest.identifier);
});
it('suppresses RemoteDevice when caller is a desktop client', () => {
// Even when device-proxy is configured, a desktop caller has local IPC
// so the proxy is redundant. Otherwise the LLM might pick RemoteDevice
// first (via `listOnlineDevices` / `activateDevice`) and route tool calls
// to a *different* registered device instead of back to the caller.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: {
plugins: [LocalSystemManifest.identifier, RemoteDeviceManifest.identifier],
},
clientRuntime: 'desktop',
deviceContext: { gatewayConfigured: true },
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [LocalSystemManifest.identifier, RemoteDeviceManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).toContain(LocalSystemManifest.identifier);
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
});
});
@@ -20,6 +20,7 @@ import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing';
import { alwaysOnToolIds, builtinTools, defaultToolIds } from '@lobechat/builtin-tools';
import { createEnableChecker, type LobeToolManifest } from '@lobechat/context-engine';
import { ToolsEngine } from '@lobechat/context-engine';
import { type RuntimeEnvMode, type RuntimePlatform } from '@lobechat/types';
import debug from 'debug';
import {
@@ -94,6 +95,7 @@ export const createServerAgentToolsEngine = (
const {
additionalManifests,
agentConfig,
clientRuntime,
deviceContext,
globalMemoryEnabled = false,
hasAgentDocuments = false,
@@ -102,24 +104,49 @@ export const createServerAgentToolsEngine = (
model,
provider,
} = params;
// ─── Tool-dispatch capability flags ───
//
// Two orthogonal signals control whether client-side tools can run.
//
// 1. `hasClientExecutor` — the caller itself is an Electron desktop
// client and can receive `tool_execute` events over the Agent
// Gateway WebSocket (Phase 6.4).
// 2. `hasDeviceProxy` — the server has a device-proxy configured that
// can tunnel commands to a *separately registered* desktop device
// (legacy Remote Device flow).
//
// Either, both, or neither can be true independently.
const hasClientExecutor = clientRuntime === 'desktop';
const hasDeviceProxy = !!deviceContext?.gatewayConfigured;
// ─── Platform / runtime mode ───
//
// `platform` is a property of the caller, not of the server. Prefer the
// explicit `clientRuntime` signal; fall back to treating a server with
// a configured device-proxy as desktop for callers that don't yet send
// `clientRuntime` (backwards compat).
const platform: RuntimePlatform = clientRuntime ?? (hasDeviceProxy ? 'desktop' : 'web');
// User-configured runtime mode for the current platform, with a
// platform-appropriate default when unset.
const runtimeMode: RuntimeEnvMode =
agentConfig.chatConfig?.runtimeEnv?.runtimeMode?.[platform] ??
(platform === 'desktop' ? 'local' : 'none');
const searchMode = agentConfig.chatConfig?.searchMode ?? 'auto';
const isSearchEnabled = searchMode !== 'off';
// Determine runtime mode based on platform
const isDesktopClient = !!deviceContext?.gatewayConfigured;
const platform = isDesktopClient ? 'desktop' : 'web';
const runtimeMode =
agentConfig.chatConfig?.runtimeEnv?.runtimeMode?.[platform] ??
(isDesktopClient ? 'local' : 'none');
log(
'Creating agent tools engine for model=%s, provider=%s, searchMode=%s, runtimeMode=%s, additionalManifests=%d, deviceGateway=%s',
'Creating agent tools engine model=%s provider=%s searchMode=%s platform=%s runtimeMode=%s additionalManifests=%d hasClientExecutor=%s hasDeviceProxy=%s',
model,
provider,
searchMode,
platform,
runtimeMode,
additionalManifests?.length ?? 0,
!!deviceContext?.gatewayConfigured,
hasClientExecutor,
hasDeviceProxy,
);
return createServerToolsEngine(context, {
@@ -128,6 +155,8 @@ export const createServerAgentToolsEngine = (
// Add default tools based on configuration
defaultToolIds,
enableChecker: createEnableChecker({
// Allow lobe-activator to dynamically enable tools at runtime (e.g., lobe-creds, lobe-cron)
allowExplicitActivation: true,
rules: {
// User-selected plugins
...Object.fromEntries((agentConfig.plugins ?? []).map((id) => [id, true])),
@@ -136,16 +165,26 @@ export const createServerAgentToolsEngine = (
// System-level rules (may override user selection for specific tools)
[CloudSandboxManifest.identifier]: runtimeMode === 'cloud',
[KnowledgeBaseManifest.identifier]: hasEnabledKnowledgeBases,
// Local-system: user must have opted into local runtime on this
// platform (`runtimeMode === 'local'`), AND one execution channel
// must exist:
// - `hasClientExecutor` — Phase 6.4 dispatch over the Agent Gateway
// WS that this request is already riding on; no extra server-side
// prerequisite needed;
// - legacy device-proxy with an online & auto-activated device.
[LocalSystemManifest.identifier]:
runtimeMode === 'local' &&
!!deviceContext?.gatewayConfigured &&
!!deviceContext?.deviceOnline &&
!!deviceContext?.autoActivated,
(hasClientExecutor ||
(hasDeviceProxy && !!deviceContext?.deviceOnline && !!deviceContext?.autoActivated)),
[MemoryManifest.identifier]: globalMemoryEnabled,
// Only auto-enable in bot conversations; otherwise let user's plugin selection take effect
...(isBotConversation && { [MessageManifest.identifier]: true }),
// Remote-device proxy: shown only when the server has a proxy but
// no specific device is auto-activated yet (user must pick). When
// the caller itself can execute `executor: 'client'` tools, the
// proxy is redundant — local-system goes directly to the caller.
[RemoteDeviceManifest.identifier]:
!!deviceContext?.gatewayConfigured && !deviceContext?.autoActivated,
hasDeviceProxy && !deviceContext?.autoActivated && !hasClientExecutor,
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
[WebBrowsingManifest.identifier]: isSearchEnabled,
},
@@ -44,6 +44,13 @@ export interface ServerCreateAgentToolsEngineParams {
/** Plugin IDs enabled for this agent */
plugins?: string[];
};
/**
* Runtime of the client initiating this request. When `'desktop'`, the
* caller itself is an Electron client connected via the Agent Gateway WS,
* so tools with `executor: 'client'` (e.g. local-system, stdio MCP) can be
* dispatched back to it via `tool_execute` no remote-device proxy needed.
*/
clientRuntime?: 'desktop' | 'web';
/** Device gateway context for remote tool calling */
deviceContext?: {
/** When true, a device has been auto-activated — Remote Device tool is unnecessary */
@@ -419,6 +419,31 @@ describe('Task Router Integration', () => {
});
});
describe('list participants', () => {
it('should populate participants from assignee agent', async () => {
const { agents } = await import('@/database/schemas');
const { eq } = await import('drizzle-orm');
await serverDB
.update(agents)
.set({ avatar: 'avatar.png', title: 'Agent One' })
.where(eq(agents.id, testAgentId));
await caller.create({ assigneeAgentId: testAgentId, instruction: 'Task A' });
await caller.create({ instruction: 'Task without assignee' });
const list = await caller.list({});
expect(list.data).toHaveLength(2);
const assigned = list.data.find((t) => t.assigneeAgentId === testAgentId)!;
expect(assigned.participants).toEqual([
{ avatar: 'avatar.png', id: testAgentId, name: 'Agent One', type: 'agent' },
]);
const unassigned = list.data.find((t) => !t.assigneeAgentId)!;
expect(unassigned.participants).toEqual([]);
});
});
describe('heartbeat timeout detection', () => {
it('should auto-detect timeout on detail and pause task', async () => {
const task = await caller.create({
+17 -1
View File
@@ -1,7 +1,7 @@
import { type AgentRuntimeContext } from '@lobechat/agent-runtime';
import { parse } from '@lobechat/conversation-flow';
import { type TaskCurrentActivity, type TaskStatusResult } from '@lobechat/types';
import { ThreadStatus, ThreadType } from '@lobechat/types';
import { ThreadStatus, ThreadType, UserInterventionConfigSchema } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
import pMap from 'p-map';
@@ -91,6 +91,12 @@ const ExecAgentSchema = z
.optional(),
/** Whether to auto-start execution after creating operation */
autoStart: z.boolean().optional().default(true),
/**
* Runtime of the client initiating this request.
* 'desktop' enables `executor: 'client'` tools (local-system, stdio MCP)
* to be dispatched over the Agent Gateway WS.
*/
clientRuntime: z.enum(['desktop', 'web']).optional(),
/** Explicit device ID to bind to the topic and activate for this run */
deviceId: z.string().optional(),
/** Optional existing message IDs to include in context */
@@ -103,6 +109,12 @@ const ExecAgentSchema = z
prompt: z.string(),
/** The agent slug to run (either agentId or slug is required) */
slug: z.string().optional(),
/**
* User intervention configuration for tool approvals.
* Pass `{ approvalMode: 'headless' }` from headless clients (CLI, cron, bots)
* so tool calls auto-execute without waiting for human approval.
*/
userInterventionConfig: UserInterventionConfigSchema.optional(),
})
.refine((data) => data.agentId || data.slug, {
message: 'Either agentId or slug must be provided',
@@ -530,10 +542,12 @@ export const aiAgentRouter = router({
prompt,
appContext,
autoStart = true,
clientRuntime,
deviceId,
existingMessageIds = [],
fileIds,
parentMessageId,
userInterventionConfig,
} = input;
log('execAgent: identifier=%s, prompt=%s', agentId || slug, prompt.slice(0, 50));
@@ -543,6 +557,7 @@ export const aiAgentRouter = router({
agentId,
appContext,
autoStart,
clientRuntime,
deviceId,
existingMessageIds,
fileIds,
@@ -551,6 +566,7 @@ export const aiAgentRouter = router({
// When parentMessageId is provided, this is a regeneration/continue — skip user message creation
resume: !!parentMessageId,
slug,
userInterventionConfig,
});
} catch (error: any) {
console.error('execAgent failed: %O', error);
+33 -2
View File
@@ -2,10 +2,16 @@ import { TaskIdentifier as TaskSkillIdentifier } from '@lobechat/builtin-skills'
import { BriefIdentifier } from '@lobechat/builtin-tool-brief';
import { NotebookIdentifier } from '@lobechat/builtin-tool-notebook';
import { buildTaskRunPrompt } from '@lobechat/prompts';
import type { TaskTopicHandoff, WorkspaceData } from '@lobechat/types';
import type {
TaskListItem,
TaskParticipant,
TaskTopicHandoff,
WorkspaceData,
} from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { AgentModel } from '@/database/models/agent';
import { BriefModel } from '@/database/models/brief';
import { TaskModel } from '@/database/models/task';
import { TaskTopicModel } from '@/database/models/taskTopic';
@@ -21,6 +27,7 @@ const taskProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
return opts.next({
ctx: {
agentModel: new AgentModel(ctx.serverDB, ctx.userId),
briefModel: new BriefModel(ctx.serverDB, ctx.userId),
taskLifecycle: new TaskLifecycleService(ctx.serverDB, ctx.userId),
taskModel: new TaskModel(ctx.serverDB, ctx.userId),
@@ -721,7 +728,31 @@ export const taskRouter = router({
try {
const model = ctx.taskModel;
const result = await model.list(input);
return { data: result.tasks, success: true, total: result.total };
const assigneeIds = [
...new Set(result.tasks.map((t) => t.assigneeAgentId).filter((id): id is string => !!id)),
];
const agents =
assigneeIds.length > 0 ? await ctx.agentModel.getAgentAvatarsByIds(assigneeIds) : [];
const agentMap = new Map(agents.map((a) => [a.id, a]));
const data: TaskListItem[] = result.tasks.map((task) => {
const participants: TaskParticipant[] = [];
if (task.assigneeAgentId) {
const agent = agentMap.get(task.assigneeAgentId);
if (agent) {
participants.push({
avatar: agent.avatar,
id: agent.id,
name: agent.title ?? '',
type: 'agent',
});
}
}
return { ...task, participants };
});
return { data, success: true, total: result.total };
} catch (error) {
console.error('[task:list]', error);
throw new TRPCError({
@@ -1,3 +1,4 @@
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { RemoteDeviceManifest } from '@lobechat/builtin-tool-remote-device';
import type * as ModelBankModule from 'model-bank';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -11,6 +12,7 @@ const {
mockGetAgentConfig,
mockGetEnabledPluginManifests,
mockMessageCreate,
mockPluginQuery,
mockQueryDeviceList,
} = vi.hoisted(() => ({
mockCreateOperation: vi.fn(),
@@ -19,6 +21,7 @@ const {
mockGetAgentConfig: vi.fn(),
mockGetEnabledPluginManifests: vi.fn(),
mockMessageCreate: vi.fn(),
mockPluginQuery: vi.fn(),
mockQueryDeviceList: vi.fn(),
}));
@@ -51,7 +54,7 @@ vi.mock('@/server/services/agent', () => ({
vi.mock('@/database/models/plugin', () => ({
PluginModel: vi.fn().mockImplementation(() => ({
query: vi.fn().mockResolvedValue([]),
query: mockPluginQuery,
})),
}));
@@ -160,6 +163,7 @@ describe('AiAgentService.execAgent - device tool pipeline (LOBE-5636)', () => {
success: true,
});
mockQueryDeviceList.mockResolvedValue([]);
mockPluginQuery.mockResolvedValue([]);
mockGenerateToolsDetailed.mockReturnValue({ enabledToolIds: [], tools: [] });
mockGetEnabledPluginManifests.mockReturnValue(new Map());
service = new AiAgentService(mockDb, userId);
@@ -224,6 +228,44 @@ describe('AiAgentService.execAgent - device tool pipeline (LOBE-5636)', () => {
});
});
describe('clientRuntime forwarded to createServerAgentToolsEngine', () => {
it('forwards clientRuntime="desktop" so the engine enables local-system for Electron callers', async () => {
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
await service.execAgent({
agentId: 'agent-1',
clientRuntime: 'desktop',
prompt: 'Hello',
});
expect(mockCreateServerAgentToolsEngine).toHaveBeenCalledTimes(1);
const params = mockCreateServerAgentToolsEngine.mock.calls[0][1];
expect(params.clientRuntime).toBe('desktop');
});
it('forwards clientRuntime="web" verbatim', async () => {
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
await service.execAgent({
agentId: 'agent-1',
clientRuntime: 'web',
prompt: 'Hello',
});
const params = mockCreateServerAgentToolsEngine.mock.calls[0][1];
expect(params.clientRuntime).toBe('web');
});
it('omits clientRuntime when the caller does not specify one', async () => {
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
const params = mockCreateServerAgentToolsEngine.mock.calls[0][1];
expect(params.clientRuntime).toBeUndefined();
});
});
describe('RemoteDevice systemRole override', () => {
it('should override RemoteDevice systemRole with dynamic prompt when enabled by ToolsEngine', async () => {
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
@@ -273,8 +315,164 @@ describe('AiAgentService.execAgent - device tool pipeline (LOBE-5636)', () => {
const callArgs = mockCreateOperation.mock.calls[0][0];
const manifestMap = callArgs.toolSet.manifestMap;
// RemoteDevice should NOT be in manifestMap — no manual injection
expect(manifestMap[RemoteDeviceManifest.identifier]).toBeUndefined();
// RemoteDevice is present in manifestMap (discoverable builtin),
// but should NOT be in enabledToolIds when gateway is not configured
const enabledToolIds = callArgs.toolSet.enabledToolIds;
expect(enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
});
describe('toolExecutorMap gating on gatewayConfigured (regression for #13769)', () => {
it('should mark local-system as client when gateway is NOT configured (standalone Electron)', async () => {
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(false);
mockGetEnabledPluginManifests.mockReturnValue(
new Map([[LocalSystemManifest.identifier, LocalSystemManifest]]),
);
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
const executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
expect(executorMap[LocalSystemManifest.identifier]).toBe('client');
});
it('should NOT mark local-system as client when gateway IS configured (cloud)', async () => {
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true);
mockQueryDeviceList.mockResolvedValue([
{ deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' },
]);
mockGetEnabledPluginManifests.mockReturnValue(
new Map([[LocalSystemManifest.identifier, LocalSystemManifest]]),
);
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
const executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
expect(executorMap[LocalSystemManifest.identifier]).toBeUndefined();
});
it('should mark stdio MCP plugin as client only when gateway is NOT configured', async () => {
const stdioPlugin = {
customParams: { mcp: { type: 'stdio' } },
identifier: 'my-stdio-mcp',
} as any;
const stdioManifest = {
api: [{ description: 't', name: 'a', parameters: {} }],
identifier: 'my-stdio-mcp',
meta: { title: 'Stdio' },
};
mockPluginQuery.mockResolvedValue([stdioPlugin]);
mockGetEnabledPluginManifests.mockReturnValue(new Map([['my-stdio-mcp', stdioManifest]]));
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig({ plugins: ['my-stdio-mcp'] }));
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
// Gateway NOT configured → should mark as client
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(false);
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
let executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
expect(executorMap['my-stdio-mcp']).toBe('client');
// Gateway configured → should NOT mark as client
mockCreateOperation.mockClear();
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true);
mockQueryDeviceList.mockResolvedValue([
{ deviceId: 'dev-1', deviceName: 'PC', platform: 'win32' },
]);
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
expect(executorMap['my-stdio-mcp']).toBeUndefined();
});
});
describe('clientRuntime="desktop" bypasses the DEVICE_GATEWAY gate (Phase 6.4)', () => {
it('marks local-system as client when caller is desktop, even with DEVICE_GATEWAY configured', async () => {
// On cloud canary, DEVICE_GATEWAY is configured AND a remote Linux VM
// may be registered. Before this fix, `!gatewayConfigured` was false, so
// local-system was never stamped `executor='client'` — and dispatch fell
// through to the Remote Device proxy (which then tried to read the file
// on the wrong host). When clientRuntime='desktop', the caller itself is
// the execution target and wins.
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true);
mockQueryDeviceList.mockResolvedValue([
{ deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' },
]);
mockGetEnabledPluginManifests.mockReturnValue(
new Map([[LocalSystemManifest.identifier, LocalSystemManifest]]),
);
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
await service.execAgent({
agentId: 'agent-1',
clientRuntime: 'desktop',
prompt: 'Hello',
});
const executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
expect(executorMap[LocalSystemManifest.identifier]).toBe('client');
});
it('marks stdio MCP as client when caller is desktop, even with DEVICE_GATEWAY configured', async () => {
const stdioPlugin = {
customParams: { mcp: { type: 'stdio' } },
identifier: 'my-stdio-mcp',
} as any;
const stdioManifest = {
api: [{ description: 't', name: 'a', parameters: {} }],
identifier: 'my-stdio-mcp',
meta: { title: 'Stdio' },
};
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true);
mockQueryDeviceList.mockResolvedValue([
{ deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' },
]);
mockPluginQuery.mockResolvedValue([stdioPlugin]);
mockGetEnabledPluginManifests.mockReturnValue(new Map([['my-stdio-mcp', stdioManifest]]));
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig({ plugins: ['my-stdio-mcp'] }));
await service.execAgent({
agentId: 'agent-1',
clientRuntime: 'desktop',
prompt: 'Hello',
});
const executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
expect(executorMap['my-stdio-mcp']).toBe('client');
});
it('keeps legacy routing for web callers with DEVICE_GATEWAY configured', async () => {
// Web client + DEVICE_GATEWAY configured → tools still route through
// Remote Device proxy; executor stays unset (legacy behaviour).
const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy');
vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true);
mockQueryDeviceList.mockResolvedValue([
{ deviceId: 'dev-1', deviceName: 'Remote VM', platform: 'linux' },
]);
mockGetEnabledPluginManifests.mockReturnValue(
new Map([[LocalSystemManifest.identifier, LocalSystemManifest]]),
);
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
await service.execAgent({
agentId: 'agent-1',
clientRuntime: 'web',
prompt: 'Hello',
});
const executorMap = mockCreateOperation.mock.calls[0][0].toolSet.executorMap;
expect(executorMap[LocalSystemManifest.identifier]).toBeUndefined();
});
});
@@ -295,8 +493,8 @@ describe('AiAgentService.execAgent - device tool pipeline (LOBE-5636)', () => {
const manifestMap = callArgs.toolSet.manifestMap;
expect(manifestMap['test-tool']).toBe(mockManifest);
// No extra manifests added manually
expect(Object.keys(manifestMap)).toEqual(['test-tool']);
// manifestMap also includes discoverable builtin tools for activator discovery
expect(Object.keys(manifestMap)).toContain('test-tool');
});
});
});
+50 -7
View File
@@ -223,6 +223,7 @@ export class AiAgentService {
appContext,
autoStart = true,
botContext,
clientRuntime,
deviceId: requestedDeviceId,
botPlatformContext,
discordContext,
@@ -565,6 +566,7 @@ export class AiAgentService {
chatConfig: agentConfig.chatConfig ?? undefined,
plugins: agentPlugins,
},
clientRuntime,
deviceContext: gatewayConfigured
? {
autoActivated: activeDeviceId ? true : undefined,
@@ -588,6 +590,9 @@ export class AiAgentService {
LocalSystemManifest.identifier,
RemoteDeviceManifest.identifier,
...(isBotConversation ? [MessageToolIdentifier] : []),
// Include LobeHub Skills and Klavis tools so they are passed to generateToolsDetailed
...lobehubSkillManifests.map((m) => m.identifier),
...klavisManifests.map((m) => m.identifier),
];
log('execAgent: agent configured plugins: %O', pluginIds);
@@ -604,11 +609,34 @@ export class AiAgentService {
log('execAgent: enabled tool ids: %O', toolsResult.enabledToolIds);
// Start with the scoped manifest map (pluginIds + defaultToolIds)
const manifestMap = toolsEngine.getEnabledPluginManifests(pluginIds);
manifestMap.forEach((manifest, id) => {
toolManifestMap[id] = manifest;
});
// Also include discoverable builtin tools that are not yet in the map,
// so the activator can find their manifests when dynamically enabling them
// (e.g., lobe-creds, lobe-cron). Exclude discoverable:false tools to prevent
// internal infrastructure tools from being surfaced to the activator.
for (const tool of builtinTools) {
if (tool.discoverable !== false && !toolManifestMap[tool.identifier]) {
toolManifestMap[tool.identifier] = tool.manifest as LobeToolManifest;
}
}
// Include lobehub skill and klavis manifests for activator discovery
for (const manifest of lobehubSkillManifests) {
if (!toolManifestMap[manifest.identifier]) {
toolManifestMap[manifest.identifier] = manifest;
}
}
for (const manifest of klavisManifests) {
if (!toolManifestMap[manifest.identifier]) {
toolManifestMap[manifest.identifier] = manifest;
}
}
for (const manifest of lobehubSkillManifests) {
toolSourceMap[manifest.identifier] = 'lobehubSkill';
}
@@ -620,13 +648,28 @@ export class AiAgentService {
// require local IPC / subprocess capabilities:
// - local-system builtin: Electron IPC for file + command execution
// - stdio MCP plugins: subprocess lives on the user's machine
// Dispatcher in RuntimeExecutors reads this to route via Agent Gateway WS.
if (manifestMap.has(LocalSystemManifest.identifier)) {
toolExecutorMap[LocalSystemManifest.identifier] = 'client';
}
for (const plugin of installedPlugins) {
if (plugin.customParams?.mcp?.type === 'stdio' && manifestMap.has(plugin.identifier)) {
toolExecutorMap[plugin.identifier] = 'client';
//
// Two triggers, in priority order:
// (a) `clientRuntime === 'desktop'` — the caller itself is an Electron
// client on the Agent Gateway WS and is ready to receive
// `tool_execute`. This is the Phase 6.4 path and is authoritative
// regardless of whether DEVICE_GATEWAY (the legacy device-proxy) is
// also configured.
// (b) `!gatewayConfigured` — no DEVICE_GATEWAY configured on the server,
// so legacy Remote Device proxy isn't an option and any client
// tooling falls through to the Gateway WS (standalone Electron).
//
// When DEVICE_GATEWAY is configured AND the caller is a web client, we
// leave executor unset so tools route via RemoteDevice proxy.
const shouldDispatchToClient = clientRuntime === 'desktop' || !gatewayConfigured;
if (shouldDispatchToClient) {
if (manifestMap.has(LocalSystemManifest.identifier)) {
toolExecutorMap[LocalSystemManifest.identifier] = 'client';
}
for (const plugin of installedPlugins) {
if (plugin.customParams?.mcp?.type === 'stdio' && manifestMap.has(plugin.identifier)) {
toolExecutorMap[plugin.identifier] = 'client';
}
}
}
@@ -546,7 +546,7 @@ export class AgentBridgeService {
client && botContext?.platformThreadId
? !!client.getMessenger(botContext.platformThreadId).triggerTyping
: true;
const useGatewayTyping = gwClient.isConfigured && platformSupportsTyping;
const useGatewayTyping = gwClient.isEnabled && platformSupportsTyping;
let progressMessage: SentMessage | undefined;
if (useGatewayTyping) {
@@ -316,7 +316,7 @@ export class BotCallbackService {
*/
private renewGatewayTyping(connectionId: string, platformThreadId: string): void {
const client = getMessageGatewayClient();
if (!client.isConfigured) return;
if (!client.isEnabled) return;
client.startTyping(connectionId, platformThreadId).catch((err) => {
log('renewGatewayTyping failed: %O', err);
@@ -325,7 +325,7 @@ export class BotCallbackService {
private stopGatewayTyping(connectionId: string, platformThreadId: string): void {
const client = getMessageGatewayClient();
if (!client.isConfigured) return;
if (!client.isEnabled) return;
client.stopTyping(connectionId, platformThreadId).catch((err) => {
log('stopGatewayTyping failed: %O', err);
+12 -6
View File
@@ -408,7 +408,9 @@ export class BotMessageRouter {
const charLimit = (info.settings?.charLimit as number) || undefined;
const displayToolCalls = info.settings?.displayToolCalls !== false;
/** Try dispatching a text command. Returns true if handled. */
/** Try dispatching a text command. Returns true if handled.
* Strips platform mention artifacts (e.g. Slack's `<@U123>`) before
* checking so that "@bot /new" correctly resolves to the /new command. */
const tryDispatch = async (
thread: {
id: string;
@@ -417,7 +419,8 @@ export class BotMessageRouter {
},
text: string | undefined,
): Promise<boolean> => {
const result = BotMessageRouter.dispatchTextCommand(text, commands);
const sanitized = client.sanitizeUserInput?.(text ?? '') ?? text;
const result = BotMessageRouter.dispatchTextCommand(sanitized, commands);
if (!result) return false;
await result.command.handler({
args: result.args,
@@ -559,7 +562,7 @@ export class BotMessageRouter {
});
// Register slash command handlers (native + text-based)
this.registerCommands(bot, commands);
this.registerCommands(bot, commands, client);
// Register onNewMessage handler based on platform config
const dmEnabled = info.settings?.dm?.enabled ?? false;
@@ -712,7 +715,7 @@ export class BotMessageRouter {
* To add a new command, add an entry to `buildCommands()` it will be
* automatically registered on all platforms.
*/
private registerCommands(bot: Chat<any>, commands: BotCommand[]): void {
private registerCommands(bot: Chat<any>, commands: BotCommand[], client: PlatformClient): void {
// --- Native slash commands (Slack, Discord) ---
for (const cmd of commands) {
bot.onSlashCommand(`/${cmd.name}`, async (event) => {
@@ -729,11 +732,14 @@ export class BotMessageRouter {
// Platforms that don't support native onSlashCommand send /commands as
// regular text messages. This handler intercepts them in unsubscribed
// threads (e.g. first command in a group chat or DM).
// The regex also matches mention-prefixed messages (e.g. "<@U123> /new")
// so that platforms like Slack can dispatch commands from @-mentions.
const namePattern = commands.map((c) => c.name).join('|');
const regex = new RegExp(`^\\/(?:${namePattern})(?:\\s|$|@)`);
const regex = new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?:\\s|$|@)`);
bot.onNewMessage(regex, async (thread, message) => {
if (message.author.isBot === true) return;
const result = BotMessageRouter.dispatchTextCommand(message.text, commands);
const sanitized = client.sanitizeUserInput?.(message.text ?? '') ?? message.text;
const result = BotMessageRouter.dispatchTextCommand(sanitized, commands);
if (!result) return;
await result.command.handler({
args: result.args,
@@ -32,7 +32,7 @@ vi.mock('@/server/services/aiAgent', () => ({
}));
vi.mock('@/server/services/gateway/MessageGatewayClient', () => ({
getMessageGatewayClient: vi.fn().mockReturnValue({ isConfigured: false }),
getMessageGatewayClient: vi.fn().mockReturnValue({ isConfigured: false, isEnabled: false }),
}));
vi.mock('@/server/services/queue/impls', () => ({
@@ -77,6 +77,7 @@ vi.mock('../AgentBridgeService', () => ({
vi.mock('@/server/services/gateway/MessageGatewayClient', () => ({
getMessageGatewayClient: vi.fn().mockReturnValue({
isConfigured: false,
isEnabled: false,
startTyping: vi.fn().mockResolvedValue(undefined),
stopTyping: vi.fn().mockResolvedValue(undefined),
}),
@@ -215,10 +215,6 @@ class SlackWebhookClient implements PlatformClient {
parseMessageId(compositeId: string): string {
return compositeId;
}
sanitizeUserInput(text: string): string {
return text.replaceAll(/<@[A-Z\d]+>\s*/g, '').trim();
}
}
// ---------- Socket Mode Client (persistent) ----------
@@ -408,10 +404,6 @@ class SlackSocketModeClient implements PlatformClient {
parseMessageId(compositeId: string): string {
return compositeId;
}
sanitizeUserInput(text: string): string {
return text.replaceAll(/<@[A-Z\d]+>\s*/g, '').trim();
}
}
// ---------- Factory ----------
@@ -428,7 +420,10 @@ export class SlackClientFactory extends ClientFactory {
return new SlackWebhookClient(config, context);
}
async validateCredentials(credentials: Record<string, string>): Promise<ValidationResult> {
async validateCredentials(
credentials: Record<string, string>,
settings?: Record<string, unknown>,
): Promise<ValidationResult> {
if (!credentials.botToken) {
return { errors: [{ field: 'botToken', message: 'Bot Token is required' }], valid: false };
}
@@ -438,6 +433,17 @@ export class SlackClientFactory extends ClientFactory {
valid: false,
};
}
if (settings?.connectionMode === 'websocket' && !credentials.appToken) {
return {
errors: [
{
field: 'appToken',
message: 'App-Level Token is required for WebSocket (Socket Mode)',
},
],
valid: false,
};
}
try {
const res = await fetch(`${SLACK_API_BASE}/auth.test`, {
@@ -67,6 +67,16 @@ export class MessageGatewayClient {
return !!(this.baseUrl && this.serviceToken);
}
/**
* Whether the gateway should be used for active flows (typing, connect, etc.).
* Requires MESSAGE_GATEWAY_ENABLED=1 in addition to URL/token. This lets us
* disable the gateway during migration while keeping the client reachable
* for cleanup (via isConfigured).
*/
get isEnabled(): boolean {
return gatewayEnv.MESSAGE_GATEWAY_ENABLED === '1' && this.isConfigured;
}
// ─── Connection Management ───
async connect(config: MessageGatewayConnectionConfig): Promise<{ status: string }> {
@@ -83,6 +93,19 @@ export class MessageGatewayClient {
return res.json();
}
async disconnectAll(): Promise<{ total: number }> {
log('Disconnecting all connections');
const res = await this.fetch('/api/connections', { method: 'DELETE' });
if (!res.ok) {
const error = await res.text();
throw new Error(`message-gateway disconnect-all failed (${res.status}): ${error}`);
}
return res.json();
}
async disconnect(connectionId: string): Promise<{ status: string }> {
log('Disconnecting %s', connectionId);
@@ -0,0 +1,284 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
// ─── Import after mocks ───
import { GatewayService } from '../index';
// ─── Hoisted mocks ───
const mockGatewayClient = vi.hoisted(() => ({
connect: vi.fn(),
disconnect: vi.fn(),
disconnectAll: vi.fn(),
getStats: vi.fn(),
getStatus: vi.fn(),
isConfigured: false,
isEnabled: false,
}));
const mockGatewayEnv = vi.hoisted(() => ({
MESSAGE_GATEWAY_ENABLED: undefined as string | undefined,
}));
const mockGatewayManager = vi.hoisted(() => ({
isRunning: false,
start: vi.fn(),
startClient: vi.fn(),
stop: vi.fn(),
stopClient: vi.fn(),
}));
const mockFindEnabledByPlatform = vi.hoisted(() => vi.fn());
const mockGetServerDB = vi.hoisted(() => vi.fn());
const mockInitWithEnvKey = vi.hoisted(() => vi.fn());
const mockUpdateBotRuntimeStatus = vi.hoisted(() => vi.fn());
const mockGetEffectiveConnectionMode = vi.hoisted(() => vi.fn());
// ─── Module mocks ───
vi.mock('@/envs/gateway', () => ({
gatewayEnv: mockGatewayEnv,
}));
vi.mock('../MessageGatewayClient', () => ({
getMessageGatewayClient: () => mockGatewayClient,
}));
vi.mock('../GatewayManager', () => ({
createGatewayManager: () => mockGatewayManager,
getGatewayManager: () => (mockGatewayManager.isRunning ? mockGatewayManager : null),
}));
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: mockGetServerDB,
}));
vi.mock('@/database/models/agentBotProvider', () => ({
AgentBotProviderModel: {
findEnabledByPlatform: mockFindEnabledByPlatform,
},
}));
vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
KeyVaultsGateKeeper: { initWithEnvKey: mockInitWithEnvKey },
}));
vi.mock('../runtimeStatus', () => ({
BOT_RUNTIME_STATUSES: {
connected: 'connected',
disconnected: 'disconnected',
failed: 'failed',
queued: 'queued',
starting: 'starting',
},
updateBotRuntimeStatus: mockUpdateBotRuntimeStatus,
}));
vi.mock('../../bot/platforms', () => ({
getEffectiveConnectionMode: mockGetEffectiveConnectionMode,
platformRegistry: {
getPlatform: () => ({ id: 'discord' }),
listPlatforms: () => [{ id: 'discord' }, { id: 'telegram' }],
},
}));
describe('GatewayService', () => {
let service: GatewayService;
beforeEach(() => {
vi.clearAllMocks();
mockGatewayClient.isConfigured = false;
mockGatewayClient.isEnabled = false;
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = undefined;
mockGatewayManager.isRunning = false;
mockGetServerDB.mockResolvedValue({});
mockInitWithEnvKey.mockResolvedValue({});
mockFindEnabledByPlatform.mockResolvedValue([]);
mockUpdateBotRuntimeStatus.mockResolvedValue({});
service = new GatewayService();
});
// ─── useMessageGateway ───
describe('useMessageGateway', () => {
it('returns false when client is not enabled', () => {
mockGatewayClient.isEnabled = false;
expect(service.useMessageGateway).toBe(false);
});
it('returns true when client is enabled', () => {
mockGatewayClient.isEnabled = true;
expect(service.useMessageGateway).toBe(true);
});
});
// ─── ensureRunning ───
describe('ensureRunning', () => {
describe('in-process mode (gateway disabled)', () => {
it('starts local GatewayManager', async () => {
await service.ensureRunning();
expect(mockGatewayManager.start).toHaveBeenCalled();
});
it('skips start if GatewayManager already running', async () => {
mockGatewayManager.isRunning = true;
await service.ensureRunning();
expect(mockGatewayManager.start).not.toHaveBeenCalled();
});
});
describe('gateway mode (ENABLED=1)', () => {
beforeEach(() => {
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = '1';
mockGatewayClient.isConfigured = true;
mockGatewayClient.isEnabled = true;
});
it('calls syncGatewayConnections instead of starting local manager', async () => {
await service.ensureRunning();
expect(mockGatewayManager.start).not.toHaveBeenCalled();
});
});
});
// ─── syncGatewayConnections ───
describe('syncGatewayConnections (via ensureRunning)', () => {
beforeEach(() => {
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = '1';
mockGatewayClient.isConfigured = true;
mockGatewayClient.isEnabled = true;
});
it('skips webhook-mode providers', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
]);
mockGetEffectiveConnectionMode.mockReturnValue('webhook');
await service.ensureRunning();
expect(mockGatewayClient.connect).not.toHaveBeenCalled();
});
it('skips already connected providers', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
]);
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
mockGatewayClient.getStatus.mockResolvedValue({
state: { status: 'connected' },
});
await service.ensureRunning();
expect(mockGatewayClient.connect).not.toHaveBeenCalled();
});
it('skips connecting providers', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
]);
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
mockGatewayClient.getStatus.mockResolvedValue({
state: { status: 'connecting' },
});
await service.ensureRunning();
expect(mockGatewayClient.connect).not.toHaveBeenCalled();
});
it('skips providers in error state', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
]);
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
mockGatewayClient.getStatus.mockResolvedValue({
state: { error: 'Session expired (errcode -14)', status: 'error' },
});
await service.ensureRunning();
expect(mockGatewayClient.connect).not.toHaveBeenCalled();
});
it('connects disconnected providers', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{
applicationId: 'app-1',
credentials: { token: 'x' },
id: 'prov-1',
settings: {},
userId: 'u1',
},
]);
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
mockGatewayClient.getStatus.mockResolvedValue({
state: { status: 'disconnected' },
});
mockGatewayClient.connect.mockResolvedValue({ status: 'connecting' });
await service.ensureRunning();
expect(mockGatewayClient.connect).toHaveBeenCalledWith(
expect.objectContaining({
applicationId: 'app-1',
connectionId: 'prov-1',
platform: 'discord',
}),
);
expect(mockUpdateBotRuntimeStatus).toHaveBeenCalledWith(
expect.objectContaining({ status: 'starting' }),
);
});
it('sets connected status for sync connect result', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
]);
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
mockGatewayClient.getStatus.mockRejectedValue(new Error('not found'));
mockGatewayClient.connect.mockResolvedValue({ status: 'connected' });
await service.ensureRunning();
expect(mockUpdateBotRuntimeStatus).toHaveBeenCalledWith(
expect.objectContaining({ status: 'connected' }),
);
});
it('tries to connect when status check fails', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
]);
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
mockGatewayClient.getStatus.mockRejectedValue(new Error('DO not found'));
mockGatewayClient.connect.mockResolvedValue({ status: 'connecting' });
await service.ensureRunning();
expect(mockGatewayClient.connect).toHaveBeenCalled();
});
it('handles connect failure gracefully', async () => {
mockFindEnabledByPlatform.mockResolvedValue([
{ applicationId: 'app-1', credentials: {}, id: 'prov-1', settings: {}, userId: 'u1' },
]);
mockGetEffectiveConnectionMode.mockReturnValue('websocket');
mockGatewayClient.getStatus.mockResolvedValue({
state: { status: 'disconnected' },
});
mockGatewayClient.connect.mockRejectedValue(new Error('timeout'));
// Should not throw
await expect(service.ensureRunning()).resolves.toBeUndefined();
expect(mockUpdateBotRuntimeStatus).not.toHaveBeenCalled();
});
});
});
@@ -2,6 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageGatewayClient } from '../MessageGatewayClient';
const mockGatewayEnv = vi.hoisted(() => ({
MESSAGE_GATEWAY_ENABLED: undefined as string | undefined,
}));
vi.mock('@/envs/gateway', () => ({
gatewayEnv: mockGatewayEnv,
}));
describe('MessageGatewayClient', () => {
let client: MessageGatewayClient;
@@ -25,6 +33,24 @@ describe('MessageGatewayClient', () => {
});
});
describe('isEnabled', () => {
it('returns false when configured but MESSAGE_GATEWAY_ENABLED is not 1', () => {
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = undefined;
expect(client.isEnabled).toBe(false);
});
it('returns false when MESSAGE_GATEWAY_ENABLED=1 but not configured', () => {
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = '1';
const c = new MessageGatewayClient('', '');
expect(c.isEnabled).toBe(false);
});
it('returns true when MESSAGE_GATEWAY_ENABLED=1 and configured', () => {
mockGatewayEnv.MESSAGE_GATEWAY_ENABLED = '1';
expect(client.isEnabled).toBe(true);
});
});
describe('connect', () => {
it('calls POST /api/connections with config', async () => {
const mockFetch = vi.fn().mockResolvedValue({
+74 -22
View File
@@ -17,12 +17,13 @@ const isVercel = !!process.env.VERCEL_ENV;
export class GatewayService {
/**
* Check if the external message-gateway is configured.
* When enabled, all platforms are registered on the gateway for
* connection management and typing persistence.
* Whether to use the external message-gateway for connection management.
* Requires MESSAGE_GATEWAY_ENABLED=1 plus URL/TOKEN to be configured.
* This allows disabling the gateway (for migration) while keeping
* the client reachable for cleanup.
*/
get useMessageGateway(): boolean {
return getMessageGatewayClient().isConfigured;
return getMessageGatewayClient().isEnabled;
}
async ensureRunning(): Promise<void> {
@@ -37,10 +38,24 @@ export class GatewayService {
return;
}
// Start local connections first, then clean up gateway —
// brief overlap is better than a gap where messages are lost.
const manager = createGatewayManager({ definitions: platformRegistry.listPlatforms() });
await manager.start();
log('GatewayManager started');
// Clean up leftover gateway connections to prevent duplicates.
const client = getMessageGatewayClient();
if (client.isConfigured) {
try {
const result = await client.disconnectAll();
if (result.total > 0) {
log('Cleaned up %d gateway connections', result.total);
}
} catch (err) {
log('Gateway cleanup skipped (non-critical): %O', err);
}
}
}
/**
@@ -56,6 +71,10 @@ export class GatewayService {
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
let totalSynced = 0;
let totalSkipped = 0;
let totalFailed = 0;
// Sync all registered platforms
for (const definition of platformRegistry.listPlatforms()) {
const platform = definition.id;
@@ -66,7 +85,10 @@ export class GatewayService {
gateKeeper,
);
log('Gateway sync: found %d enabled providers for %s', providers.length, platform);
let synced = 0;
let skippedWebhook = 0;
let skippedConnected = 0;
let failed = 0;
for (const provider of providers) {
try {
@@ -74,26 +96,26 @@ export class GatewayService {
const connectionMode = getEffectiveConnectionMode(definition, provider.settings);
// Webhook-mode platforms don't need persistent gateway connections.
// Run the platform client locally via GatewayManager instead
// (e.g. Telegram setWebhook, QQ credential verification).
// The webhook URL is set once when the user saves the bot config
// (via startClientViaGateway). No action needed during periodic sync.
if (connectionMode === 'webhook') {
const manager = createGatewayManager({
definitions: platformRegistry.listPlatforms(),
});
await manager.startClient(platform, provider.applicationId, provider.userId);
log(
'Gateway sync: started webhook-mode %s:%s locally',
platform,
provider.applicationId,
);
skippedWebhook++;
continue;
}
// For persistent connections, check gateway status before reconnecting
try {
const status = await client.getStatus(provider.id);
if (status.state.status === 'connected') {
log('Gateway sync: %s already connected, skipping', provider.id);
if (status.state.status === 'connected' || status.state.status === 'connecting') {
skippedConnected++;
log('Gateway sync: %s already %s, skipping', provider.id, status.state.status);
continue;
}
// "error" means credential/config issue (e.g. session expired, unauthorized).
// Auto-retry is pointless — only user action (saving new credentials) can fix it.
if (status.state.status === 'error') {
skippedConnected++;
log('Gateway sync: %s in error (%s), skipping', provider.id, status.state.error);
continue;
}
} catch {
@@ -101,7 +123,7 @@ export class GatewayService {
}
const webhookPath = `/api/agent/webhooks/${platform}/${provider.applicationId}`;
await client.connect({
const result = await client.connect({
applicationId: provider.applicationId,
connectionId: provider.id,
connectionMode,
@@ -111,21 +133,51 @@ export class GatewayService {
webhookPath,
});
// Gateway returns "connecting" for async persistent connections
// (e.g. Discord WebSocket), "connected" for sync webhook-mode.
const runtimeStatus =
result.status === 'connected'
? BOT_RUNTIME_STATUSES.connected
: BOT_RUNTIME_STATUSES.starting;
await updateBotRuntimeStatus({
applicationId: provider.applicationId,
platform,
status: BOT_RUNTIME_STATUSES.connected,
status: runtimeStatus,
});
log('Gateway sync: connected %s:%s', platform, provider.applicationId);
synced++;
log('Gateway sync: %s %s:%s', result.status, platform, provider.applicationId);
} catch (err) {
failed++;
log('Gateway sync: failed to connect %s:%s: %O', platform, provider.applicationId, err);
}
}
log(
'Gateway sync: %s — total=%d synced=%d skippedWebhook=%d skippedConnected=%d failed=%d',
platform,
providers.length,
synced,
skippedWebhook,
skippedConnected,
failed,
);
totalSynced += synced;
totalSkipped += skippedWebhook + skippedConnected;
totalFailed += failed;
} catch (err) {
log('Gateway sync: error syncing platform %s: %O', platform, err);
}
}
log(
'Gateway sync complete: synced=%d skipped=%d failed=%d',
totalSynced,
totalSkipped,
totalFailed,
);
}
async stop(): Promise<void> {
@@ -0,0 +1,110 @@
import type { ChatToolPayload } from '@lobechat/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BuiltinToolsExecutor } from '../builtin';
import type { ToolExecutionContext } from '../types';
const mockApiHandler = vi.fn();
vi.mock('../serverRuntimes', () => ({
hasServerRuntime: vi.fn().mockReturnValue(true),
getServerRuntime: vi.fn(async () => ({ createDocument: mockApiHandler })),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({})),
}));
vi.mock('@/server/services/market', () => ({
MarketService: vi.fn().mockImplementation(() => ({})),
}));
const buildPayload = (argsStr: string): ChatToolPayload => ({
apiName: 'createDocument',
arguments: argsStr,
id: 't1',
identifier: 'lobe-notebook',
type: 'default' as any,
});
const context: ToolExecutionContext = {
toolManifestMap: {},
userId: 'user-1',
};
describe('BuiltinToolsExecutor truncated arguments', () => {
const executor = new BuiltinToolsExecutor({} as any, 'user-1');
beforeEach(() => {
mockApiHandler.mockReset();
});
it('short-circuits with TRUNCATED_ARGUMENTS when JSON is cut mid-object', async () => {
const truncated = '{"title": "Report", "description": "foo", "type": "report"';
const result = await executor.execute(buildPayload(truncated), context);
expect(result.success).toBe(false);
expect(result.error?.code).toBe('TRUNCATED_ARGUMENTS');
expect(result.content).toMatch(/truncated/i);
expect(result.content).toMatch(/max_tokens/);
// The raw truncated payload is echoed back so the model sees exactly what
// it produced and cannot blame upstream for a different payload.
expect(result.content).toContain(truncated);
expect(mockApiHandler).not.toHaveBeenCalled();
});
it('short-circuits with TRUNCATED_ARGUMENTS when a string value is unterminated', async () => {
const truncated = '{"title": "Report", "content": "this is cut';
const result = await executor.execute(buildPayload(truncated), context);
expect(result.success).toBe(false);
expect(result.error?.code).toBe('TRUNCATED_ARGUMENTS');
expect(result.content).toMatch(/unterminated string/);
expect(result.content).toContain(truncated);
expect(mockApiHandler).not.toHaveBeenCalled();
});
it('still dispatches to the runtime for valid JSON missing required fields', async () => {
mockApiHandler.mockResolvedValueOnce({
content: 'Error: Missing content. The document content is required.',
success: false,
});
const result = await executor.execute(
buildPayload('{"title": "Report", "type": "report"}'),
context,
);
expect(mockApiHandler).toHaveBeenCalledWith({ title: 'Report', type: 'report' }, context);
// The schema-level error from the runtime passes through untouched.
expect(result.success).toBe(false);
expect(result.content).toMatch(/Missing content/);
});
it('returns INVALID_JSON_ARGUMENTS for balanced-but-invalid JSON (not truncated)', async () => {
// Balanced brackets but invalid syntax (unquoted key). Not a truncation,
// but still unparseable — reject with a non-truncation error rather than
// silently passing `{}` to the tool.
const invalid = '{title: "Report"}';
const result = await executor.execute(buildPayload(invalid), context);
expect(result.success).toBe(false);
expect(result.error?.code).toBe('INVALID_JSON_ARGUMENTS');
expect(result.content).toMatch(/not valid JSON/);
expect(result.content).toContain(invalid);
expect(mockApiHandler).not.toHaveBeenCalled();
});
it('still dispatches normally when argsStr is empty', async () => {
mockApiHandler.mockResolvedValueOnce({ content: 'ok', success: true });
// Empty arguments are legitimate for tools that take no params —
// parse falls through to `{}` without triggering the invalid-JSON guard.
const result = await executor.execute(buildPayload(''), context);
expect(mockApiHandler).toHaveBeenCalledWith({}, context);
expect(result.success).toBe(true);
});
});
+33 -2
View File
@@ -1,6 +1,6 @@
import { type LobeChatDatabase } from '@lobechat/database';
import { type ChatToolPayload } from '@lobechat/types';
import { safeParseJSON } from '@lobechat/utils';
import { detectTruncatedJSON, safeParseJSON } from '@lobechat/utils';
import debug from 'debug';
import { KlavisService } from '@/server/services/klavis';
@@ -25,7 +25,38 @@ export class BuiltinToolsExecutor implements IToolExecutor {
context: ToolExecutionContext,
): Promise<ToolExecutionResult> {
const { identifier, apiName, arguments: argsStr, source } = payload;
const args = safeParseJSON(argsStr) || {};
const parsed = safeParseJSON(argsStr);
// When JSON.parse fails, return a dedicated error rather than silently
// falling back to `{}`. Passing `{}` to the tool produced generic
// "required field missing" errors, which led the model to retry with the
// same broken payload. Distinguish a truncated payload (typical when
// max_tokens is exhausted mid-tool-call) from plain malformed JSON, and
// echo the raw arguments string so the model can verify it is exactly
// what it produced.
if (parsed === undefined && argsStr) {
const truncationReason = detectTruncatedJSON(argsStr);
const explanation = truncationReason
? `The tool call arguments JSON appears to be truncated (${truncationReason}), ` +
`likely because the model's max_tokens budget was exhausted ` +
`(possibly by extended-thinking tokens). ` +
`Either reduce the size of the content you are about to write, ` +
`or ask the user to increase the model's max_tokens ` +
`(and/or disable extended thinking or set a separate thinking budget). ` +
`Do not retry with the same payload.`
: `The tool call arguments string is not valid JSON and could not be parsed, ` +
`so the tool was not invoked. Fix the JSON syntax and try again.`;
const content = `${explanation}\n\nThe received arguments string was:\n${argsStr}`;
const code = truncationReason ? 'TRUNCATED_ARGUMENTS' : 'INVALID_JSON_ARGUMENTS';
log('Rejected invalid arguments for %s:%s (%s): %s', identifier, apiName, code, argsStr);
return {
content,
error: { code, message: explanation },
success: false,
};
}
const args = parsed || {};
log(
'Executing builtin tool: %s:%s (source: %s) with args: %O',
+6
View File
@@ -14,6 +14,12 @@ export interface ExecAgentTaskParams {
topicId?: string | null;
};
autoStart?: boolean;
/**
* Runtime of the client initiating this request. When 'desktop', server
* enables `executor: 'client'` tools (local-system, stdio MCP) and
* dispatches them over the Agent Gateway WS back to this client.
*/
clientRuntime?: 'desktop' | 'web';
deviceId?: string;
existingMessageIds?: string[];
/** File IDs of already-uploaded attachments to attach to the new user message */
+10 -8
View File
@@ -8,7 +8,7 @@ import type { SWRResponse } from 'swr';
import type { PartialDeep } from 'type-fest';
import { MESSAGE_CANCEL_FLAT } from '@/const/message';
import { mutate, useClientDataSWR, useClientDataSWRWithSync } from '@/libs/swr';
import { mutate, useClientDataSWRWithSync } from '@/libs/swr';
import type { CreateAgentParams, CreateAgentResult } from '@/services/agent';
import { agentService } from '@/services/agent';
import {
@@ -270,19 +270,21 @@ export class AgentSliceActionImpl {
isLogin: boolean | undefined,
agentId: string,
): SWRResponse<LobeAgentConfig> => {
return useClientDataSWR<LobeAgentConfig>(
// Only fetch when login status is explicitly true (not null/undefined)
const swrKey =
isLogin === true && agentId && !isChatGroupSessionId(agentId)
? ([FETCH_AGENT_CONFIG_KEY, agentId] as const)
: null,
async ([, id]: readonly [string, string]) => {
const data = await agentService.getAgentConfigById(id);
: null;
return useClientDataSWRWithSync<LobeAgentConfig>(
swrKey,
async () => {
const data = await agentService.getAgentConfigById(agentId);
return data as LobeAgentConfig;
},
{
onSuccess: (data) => {
onData: (data) => {
if (!data) return;
this.#get().internal_dispatchAgentMap(agentId, data);
this.#set({ activeAgentId: data.id }, false, 'fetchAgentConfig');
},
},
@@ -0,0 +1,305 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ToolExecuteData } from '@/libs/agent-stream';
import { ClientToolExecutionActionImpl } from '../clientToolExecution';
// ─── Hoisted mocks ───
const { hasExecutorMock, invokeExecutorMock, invokeMcpToolCallMock } = vi.hoisted(() => ({
hasExecutorMock: vi.fn(),
invokeExecutorMock: vi.fn(),
invokeMcpToolCallMock: vi.fn(),
}));
vi.mock('@/store/tool/slices/builtin/executors', () => ({
hasExecutor: hasExecutorMock,
invokeExecutor: invokeExecutorMock,
}));
vi.mock('@/services/mcp', () => ({
mcpService: {
invokeMcpToolCall: invokeMcpToolCallMock,
},
}));
// ─── Shared harness ───
function makeData(overrides: Partial<ToolExecuteData> = {}): ToolExecuteData {
return {
apiName: 'readFile',
arguments: '{"path":"/tmp/a.txt"}',
executionTimeoutMs: 60_000,
identifier: 'local-system',
toolCallId: 'call_1',
...overrides,
};
}
function setup(options: { hasConnection?: boolean; sendReturns?: boolean } = {}) {
const { hasConnection = true, sendReturns = true } = options;
const sendToolResult = vi.fn(() => sendReturns);
const state: any = {
gatewayConnections: hasConnection
? {
'op-1': {
client: {
connect: vi.fn(),
disconnect: vi.fn(),
on: vi.fn(),
sendInterrupt: vi.fn(),
sendToolResult,
},
status: 'connected',
},
}
: {},
operations: {
'op-1': {
abortController: { signal: { aborted: false } },
context: { agentId: 'agent-1', topicId: 'topic-1' },
},
},
pendingClientToolExecutions: {},
};
const set = vi.fn((updater: any) => {
const patch = typeof updater === 'function' ? updater(state) : updater;
Object.assign(state, patch);
});
const get = vi.fn(() => state);
const action = new ClientToolExecutionActionImpl(set, get);
return { action, sendToolResult, state, set, get };
}
beforeEach(() => {
hasExecutorMock.mockReset();
invokeExecutorMock.mockReset();
invokeMcpToolCallMock.mockReset();
});
// ─── Tests ───
describe('internal_executeClientTool', () => {
describe('builtin dispatch', () => {
it('sends a successful tool_result when the executor returns content', async () => {
hasExecutorMock.mockReturnValue(true);
invokeExecutorMock.mockResolvedValue({
content: 'files: a.txt',
state: { lastDir: '/tmp' },
success: true,
});
const { action, sendToolResult } = setup();
await action.internal_executeClientTool(makeData(), { operationId: 'op-1' });
expect(invokeExecutorMock).toHaveBeenCalledWith(
'local-system',
'readFile',
{ path: '/tmp/a.txt' },
expect.objectContaining({
agentId: 'agent-1',
messageId: 'call_1',
operationId: 'op-1',
topicId: 'topic-1',
}),
);
expect(sendToolResult).toHaveBeenCalledWith({
content: 'files: a.txt',
state: { lastDir: '/tmp' },
success: true,
toolCallId: 'call_1',
});
});
it('sends a failure tool_result when the executor reports error', async () => {
hasExecutorMock.mockReturnValue(true);
invokeExecutorMock.mockResolvedValue({
error: { message: 'ENOENT', type: 'fs_error' },
success: false,
});
const { action, sendToolResult } = setup();
await action.internal_executeClientTool(makeData(), { operationId: 'op-1' });
expect(sendToolResult).toHaveBeenCalledWith({
content: 'ENOENT',
error: { message: 'ENOENT', type: 'fs_error' },
state: undefined,
success: false,
toolCallId: 'call_1',
});
});
it('passes {} to executor when arguments is empty', async () => {
hasExecutorMock.mockReturnValue(true);
invokeExecutorMock.mockResolvedValue({ content: 'ok', success: true });
const { action } = setup();
await action.internal_executeClientTool(makeData({ arguments: '' }), {
operationId: 'op-1',
});
expect(invokeExecutorMock).toHaveBeenCalledWith(
'local-system',
'readFile',
{},
expect.anything(),
);
});
});
describe('error paths never block the server', () => {
it('sends tool_result with parse error when arguments are malformed', async () => {
hasExecutorMock.mockReturnValue(true);
const { action, sendToolResult } = setup();
await action.internal_executeClientTool(makeData({ arguments: '{not-json' }), {
operationId: 'op-1',
});
expect(invokeExecutorMock).not.toHaveBeenCalled();
expect(sendToolResult).toHaveBeenCalledWith(
expect.objectContaining({
content: null,
error: expect.objectContaining({ type: 'arguments_parse_error' }),
success: false,
toolCallId: 'call_1',
}),
);
});
it('sends tool_result when invokeExecutor throws', async () => {
hasExecutorMock.mockReturnValue(true);
invokeExecutorMock.mockRejectedValue(new Error('ipc died'));
const { action, sendToolResult } = setup();
await action.internal_executeClientTool(makeData(), { operationId: 'op-1' });
expect(sendToolResult).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: 'ipc died',
type: 'client_tool_execution_error',
}),
success: false,
toolCallId: 'call_1',
}),
);
});
it('sends a not-found tool_result when no executor matches and MCP returns nothing', async () => {
hasExecutorMock.mockReturnValue(false);
invokeMcpToolCallMock.mockResolvedValue(undefined);
const { action, sendToolResult } = setup();
await action.internal_executeClientTool(makeData({ identifier: 'unknown-tool' }), {
operationId: 'op-1',
});
expect(sendToolResult).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ type: 'executor_not_found' }),
success: false,
}),
);
});
it('does not throw when gateway connection is missing (server will timeout)', async () => {
hasExecutorMock.mockReturnValue(true);
invokeExecutorMock.mockResolvedValue({ content: 'ok', success: true });
const { action, state } = setup({ hasConnection: false });
await expect(
action.internal_executeClientTool(makeData(), { operationId: 'op-1' }),
).resolves.toBeUndefined();
// Pending state was cleared even when send couldn't happen
expect(state.pendingClientToolExecutions).toEqual({});
});
});
describe('MCP fallback', () => {
it('routes to mcpService when no builtin executor registered', async () => {
hasExecutorMock.mockReturnValue(false);
invokeMcpToolCallMock.mockResolvedValue({
content: 'mcp-ok',
state: { cursor: 1 },
success: true,
});
const { action, sendToolResult } = setup();
await action.internal_executeClientTool(
makeData({ apiName: 'echo', identifier: 'mcp-demo' }),
{ operationId: 'op-1' },
);
expect(invokeMcpToolCallMock).toHaveBeenCalledWith(
expect.objectContaining({
apiName: 'echo',
arguments: '{"path":"/tmp/a.txt"}',
identifier: 'mcp-demo',
}),
expect.objectContaining({ topicId: 'topic-1' }),
);
expect(sendToolResult).toHaveBeenCalledWith(
expect.objectContaining({
content: 'mcp-ok',
state: { cursor: 1 },
success: true,
toolCallId: 'call_1',
}),
);
});
it('sends failure tool_result when MCP returns an error result', async () => {
hasExecutorMock.mockReturnValue(false);
invokeMcpToolCallMock.mockResolvedValue({
content: null,
error: { message: 'mcp boom', type: 'mcp_error' },
success: false,
});
const { action, sendToolResult } = setup();
await action.internal_executeClientTool(makeData({ identifier: 'mcp-demo' }), {
operationId: 'op-1',
});
expect(sendToolResult).toHaveBeenCalledWith(
expect.objectContaining({
content: null,
error: expect.objectContaining({ message: 'mcp boom', type: 'mcp_error' }),
success: false,
}),
);
});
});
describe('pending state', () => {
it('marks the call pending during execution and clears it afterwards', async () => {
hasExecutorMock.mockReturnValue(true);
let resolver: (v: any) => void = () => {};
invokeExecutorMock.mockImplementation(
() =>
new Promise((resolve) => {
resolver = resolve;
}),
);
const { action, state } = setup();
const promise = action.internal_executeClientTool(makeData(), { operationId: 'op-1' });
// Between start and resolve: pending map has the id
await Promise.resolve();
expect(state.pendingClientToolExecutions).toEqual({ call_1: true });
resolver({ content: 'done', success: true });
await promise;
expect(state.pendingClientToolExecutions).toEqual({});
});
});
});
@@ -85,6 +85,49 @@ describe('ConversationControl actions', () => {
expect(result.current.operations[operationId!].status).toBe('running');
});
it('cancels Gateway-mode execServerAgentRuntime ops and invokes their cancel handler', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({
activeAgentId: TEST_IDS.SESSION_ID,
activeTopicId: TEST_IDS.TOPIC_ID,
});
});
let operationId!: string;
act(() => {
const res = result.current.startOperation({
type: 'execServerAgentRuntime',
context: { agentId: TEST_IDS.SESSION_ID, topicId: TEST_IDS.TOPIC_ID },
});
operationId = res.operationId;
});
const cancelHandler = vi.fn();
act(() => {
result.current.onOperationCancel(operationId, cancelHandler);
});
expect(result.current.operations[operationId].status).toBe('running');
act(() => {
result.current.stopGenerateMessage();
});
// Operation gets cancelled and the handler (which would fire the WS interrupt
// in real code) is invoked with the operation context.
expect(result.current.operations[operationId].status).toBe('cancelled');
expect(cancelHandler).toHaveBeenCalledWith(
expect.objectContaining({
operationId,
type: 'execServerAgentRuntime',
}),
);
// isAborting flag is also flipped so the UI loading state clears immediately.
expect(result.current.operations[operationId].metadata.isAborting).toBe(true);
});
});
describe('cancelSendMessageInServer', () => {
@@ -9,6 +9,7 @@ import { GatewayActionImpl } from '../gateway';
vi.mock('@/services/aiAgent', () => ({
aiAgentService: {
execAgentTask: vi.fn(),
interruptTask: vi.fn(),
},
}));
@@ -52,6 +53,7 @@ function createMockClient(): GatewayConnection['client'] & {
set.add(listener);
}),
sendInterrupt: vi.fn(),
sendToolResult: vi.fn(() => true),
};
}
@@ -280,6 +282,7 @@ describe('GatewayActionImpl', () => {
associateMessageWithOperation: vi.fn(),
connectToGateway: vi.fn(),
internal_updateTopicLoading: vi.fn(),
onOperationCancel: vi.fn(),
replaceMessages: vi.fn(),
switchTopic: vi.fn(),
})) as any;
@@ -397,5 +400,67 @@ describe('GatewayActionImpl', () => {
}),
);
});
it('registers a cancel handler that calls aiAgentService.interruptTask with the server operationId', async () => {
const onOperationCancel = vi.fn();
const startOperation = vi.fn(() => ({ operationId: 'gw-op-local' }));
const mockClient = createMockClient();
const state: Record<string, any> = { gatewayConnections: {} };
const set = vi.fn((updater: any) => {
if (typeof updater === 'function') Object.assign(state, updater(state));
else Object.assign(state, updater);
});
const get = vi.fn(() => ({
...state,
associateMessageWithOperation: vi.fn(),
connectToGateway: vi.fn(),
internal_updateTopicLoading: vi.fn(),
onOperationCancel,
replaceMessages: vi.fn(),
startOperation,
switchTopic: vi.fn(),
})) as any;
(globalThis as any).window = {
global_serverConfigStore: {
getState: () => ({ serverConfig: { agentGatewayUrl: 'https://gateway.test.com' } }),
},
};
const action = new GatewayActionImpl(set as any, get, undefined);
action.createClient = vi.fn(() => mockClient);
const interruptTaskSpy = vi
.mocked(aiAgentService.interruptTask)
.mockResolvedValue({ operationId: 'server-op-xyz', success: true });
vi.mocked(aiAgentService.execAgentTask).mockResolvedValue({
agentId: 'agent-1',
assistantMessageId: 'ast-1',
autoStarted: true,
createdAt: new Date().toISOString(),
message: 'ok',
operationId: 'server-op-xyz',
status: 'created',
success: true,
timestamp: new Date().toISOString(),
token: 'test-token',
topicId: 'topic-1',
userMessageId: 'usr-1',
});
await action.executeGatewayAgent({
context: { agentId: 'agent-1', topicId: 'topic-1', threadId: null, scope: 'main' },
message: 'Hello',
});
// Handler was registered against the local operation id...
expect(onOperationCancel).toHaveBeenCalledWith('gw-op-local', expect.any(Function));
// ...and, when invoked, fires tRPC interruptTask with the *server-side* operation id
const [, handler] = onOperationCancel.mock.calls[0];
await handler();
expect(interruptTaskSpy).toHaveBeenCalledWith({ operationId: 'server-op-xyz' });
});
});
});
@@ -15,6 +15,7 @@ function createMockStore() {
associateMessageWithOperation: vi.fn(),
completeOperation: vi.fn(),
internal_dispatchMessage: vi.fn(),
internal_executeClientTool: vi.fn().mockResolvedValue(undefined),
internal_toggleToolCallingStreaming: vi.fn(),
replaceMessages: vi.fn(),
};
@@ -22,12 +23,13 @@ function createMockStore() {
function createHandler(
store: ReturnType<typeof createMockStore>,
overrides?: { assistantMessageId?: string },
overrides?: { assistantMessageId?: string; gatewayOperationId?: string },
) {
const get = vi.fn(() => store) as any;
return createGatewayEventHandler(get, {
assistantMessageId: overrides?.assistantMessageId ?? 'msg-initial',
context: { agentId: 'agent-1', scope: 'session', topicId: 'topic-1' } as any,
gatewayOperationId: overrides?.gatewayOperationId,
operationId: 'op-1',
});
}
@@ -197,6 +199,72 @@ describe('createGatewayEventHandler', () => {
});
});
describe('tool_execute', () => {
const toolExecuteData = {
apiName: 'readFile',
arguments: '{"path":"/tmp/a.txt"}',
executionTimeoutMs: 60_000,
identifier: 'local-system',
toolCallId: 'call_1',
};
it('forwards the payload to internal_executeClientTool with operationId', async () => {
const store = createMockStore();
const handler = createHandler(store);
handler(makeEvent('tool_execute', toolExecuteData));
await flush();
expect(store.internal_executeClientTool).toHaveBeenCalledWith(toolExecuteData, {
operationId: 'op-1',
});
});
it('uses gatewayOperationId (WS key) when distinct from local operationId', async () => {
// Locally the handler tracks `op-1` (used for message dispatch), but
// the Agent Gateway WS is keyed on the server-side id `gw-op-server`.
// The action must receive the latter so it can look up the live
// AgentStreamClient in `gatewayConnections` and reply with tool_result.
const store = createMockStore();
const handler = createHandler(store, { gatewayOperationId: 'gw-op-server' });
handler(makeEvent('tool_execute', toolExecuteData));
await flush();
expect(store.internal_executeClientTool).toHaveBeenCalledWith(toolExecuteData, {
operationId: 'gw-op-server',
});
});
it('is fire-and-forget — does not block the event pipeline', async () => {
const store = createMockStore();
// Simulate a slow tool execution that never resolves
store.internal_executeClientTool.mockImplementation(() => new Promise(() => {}));
const handler = createHandler(store);
handler(makeEvent('tool_execute', toolExecuteData));
// If the handler awaited the action, this subsequent stream_chunk would
// be queued behind the pending promise forever. We assert it still runs.
handler(makeEvent('stream_chunk', { chunkType: 'text', content: 'hi' }));
await flush();
expect(store.internal_dispatchMessage).toHaveBeenCalledWith(
expect.objectContaining({ value: { content: 'hi' } }),
expect.any(Object),
);
});
it('ignores tool_execute events without data', async () => {
const store = createMockStore();
const handler = createHandler(store);
handler(makeEvent('tool_execute'));
await flush();
expect(store.internal_executeClientTool).not.toHaveBeenCalled();
});
});
describe('tool_end', () => {
it('should refresh messages to pull tool results', async () => {
const store = createMockStore();

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