Compare commits

...

190 Commits

Author SHA1 Message Date
Arvin Xu 65cbfc8491 🐛 fix(desktop): swallow transient net errors in main process
Electron's net stack (SimpleURLLoaderWrapper, used internally by
electron-updater's net.request) emits transient connectivity errors
(ERR_NETWORK_CHANGED, ERR_NETWORK_IO_SUSPENDED) on Wi-Fi/VPN switch and
system sleep. With no global guard they bubble up as an uncaughtException
and pop the "A JavaScript error occurred in the main process" dialog.

Add a process-level handler that swallows known transient net-stack
errors and re-throws everything else, so genuine crashes stay visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:57:04 +08:00
Arvin Xu 2487407192 feat(cli): add lh update command to self-upgrade the CLI (#16052)
*  feat(cli): add `lh update` command to self-upgrade the CLI

Add a `lh update` command that checks the npm registry for the latest
published version of `@lobehub/cli`, compares it against the installed
version, and runs the matching global-install command. The package
manager is auto-detected (npm/pnpm/yarn/bun) from the running binary
path, with `--package-manager` to override. Supports `--check` for a
dry run and `--tag` to target a non-latest dist-tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(cli): resolve update command package metadata from the bundled entry

`update.ts` loaded `require('../../package.json')`, a path relative to its
source location. tsdown bundles everything into `dist/index.js`, where that
runtime require resolves to `node_modules/@lobehub/package.json` instead of
`@lobehub/cli/package.json` — so a published build threw at startup the moment
the update module was registered, breaking every `lh` invocation.

Reuse the single package-metadata load already done by the bundled entry
(`program.ts`, at the depth where `../package.json` is correct both in source
and in dist). Export the package name alongside the version and consume both
in the update command. Verified against a real `tsdown` build: `lh --version`
starts cleanly and `lh update --check` resolves `@lobehub/cli` correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(cli): extract package metadata into a shared src/pkg.ts const

Importing `cliVersion`/`cliPackageName` from `program.ts` into `update.ts`
created a `program ↔ update` import cycle (program registers the update
command). Move the single `require('../package.json')` load into a dedicated
`src/pkg.ts` const module that both `program.ts` and `update.ts` import,
breaking the cycle. The module sits directly under `src/` — the same depth as
the bundled `dist/index.js` — so `../package.json` resolves to `@lobehub/cli`'s
own package.json in both source and bundled runs.

Verified against a real tsdown build: one `createRequire("../package.json")`
remains, `lh --version` starts cleanly, `lh update --check` resolves the
package, and `man:generate` still reads the version via the re-export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(cli): use the semver package for update version comparison

The hand-rolled comparator ordered prerelease identifiers lexicographically,
so `1.0.0-beta.10` sorted below `1.0.0-beta.9` and `lh update --tag beta`
reported "already current" instead of offering the newer prerelease.

Replace `compareSemver` with `isNewerVersion`, delegating to the `semver`
package (already a workspace dependency) so numeric prerelease parts compare
numerically. Add `semver` / `@types/semver` to the CLI package and a
regression test for the beta.9 → beta.10 case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:22:38 +08:00
AmAzing- 6de1e14a4d feat(subscription): add referral invite entry (#16073)
*  feat(subscription): add referral invite entry

* 💬 chore(subscription): refine referral code copy
2026-06-20 10:52:52 +08:00
Arvin Xu db2a62d704 🐛 fix(chat): treat parked runs as non-terminal in client run-lifecycle (#16072)
A parked run (`waiting_for_human` / `waiting_for_async_tool`) is not finished —
it is paused waiting out-of-band and resumes under a new operation. The client
lifecycle wrongly drove parked states through the terminal path: it emitted a
`client.runtime.complete` signal (mis-encoding `waiting_for_human` as a terminal
`cancelled`) and let `waiting_for_async_tool` fall through with an undefined
complete status.

Route parked states to `onRunParked` (no longer NOOP) instead of `completeRun`:
- `waiting_for_human`: complete the op so the approval spinner clears; a new op
  resumes the run.
- `waiting_for_async_tool`: keep the op running until the async result drives it.
- neither fires terminal side effects (title / queue drain / notification /
  markUnread) nor emits `client.runtime.complete`.

Drops the `waiting_for_human → cancelled` mis-encoding from the normalizer and
the parked branch from `completeRun` (parked never reaches it). This makes the
client lifecycle a correct shared implementation before gateway/hetero reuse it.

Characterization updated to lock the corrected behavior. LOBE-10382.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:38:07 +08:00
YuTengjing e914e98369 🐛 fix: avoid task template locale key flash (#16071) 2026-06-20 01:16:36 +08:00
Arvin Xu 825cfc2189 🐛 fix(device): tolerate CRLF line endings when editing files & surface real edit errors (#16061) 2026-06-20 01:15:22 +08:00
Arvin Xu 4386a42b92 🐛 fix(bot): recover MIME type for QQ c2c file attachments so audio reaches the model (#16063) 2026-06-20 01:00:14 +08:00
YuTengjing c316279606 🐛 fix: preserve gateway error event body (#16069) 2026-06-20 00:58:05 +08:00
YuTengjing c17dc415ed 🐛 fix: avoid flashing unauthorized state in tool auth alert (#16068) 2026-06-20 00:35:09 +08:00
YuTengjing 6b87a141b6 🐛 fix: resolve resource header i18n namespace fallback (#16066) 2026-06-20 00:09:28 +08:00
Arvin Xu b569b3e53b 🗃️ feat(database): combine workspace-device + ai_infra surrogate _id PK migrations (#16065)
* 🗃️ feat(database): combine workspace-device + ai_infra surrogate PK migrations (0111)

Merge the two staged workspace-scoped DB rollouts that both claimed 0111
into a single migration, since they touch disjoint tables:

- ai_infra (LOBE-10056 Phase 5): ai_providers / ai_models move from their
  composite PK to a surrogate `_id` uuid PK, with business uniqueness moved
  to workspace-scoped partial unique indexes (personal WHERE workspace_id IS
  NULL, workspace WHERE workspace_id IS NOT NULL).
- device + workspace (LOBE-10315): replace the full (user_id, device_id)
  unique with two partial uniques scoped by workspace_id; add workspace
  frozen / frozen_reason / frozen_at columns; DeviceModel gains an optional
  workspaceId plus registerWorkspaceDevice / queryWorkspaceDevices /
  findWorkspaceDeviceById / updateWorkspaceDevice / deleteWorkspaceDevice.

The combined 0111 SQL stays hand-written and idempotent: a NO-OP on cloud
production (ai_infra side was applied online via manual steps; device/frozen
adds are all guarded) and a full rebuild on fresh / self-hosted databases.
`bun run db:generate` reports no drift; device model tests pass.

Supersedes #15533 and #16004.

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

* 🗃️ feat(database): add verify_evidence table (0112)

Captured artifacts backing a verify_check_result — screenshot / gif /
video / text / dom_snapshot / transcript — with storage key, media
metadata (mime/width/height/duration/size/checksum), provenance
(captured_by / captured_at) and a self-referencing parent_evidence_id
that forms an evidence tree (ON DELETE set null keeps children).

- types: add verifyEvidenceTypes + verifyEvidenceCaptureSources to the
  verify domain vocabulary in @lobechat/types.
- schema: verify_evidence table with check_result / user / workspace
  cascade FKs and FK-lookup indexes.
- 0112 SQL is idempotent (CREATE TABLE IF NOT EXISTS, DROP+ADD FK
  constraints, CREATE INDEX IF NOT EXISTS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Revert "🗃️ feat(database): add verify_evidence table (0112)"

This reverts commit 45a253b8f8.

---------

Co-authored-by: rdmclin2 <rdmclin2@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:03:59 +08:00
Arvin Xu ef09457e63 feat(topic): persist topic unread as a backend status (#16056)
*  feat(topic): persist topic unread as a backend status

Move the "unread completed generation" indicator from a client-only
Zustand Set to a persisted `topics.status = 'unread'`, so it survives
reload and syncs across devices.

- add 'unread' to the topic status enum / type / updateTopic zod / sort rank
- compute cross-agent unread counts server-side on the sidebar agent list
  (SidebarAgentItem.unreadCount) so the home badge stays accurate for
  agents whose topics aren't loaded on the client
- markTopicUnread (formerly markUnreadCompleted) writes status 'unread';
  markTopicRead (formerly clearUnreadCompletedTopic) flips it back to
  'active' when the user opens the topic
- partition the terminal status write by (viewing, succeeded) so the
  background completion's 'unread' write never races the 'active' write
- derive unread selectors + sidebar status bucketing from topic.status

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(topic): clear unread on route hydration; don't mark parked/cancelled runs unread

Address two review findings on the persisted unread status:

- Route hydration (reload / deep link / notification) sets activeTopicId
  directly instead of going through switchTopic, so the persisted
  'unread' was never cleared while the user was already reading. Add
  useClearActiveTopicUnread — once the active topic appears in a loaded
  bucket as 'unread', mark it read. Wired into agent + group ChatHydration.
- A gateway agent_runtime_end with reason 'interrupted' / 'waiting_for_async_tool'
  is a cancel / park, not a completion. Gate markTopicUnread on
  isCompletedRuntimeEnd(reason) and derive onSessionComplete's `succeeded`
  from the same predicate, so aborts/parks clear back to 'active' instead
  of persisting as an unread completion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(topic): hoist topic status enum into @lobechat/types + add TopicModel tests

- extract the inline status z.enum in the topic router into a shared
  chatTopicStatusSchema / TOPIC_STATUSES source of truth in @lobechat/types
- derive ChatTopicStatus from the schema so the type and validator stay in sync
- add TopicModel unit tests covering CRUD, status filtering/ordering, triggers,
  metadata merge, duplicate, batchMoveToAgent and memory-extractor queries

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 23:55:04 +08:00
YuTengjing 44ebb77365 🐛 fix: preserve gateway error semantics (#16058) 2026-06-19 23:15:24 +08:00
YuTengjing a97e331727 🐛 fix: handle generated asset metadata dedup (#16057) 2026-06-19 23:11:30 +08:00
Arvin Xu 63b52522d0 🐛 fix(trusted-client): skip Market trusted token for synthetic eval userIds (#15959)
Synthetic local agent-eval / smoke userIds (`eval_*`, `qstash_smoke_*`) are
never real Market accounts, so Market rejects any trusted-client token built
from them with `invalid_trust_token / Invalid userId format`. The rejected
round-trip is non-fatal (prep calls are best-effort) but noisy and adds
noticeable prep latency during evals.

generateTrustedClientToken now returns undefined for these ids before signing,
avoiding the doomed request. Real userIds are unaffected (no real id carries
these prefixes).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:44:18 +08:00
Arvin Xu ba5571cb4a 🐛 fix(hetero-agent): don't treat an inline tool's task_notification as a post-task summary (#16050)
A slow `git commit` (running a lint-staged hook) makes Claude Code track the Bash
call as a task and emit `task_started` + `task_notification` back-to-back, with no
out-of-band callback turn in between, immediately followed by the tool_result —
i.e. an ordinary inline synchronous tool, not a Monitor-style long-running task.

The adapter armed `pendingTaskCompletion` on every `task_notification`, so the
next normal turn (which simply consumes that commit's tool_result and continues
working) got stamped `signal.type = 'task-completion'`. Downstream, a signal-
tagged turn is anchored off the source tool and kept off the spine, while the
following turn re-anchors to the pre-task assistant — the continuation forks off
the main chain and disappears from the rendered topic. Observed on
tpc_joZS2mksoY5L (two `git commit`s, two dropped turns) and tpc_SZXNHtiNeqxv.

Fix at the source: only arm `pendingTaskCompletion` when the ending task actually
fired callback turns while alive (`callbackCount > 0`). That is the signature of
a genuine long-running task whose completion produces a post-task summary (the
summary is meant to render after the preceding callbacks). A task that starts and
ends with zero callbacks was an inline tool — leave the next turn untagged so it
stays a normal main-chain step. Erring this way is safe: a mis-classified genuine
summary merely renders inline instead of disappearing.

Regression test: `task_started` + `task_notification` with no intervening callback
→ the next turn carries no `externalSignal`. The existing Monitor flow (a callback
fires before `task_notification`, `callbackCount > 0`) still tags `task-completion`.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:50:10 +08:00
Arvin Xu ce2e517be9 🐛 fix(model-runtime): converge 2nd residue wave + unmap payload-too-large from ECW (#16053)
🐛 fix(model-runtime): converge second residue wave + unmap payload-too-large from ECW

Second pass over the UpstreamHttpError fallback residue. Add ~40 message
patterns so the recognizable remainder gets a precise code instead of the
bare-HTTP bucket:

- ModelNotFound: invalid model identifier, incorrect model ID, integrator gating
- PermissionDenied: 403 Forbidden, Azure VNet/firewall, channel client restriction
- UserConfigError: routing 404 / "No route for that URI" / url.not_found (base-URL misconfig)
- InvalidRequestFormat: provider-side validation + custom-proxy schema bridges
  (reasoning/thinking type, Gemini INVALID_ARGUMENT, function_declarations, pydantic field errors)
- ExceededContextWindow: genuine "the model's context length" overflow
- CapabilityNotSupported: image inference unsupported, no-SSE
- InsufficientQuota: paid-only model, credits exhausted, CodingPlan
- ProviderNetworkError: undici "fetch failed"
- UpstreamGatewayError: bare HTML error bodies

Also unmap `Request body too large` from ExceededContextWindow: a payload / 413
size limit is not the model context window. It stays in the bare-HTTP bucket.

Tests: +second-round cases + a guard that payload-too-large is NOT ECW.
`bunx vitest run src/errors` → 103 passed.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:47:21 +08:00
Arvin Xu 9e9ab1f05d feat(device): auto-activate a device for bot triggers on a local target (#16032)
*  feat(device): auto-activate a device for bot triggers on a local target

A bot conversation has no UI to pick an execution device, and a stored
`local` target (in-process IPC) is unreachable from the cloud bot server,
so a bot-triggered run on a `local`-target agent would stay
`device-unrouted` and never touch the owner's machine.

Upgrade a stored `local` target to `auto` when `trigger === bot`, so the
run auto-activates an online device (single → use it; several → stay
unrouted and let the model pick via the remote-device tool). `none`
(explicit no-device) and `sandbox` (explicit cloud) are deliberate
opt-outs and stay; an explicit `requestedDeviceId` already pins a device;
chat mode still wins (plain chat). Only the `bot` trigger fires this — a
`chat`/`cron` run on a `local` target is unchanged.

Plumbs `trigger` into `resolveExecutionPlan` and both call sites (native
+ hetero dispatch) in execAgent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(device): rename misleading `isDesktop` to `clientExecutionAvailable`

The `isDesktop` option on `resolveExecutionTarget` / `resolveExecutionPlan`
never meant "the build is desktop" nor "the message came from a desktop
client". It gates whether the `local` target has somewhere to run — i.e.
whether tools can execute on the user's own client/device (`runtimeMode:
'local'`, the `'client'` executor), as opposed to a pure-cloud context that
only reaches the sandbox. It is the boolean form of `RuntimePlatform`
('desktop' vs 'web'); each layer feeds the value that means this for it:
`isDesktop` (build const) in the UI, `gatewayConfigured` on the server,
`hasDeviceProxy` in the tools engine.

`isDesktop` was misleading on two counts: it implied the desktop build, and
"local"/"desktop" are perspective-relative (the server is never "local" /
"desktop"). `clientExecutionAvailable` names the user-side capability
directly, so `!clientExecutionAvailable` reads unambiguously as "no client to
run on" regardless of where the resolver runs. It gates only `local`; an
explicit `device` binding is reachable from anywhere and is unaffected.

Pure rename + doc clarification, no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(device): honour a bound local device for bot runs

The bot-trigger upgrade relabelled every `local` target to `auto`, but the
device switcher persists the desktop's own `deviceId` as `boundDeviceId` when
the user picks `local` ("this device"). `auto` deliberately ignores
`boundDeviceId`, so an owner's bot message for a pinned-local agent could
auto-grab whichever single device was online — or go ambiguous with several —
instead of the machine the user chose.

Upgrade a bot `local` target to `device` (route to the pinned machine) when it
carries a `boundDeviceId`; only an UNBOUND `local` falls through to `auto`. An
offline pinned device now stays `device-unrouted` (bound-device-offline)
rather than silently running elsewhere — same no-silent-fallback rule the
`device` target already follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:52:31 +08:00
YuTengjing 6c62349339 feat: inject model knowledge cutoff (#16048) 2026-06-19 20:50:20 +08:00
YuTengjing 3beafb20c6 🌐 feat(subscription): add contextual plans modal header i18n keys (#16025) 2026-06-19 20:45:48 +08:00
YuTengjing b2ae69ac11 ♻️ refactor: derive OpenAI model routing from IDs (#16046) 2026-06-19 20:36:39 +08:00
Arvin Xu ec40f7e405 📝 docs(hetero-agent): document live-trace capture & field-classifier debug flow (#16049)
* 📝 docs(hetero-agent): document live-trace capture & field-classifier debug flow

Capture the recurring "read the recorded trace, then check the hetero
implementation against it" workflow in the heterogeneous-agent skill so it
doesn't have to be rediscovered each time.

- references/debug-workflow.md §2: the in-app live-trace recorder
  (HeterogeneousAgentCtr.createCliTraceSession) — when it records, where it
  writes (cwd/.heerogeneous-tracing vs appStoragePath when the toggle is on),
  the per-session layout (meta/stdin/stdout/stderr/exit), and the
  .last-live-trace fast path. Prefer it over a hand-rolled `claude -p` repro.
- references/debug-workflow.md §8: verify a structured-field classifier
  against a real trace — grep the discriminator field across all event states,
  not just the failing one. Worked example: the CC usage-limit fix, where
  rate_limit_info rides on status:"allowed" events too.
- SKILL.md: point the debug order at the live trace and add the
  wrong-terminal-error-guide bug pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(hetero-agent): fix mangled reproduce-session sentence

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:21:05 +08:00
Arvin Xu 8b9fd761f6 🐛 fix(chat): allow audio upload in agent mode and render audio with a waveform player (#16022)
* 🐛 fix(chat): allow audio upload in agent mode and render audio with a waveform player

- Unify the upload UI media gate with the store's agent-mode bypass: in agent
  mode / heterogeneous agents the gate no longer drops audio/video/image the
  agent can still process (e.g. .m4a on a non-audio model).
- Backfill the audio mime from the file extension on upload, since .m4a shares
  the ISO-BMFF container with mp4 and the browser / byte-sniffing can report an
  empty or video/mp4 mime, which mis-classified it as a non-audio file.
- Replace the native <audio> in user messages with a self-contained waveform
  player (play/pause, click-to-seek, decoded peaks with a deterministic fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ️ a11y(chat): lazy-load audio waveform and label the play/pause control

- Defer waveform fetch/decode until first playback (or seek) instead of on mount,
  and skip decoding files over 20MB, so opening a conversation with several audio
  attachments no longer downloads/decodes every clip and spikes network/memory.
- Give the toggle a stateful accessible label (Play audio / Pause audio) with
  aria-pressed, keeping the filename as a title tooltip, so screen readers announce
  the action rather than the file name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:14:34 +08:00
Arvin Xu 5dea768397 🐛 fix(device): inject device-bound working directory into server local-system tool chain (#15887)
* 🐛 fix(device): inject bound working directory into server local-system tool chain

Server/gateway-mode device runs ignored the bound working directory
(workingDirByDevice): the local-system tool's systemRole prompt
({{workingDirectory}}) and the daemon both saw the device default cwd
(= `/` for a Finder/Dock-launched app), so the agent didn't know where
the repo was and ran commands from `/`.

Core fix: resolveWorkspaceInit already loads the persisted device row and
resolves the bound directory (resolveDeviceWorkingDirectory: topic override
> workingDirByDevice > device.defaultCwd) for its workspace scan. Return
that boundCwd and write it onto deviceSystemInfo.workingDirectory, which
fills the {{workingDirectory}} prompt placeholder — the channel the model
uses to know where it is and reach for absolute paths. Sourced from the DB
row (works offline), not a live queryDeviceSystemInfo() call (which only
reports the daemon's process.cwd = `/`); that field is dropped from the
template fetch. Resume-safe via the existing deviceSystemInfo plumbing.

The model can't pick cwd/scope, so two ops still need a runtime default
injected into the device-proxy args: runCommand (manifest hides `cwd`,
daemon spawns in process.cwd() when omitted) and the search ops
(searchFiles/globFiles/grepContent, whose `scope` daemon-side falls back
to process.cwd() despite the manifest claiming it defaults to the working
directory). File ops (read/write/edit/move/list) get absolute paths from
the prompt and need nothing injected.

LOBE-10440

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

* 🐛 fix(device): resolve relative file-op paths against bound working directory

Server/gateway mode follow-up: local-system file ops (readFile / writeFile /
editFile / listFiles / moveFiles) now resolve a relative path against the
device-bound working directory instead of the daemon's process cwd (= `/` for a
Finder/Dock-launched app), so a model-supplied relative path lands in the bound
directory. `executeToolCall` only forwards `arguments`, so `cwd` rides in the
args: the server proxy injects it (WORKING_DIR_ARG) and the daemon resolves it
via a new shared `resolveAgainstCwd` in `@lobechat/local-file-shell` (absolute
paths and the no-cwd case are unchanged → no regression for desktop client-mode).

Also returns `resolveWorkspaceInit` as a dedicated `ResolvedWorkspaceInit`
({ boundCwd, workspace }) instead of an ad-hoc intersection, keeping `boundCwd`
out of the persisted/UI-shared `WorkspaceInitResult`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(device): drop unexposed listFiles from working-dir injection

listFiles was removed from LocalSystemManifest.api (99023811d8), so the
proxy never builds a listFiles function. Drop it from WORKING_DIR_ARG and
the test, swapping in moveFiles which is still an exposed file op.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:13:31 +08:00
Arvin Xu deb4bd6a3c feat(devices): add recent-directory button to device detail panel (#16040)
*  feat(devices): add recent-directory button to device detail panel

Add an always-visible "+" action to the right of the "Recent directories"
title in the device settings detail panel. Previously the add affordance was
gated on canBrowse (current desktop device only), leaving remote / non-current
devices with no way to add a directory — which looked like a bug.

The button adapts per device: native folder dialog when browsable, otherwise
reuses the control bar's openAddWorkingDirModal with deviceService.statPath
validation on the target device. The redundant bottom button is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(devices): extract AddWorkingDirModal into shared WorkingDirectory feature

Move AddWorkingDirModal out of ChatInput/ControlBar into a neutral
src/features/WorkingDirectory domain feature, so the device settings panel no
longer reaches into the chat control bar to reuse it. Both the control bar
picker and the device detail panel now import it from @/features/WorkingDirectory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(devices): load the device i18n namespace for recent-dir errors

The detail panel's useTranslation only declared the `setting` namespace, so the
inline `{ ns: 'device' }` overrides for the add-directory validation errors
relied on `device` being loaded elsewhere — on the standalone settings page it
may not be, rendering the raw key. Declare both namespaces and reference the
device keys with the `device:` prefix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:09:53 +08:00
Arvin Xu 415fdd02eb 🐛 fix(hetero-agent): gate CC usage-limit guide on rejected status (#16042)
A later unrelated terminal failure (e.g. an ECONNRESET network drop) was
inheriting the last allowed rate_limit_event's rolling-window metadata and
rendering a bogus "usage limit reached, resets at X" guide.

CC stamps `rate_limit_info` onto its events even when the request goes
through (`status: 'allowed'`) — that block is the rolling-window metadata
(`resetsAt`, `rateLimitType`), not evidence the limit was hit. Now require
BOTH `status: 'rejected'` AND a concrete reset window before classifying a
failure as a user-side quota limit.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:09:41 +08:00
YuTengjing 815901efa0 ️ perf: persist model config cache (#16047) 2026-06-19 19:40:46 +08:00
YuTengjing a6f816f9bd 🐛 fix: provide Market auth to SPA modals (#16045) 2026-06-19 19:26:01 +08:00
YuTengjing 4e96552102 🐛 fix: suppress agent mode notice for CLI agents (#16044) 2026-06-19 19:00:25 +08:00
Arvin Xu 80cce10dd5 🐛 fix(cli): stop connect daemon on logout (#16038)
* 🐛 fix(cli): stop connect daemon on logout

`lh logout` only cleared the credentials file, leaving the `lh connect`
daemon running. It kept the device online on the gateway using the cached
token until expiry, so the machine stayed remotely driveable after the user
thought they had logged out, and left stale daemon.pid / daemon.status.json
behind.

Tear down the daemon via stopDaemon() before clearing credentials, which
SIGTERMs the daemon (disconnecting the gateway) and removes the pid/status
files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔒 fix(cli): verify daemon identity before SIGTERM

A bare isProcessAlive() check let logout/connect-stop SIGTERM an unrelated
process when the OS reused a dead daemon's PID after a crash or reboot.
getRunningDaemonPid() now also confirms the live PID is actually a LobeHub
daemon (matches the `connect … --daemon-child` command-line signature) and
treats reused PIDs as stale, cleaning up the metadata instead of killing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔖 chore(cli): bump @lobehub/cli to 0.0.32

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(cli): add top-level `disconnect` command

`lh connect` was only stoppable via the nested `lh connect stop`, which is
hard to discover. Add `lh disconnect` as a top-level alias that runs the same
daemon-stop handler, so users have an obvious inverse of `lh connect`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:06:09 +08:00
Arvin Xu d28e976ac2 🐛 fix(device): distinguish local and gateway device targets (#15921)
* 💄 style(device): hide current machine's duplicate entry from remote device list on desktop

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

*  feat(device): add explicit `auto` execution mode — never auto-activate a device unless opted in

Auto-activation used to be an implicit default: an unbound run on any
device-capable target (incl. the desktop `local` default) silently grabbed
the single online device. So an agent the user never pointed at a device
would still activate one — and a `none` selection was the only way to opt out.

Introduce `auto` as an explicit, opt-in mode and make it the ONLY mode that
auto-activates an unbound device:

- `none`    → plain chat, never a device
- `auto`    → one online device is activated automatically; with several, the
              run stays unrouted so the model picks one via the remote-device
              tool. Ignores any stale stored `boundDeviceId` (that's `device`
              mode) — only an explicit `requestedDeviceId` pins a device.
- `local`   → strictly this desktop machine
- `device`  → the explicitly bound device
- `sandbox` → cloud sandbox

`local` / `device` no longer auto-grab a device: an unbound run stays
`device-unrouted` until one is bound/requested. Platform defaults are
unchanged (desktop `local`, web `none`) so no migration is needed — existing
configs simply stop auto-activating; users opt into it by picking `auto` in
the execution-device switcher.

Adds the `auto` option + chip to HeteroDeviceSwitcher (non-hetero only) and
en/zh copy. Updates resolveExecutionPlan + tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(device): show this machine as a named device row with a local tag

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix: restore gateway device option

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:55:04 +08:00
Innei f22185716a ⬆️ chore(deps): upgrade react-router to v8 (#16029)
- Bump react-router-dom 7.13 → react-router 8.0
- Codemod 'react-router-dom' → 'react-router' across 275 sites
- Pin RouterProvider to 'react-router/dom' in 5 SPA entries
- Bump react/react-dom to 19.2.7 (v8 minimum)
- Update vite manualChunks + optimizeDeps include
- Update vi.mock string literals in test files
2026-06-19 16:31:01 +08:00
YuTengjing 702e2aa15d 🔨 chore: fix skill audit symlink inventory (#16039) 2026-06-19 16:12:44 +08:00
YuTengjing b917a81c77 🐛 fix: guide task template connector auth (#16036) 2026-06-19 15:48:52 +08:00
Arvin Xu 32eaab9537 chore: clean LOBE-XXX code comment markers (2026-06-19) (#16031) 2026-06-19 13:21:45 +08:00
AmAzing- a976cd52c9 🐛 fix(agent): hide Codex cloud config tab (#16037) 2026-06-19 13:21:16 +08:00
AmAzing- ce274593c2 🐛 fix(chat): clarify skill installation flow (#16034) 2026-06-19 11:41:01 +08:00
René Wang 93a6f956d9 💄 style(chat-input): polish attachment menu, agent mode & status hints (#16010) 2026-06-19 08:41:17 +08:00
AmAzing- 46bad6e617 💄 style(agent): polish skill suggestion modal UX (#16027) 2026-06-19 00:52:18 +08:00
Arvin Xu dcd1650167 🐛 fix(model-runtime): classify upstream-message fallbacks out of UpstreamHttpError (#16024)
* 🐛 fix(model-runtime): classify upstream-message fallbacks out of UpstreamHttpError

Harvest ~30 message patterns from the agent-gateway error dashboard's
UpstreamHttpError / ProviderBizError fallback bucket. These are 4xx errors that
carried no recognized message, so refineErrorCode demoted them to the bare-HTTP
UpstreamHttpError bucket. Classify them into precise codes instead:

- ExceededContextWindow: oversized request body, 1m-context gating, /compact hint
- ProviderServiceUnavailable: Vietnamese/Chinese "system busy" + upgrade notices
- ModelNotFound: ollama allow-list, Vertex publisher-model, CN model-not-online
- PermissionDenied: proxy bans, Codex-only group, trial-user gating
- InvalidRequestFormat: illegal params, bad tool schema, malformed proto, negative max_tokens

Provider-agnostic, so custom proxies benefit too. Classification only — it does
not suppress anything; messages stay visible under a precise code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(model-runtime): make UpstreamHttpError fallback re-refinable

UpstreamHttpError is itself a catch-all — the status fallback emits it for a 4xx
whose message matched nothing — but it was excluded from PATTERN_REFINABLE_CODES,
so once an error was demoted to it, it could never be upgraded again.

formatErrorForState is idempotent and re-enriches an already-normalized error, so
a code demoted to UpstreamHttpError on an inner pass was frozen there even when
the message became recognizable. Add UpstreamHttpError to the pattern-refinable
set so a later pass (or a historical batch-rewrite) can still upgrade it. Bare
500 is already covered via InternalServerError. Pattern-only — UpstreamHttpError
is intentionally kept out of the HTTP-status fallback to avoid re-bucketing loops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:56:38 +08:00
Arvin Xu af51c3290f 🐛 fix(agent-documents): skill index page title, empty state & context-aware default tab (#16005)
* 📝 docs(ux): add empty-state fallback + context-aware default tab principles

Distill this branch's agent-document fixes into the ux skill: persistent chrome
still needs a body empty state (§1.1), and a multi-tab surface's landing tab
should follow entry intent + data state rather than a hardcoded first tab (§1.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(agent-documents): honor skill entry for default tab & enforce metaReadOnly at the store

Two review fixes for the agent-document panel:

- Default tab: when the open doc is a skill (SKILL.md or any file inside a
  skill bundle), land on the Skills tab even when normal documents exist.
  Previously the default only checked whether any document existed, so the
  skill-entry flow opened on the wrong tab. Derived from the current docId's
  list item category.

- metaReadOnly enforcement: gate setTitle / setEmoji / performMetaSave in the
  PageEditor store instead of only disabling the TitleSection UI. The
  page-agent title handlers route editTitle, applyServerSnapshot, and initPage
  title extraction through the store's setTitle, so a managed SKILL.md could
  still be renamed (and its filename clobbered via DocumentService.updateDocument)
  by AI/extraction paths. The store is the single chokepoint covering every
  caller. Adds tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:52:24 +08:00
Arvin Xu dc3b325040 ♻️ refactor(hetero-agent): assistant-anchored message-chain write spine (#15930)
* ♻️ refactor(hetero-agent): assistant-anchored message-chain write spine (LOBE-10445 phase 2)

Switch the heterogeneous-agent write side from the fragile "next assistant
parents off the run's last tool" rule to an assistant-anchored spine:
`user → assistant → assistant …` with tools as inline children. Phase 1's
role-aware reader already reads both shapes equivalently, so this moves
chain-linearity off the distributed write path and onto a deterministic,
DB-authoritative anchor.

- mainAgentCoordinator: add `lastSpineMessageId` (the most recent non-tool /
  non-signal main message). Normal turns parent off it; signal / reactive
  toolless turns (Monitor stdout callbacks) keep parenting off
  `lastToolMsgIdEver` so the reader still renders them as tool-child callbacks.
  The spine advances on normal turns only — never on a signal turn — so a
  normal continuation after a callback burst re-mounts on the pre-callback
  spine assistant, not on a callback (which the reader would skip, orphaning
  everything after it).
- subagentCoordinator: stop advancing `lastChainParentId` to the batch's last
  tool; it stays at the current assistant (subagents have no signal turns).
- MessageModel: replace the seed-scoped last-tool anchor query
  (`getLastMainThreadToolMessageIdSince` + `getLastChildToolMessageId`) with
  `getLastMainThreadSpineMessageId(topicId)` — latest `threadId IS NULL`,
  `role != 'tool'`, `metadata->'signal' IS NULL`. Reading the spine straight
  from the DB is fork-resistant: it does not depend on the in-memory
  current-assistant pointer, which can regress to the run's seed on a cold
  serverless replica (the remote "断链" fork). Consecutive cold-replica steps
  now chain linearly instead of forking onto a stale node.
- HeterogeneousPersistenceHandler: recover `lastSpineMessageId` from the new
  query in `refreshMainStateFromDb`; drop the whole tool-anchor recovery block,
  the `seedAssistantMessageId` state field, and its helper methods;
  `buildSubagentSnapshot` recovers `lastChainParentId` as the thread's current
  assistant.

Tests: heterogeneous-agents 256, server hetero 92 (cold-replica anchor test
rewritten to assert a linear, fork-resistant spine), database message model 63
(incl. spine query: excludes tools / signal callbacks / subagent threads, user
scoped). Type-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(hetero-agent): align client executor tests to the assistant-anchored spine

The client `heterogeneousAgentExecutor` consumes the shared
`@lobechat/heterogeneous-agents` reducer, so the write-side spine switch in this
PR also changes client behavior — but the client tests still asserted the old
"next assistant parents off the run's last tool" rule and failed in CI
(Test App shard 1/2, 9 failures). Update them to the spine rule:

- multi-step / Codex multi-turn / full E2E: step assistants chain off the most
  recent non-tool main message (the spine), with tools inline children — not
  off the preceding turn's last tool.
- subagent terminal assistant: chains off the in-thread assistant (the subagent
  reducer keeps the chain anchor on the assistant, not the tool), so the thread
  reads user → asst(tools) → asst(result).
- Monitor parentId chain: reactive Monitor stdout steps arrive as ordinary CC
  message steps with no `task_started`/`task_notification` signal context, so
  under the spine rule they parent off the previous spine assistant and render
  as plain spine assistants. (Genuine signal turns — `external signal` describe
  — stay tool-anchored and are unchanged.)

Behavior-only test alignment; no production code change. 70/70 pass, type-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:49:11 +08:00
Innei 33d95777eb 🐛 fix(desktop): close DevTools with window shortcut (#16023) 2026-06-18 23:30:46 +08:00
YuTengjing dc5aa7c39e 🐛 fix: drop task template connector filtering (#16020) 2026-06-18 22:18:44 +08:00
Innei 9d4bb09aa5 feat(chat): collapse FloatingChatPanel into a slim strip by default (#15991)
*  feat(chat): collapse FloatingChatPanel into a slim strip by default

The doc-portal side chat now starts as a one-row input strip instead of a 180px
sheet, freeing space for the document. A hover-revealed expand button and the
existing Send hook both lift it into a 420 / 800 snap sheet; drag-to-dismiss or
the new header chevron-down collapse back. The ChatInput's chrome change
(showControlBar + actions) on each toggle is crossfaded via the View Transition
API, so its height shift no longer reads as a jump while the sheet animates.

The panel wrapper now owns the rounded surface; the FloatingSheet renders
seamlessly inside it so the sheet and input share one continuous card.

*  feat(chat): wire FloatingChatPanel into the full-page agent doc route and shrink the collapsed input to one line

The standalone agent-document view (`/agent/:aid/docs/:docId`) now anchors the
same side-chat panel at the bottom under the same lab toggle. The chat surface
no longer has to live on the in-chat portal alone.

Conversation `ChatInput` gains a `compact?: boolean` prop. When set, the panel
wrapper collapses the rendered chrome to a one-row strip via a CSS focus-within
clamp — action bar, control bar, footnote stay rendered but are hidden until the
user focuses the editor. The strip expands smoothly on focus and collapses
again on blur; the prop defaults to false so every other chat surface is
unaffected.

* 💄 style(chat): clamp the full-page FloatingChatPanel to the conversation width

The standalone agent-document route hosted the panel at the editor's full width,
so its rounded surface read as one giant card. Reuse `WideScreenContainer` (same
clamp as the document body) so the panel inherits the surrounding content width
and centers below the page.

* 💄 style(chat): drop the FloatingChatPanel surface while collapsed

The wrapper's border and background made the one-row strip read as a card even
when no sheet is open. Hide both while collapsed and let the strip sit flush
against the page; the border + bg fade back in over 240ms as the sheet rises.

*  feat(chat-input): drop the action bar footer when ChatInput is compact

The previous compact wrap clipped the editor with a CSS max-height; that cut
straight through the editor's rounded border and the result looked truncated.
Push the compact contract into the renderer instead: DesktopChatInput accepts
a `compact` prop and skips passing a footer to the underlying editor, so the
single-row strip ends on the editor's own rounded bottom edge with no clip.
Enter still sends, which is the only entry point the FloatingChatPanel strip
exposes while collapsed.

*  feat(chat): expand the collapsed strip into the full ChatInput while focused

The strip only dropped the action bar based on the panel's collapsed state, so
focusing the editor never restored Send / actions. Track focus-within on the
input row and recompute `compact = renderedCollapsed && !focused`; the action
bar (and control bar / left+right actions) returns on focus and folds back on
blur, with each transition wrapped in the same View Transition crossfade as
the collapse / expand toggle.

*  feat(chat): gate the FloatingChatPanel expand bar on focus instead of hover

The "展开聊天" pill used to surface whenever the cursor entered the strip,
including while the input was still in its one-row compact state. Move the
trigger to focus: while the strip is collapsed and unfocused (compact) the bar
stays hidden; focusing the editor expands the input AND reveals the pill, so
the affordance only shows once the user has actually engaged with the input.

*  feat(chat): anchor the doc-side chat to a per-document topic, not the active one

The FloatingChatPanel used to inherit whatever `useChatStore.activeTopicId`
happened to hold when the panel mounted. On the full-page document route that
landed it on a stale or empty topic; on the in-chat portal it tied the
"doc-side" chat to the user's current main conversation. Replace both with a
doc-anchored topic provisioned per `(documentId, agentId)` pair.

Server
- New `agentDocument.getOrCreateChatTopic` procedure. Idempotent: returns the
  existing topic when one is already linked via `topic_documents` for this
  agent + document pair; otherwise creates a topic with
  `trigger='document'` (titled from the document) and inserts the
  `topic_documents` association.
- `TopicModel.findByAgentAndDocumentTrigger` joins through `topic_documents`
  for the lookup half of the get-or-create.
- `'document'` is now part of `MAIN_SIDEBAR_EXCLUDE_TRIGGERS` and the recent /
  topic-list system trigger filter so these auto-provisioned topics stay out
  of the user-facing topic surface.

Client
- `useDocumentChatTopic({agentId, documentId})` wraps the procedure with SWR
  so re-mounts share the resolved topic id.
- `AgentDocumentPage` (full-page route) and `Portal/Document/Body` (in-chat
  portal) both gate panel rendering on the resolved topic id and pass
  `scope='main'`.
- `FloatingChatPanel` branches on `scope`: `'main'` hands message loading off
  to `ConversationProvider` (no `skipFetch` / `hasInitMessages` / external
  messages), `'thread'` keeps the existing self-managed slice for backward
  compat. Thread-anchoring logic only runs under `'thread'`.

* 🔥 refactor(chat): drop thread-scope branch from FloatingChatPanel

Both call sites now resolve a doc-anchored topic and render through the
main-scope path. With no remaining `'thread'` consumer there is no reason for
the panel to keep the dual code path: the thread-anchoring (`portalThreadId`
sync, `sourceMessageId` lookup, `isNew` / `threadType`), the private message
slice (`dbMessagesMap` filter, `replaceMessages`, `onMessagesChange`), and
the `scope` / `threadId` props are all removed. The panel is now main-scope
on the supplied topic id, full stop, and `ConversationProvider` owns message
loading.

Tests collapse to the main-scope shape — the thread-scope assertions and the
related chat-store mocks go away.

* 🔥 refactor(context-engine): remove ActiveTopicDocumentContextInjector

With doc-anchored topics, every turn in a topic is by definition about the
linked document — the chat never crosses scopes, so the per-turn `<document>` +
"use lobe-agent-documents, not PageAgent" guidance is now redundant.

Removed
- The `ActiveTopicDocumentContextInjector` provider and its Phase 4 wiring in
  `MessagesEngine`.
- `RuntimeActiveTopicDocumentContext` plus `RuntimeInitialContext.activeTopicDocument`.
- The server-side `aiAgent.execAgent` block that resolved the agent-document
  row from `appContext.documentId` to populate `activeTopicDocument`.
- The client-side `resolveActiveTopicDocumentInitialContext` resolver plus its
  unit tests and the call sites in `conversationLifecycle` / regenerate.

Kept
- `mergeAgentRuntimeInitialContexts` — still needed for mentioned-agents and
  ad-hoc tool manifest merging. Moved to a focused
  `src/store/chat/utils/runtimeInitialContext.ts` module.
- The `documentId` / `agentDocumentId` fields on `ConversationContext` — the
  downstream `lobe-agent-documents` tool calls still read them for direct row
  lookups, just without the per-turn guidance prologue.

* 🐛 fix(chat): pin the AgentDocumentPage panel to the URL agent, not the active one

The full-page document route reads its agent from the URL params (`aid`), but
the FloatingChatPanel was pulling `useAgentStore.activeAgentId` first and only
falling back to the URL value. Whenever the user's globally-active agent
didn't own the document being viewed, `agentDocument.getOrCreateChatTopic`
threw NOT_FOUND on the `findRowByDocumentId(activeAgent, doc)` lookup, the
hook never resolved a `topicId`, and the panel silently never rendered.

Use `aid` directly — the route already owns the agent identity, so the lookup
hits the agent that genuinely owns the document.

* 🩹 chore(chat): log failures in useDocumentChatTopic so the panel's silent gating is debuggable

The panel renders only when the doc-anchored topic id resolves, so any
procedure error currently disappears into SWR. Surface failures through
`debug('lobe-chat:useDocumentChatTopic')` and a plain `console.error`, and
log skipped / resolved transitions, so a missed topic id can be traced to
either the input pair or the server call from the browser console.

* 🐛 fix(chat): sync the FloatingChatPanel through the shared chat store, not just its local slice

The doc-scoped refactor handed message loading off to `ConversationProvider`'s
internal store, but the lifecycle's `chatStore.replaceMessages` (called after
`aiChat.sendMessageInServer` resolves) only writes to the *global* chat
store's `dbMessagesMap`. The panel never observed that map, so the user's
message and the streamed assistant response landed in the database but the
panel UI stayed empty.

Wire the same `messages` / `onMessagesChange` / `hasInitMessages` loop the
main `ConversationArea` uses: read `dbMessagesMap[chatKey]` from the chat
store, pass it as the external `messages` prop, and write back through
`replaceMessages` on changes. `ConversationProvider` then mirrors the
canonical slice in its local store and the panel re-renders the moment the
lifecycle persists.

* Revert "🔥 refactor(context-engine): remove ActiveTopicDocumentContextInjector"

This reverts commit 9419ee7de4.

* 💄 style(floating-chat-panel): drop side/bottom borders in expanded state

Only keep the top border (aligned with the drag handle) so the panel
no longer competes with adjacent sidebar borders.
2026-06-18 22:17:41 +08:00
Arvin Xu ad4545539c 💄 style(chat): lead collapsed tool-call summary with total call count (#16003)
* 💄 style(chat): lead collapsed tool-call summary with total call count

Move the total call count to the front of the folded workflow summary and
drop the "total" wording, merging the tool-kind count into the list as a
trailing "across N tools". Reads as "15 calls: … across 6 tools · 1 failed".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🌐 i18n(chat): use a fresh key for the tool-list suffix

The suffix wording changed meaning (standalone "N tool kinds" → appended
"across N tools"). Reusing summaryMoreTools would leave other locales with
stale standalone grammar, since auto-i18n only fills missing keys. Rename to
summaryAcrossTools so every locale is missing it and CI translates the new
copy; until then those locales fall back to the English default rather than
broken grammar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:26:28 +08:00
Arvin Xu d3e95dc8f4 🐛 fix(agent): make working-directory clear button actually clear (#16019)
The "clear directory" picker action wrote a workingDirByDevice map with the
device key removed, but both the client optimistic store and the server
persist path deep-merge, which can only add/overwrite keys — never drop one.
The cleared entry was re-merged from the prior value, so the selection
survived and the button looked dead.

Carry the clear as an explicit `undefined` marker and prune undefined-valued
keys after each merge (client `internal_dispatchAgentMap` + server
`updateConfig`), mirroring the existing `params` undefined-delete pattern.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:13:21 +08:00
Arvin Xu 3d0ee23c2d feat(prompt): rework input completion prompt to v1.2 — predict intent, incl. long-range (#15961)
*  feat(prompt): rework input completion prompt to v1.2 — predict intent, incl. long-range

Real tab-accept rate for input_completion is ~4% (positive / all feedback incl.
neutral implicit-ignore). But the fix is not "abstain by default" — that is too
conservative and blocks the long-range completion we actually want. v1.2 reframes
the engine around predicting the user's intent, the way inline code completion does:

- Lead with prediction: complete the rest of the phrase, the sentence, or the
  next sentence or two when the conversation makes intent clear. Favor a useful
  continuation over a timid one-word guess.
- Abstention is now a narrow safety valve, not the default: empty only when the
  natural continuation would be the assistant's voice, would fabricate unknown
  specifics, or there is no signal. The "complete sentence -> empty" rule is gone
  (sentence-end cancellation belongs in the app layer, not the prompt).
- Voice guard kept and made semantic (you are finishing the user's message, never
  replying); length cap relaxed from <8 words.
- Examples are synthetic and English-led (match-user-language instruction stays);
  no verbatim trace text.

Context engineering:
- History is rendered as a flat, speaker-labelled <conversation> block rather than
  replayed as real assistant/user turns — replaying invites the role-flip failure.
- Filter to user/assistant non-empty messages, keep the last 8 turns, clip each to
  1000 chars so one long turn can't drown the draft and inflate latency/cost.
- Draft sits last (recency) inside a <draft> block with an explicit <|cursor|>
  marker, folded with context into a single user message. System prompt stays
  constant so the tracing promptHash keeps grouping by version.

*  feat(eval-rubric): feed task input into the llm-rubric judge prompt

The llm-rubric matcher only sent [Criteria]/[Output] to the judge, so the judge
never saw the task input it was scoring against (the input the answer-relevance
matcher already receives). Thread `input` through the dispatch into the judge
user prompt as an [Input] block — without it the judge scores an output in a
vacuum.

Also refactor matchLLMRubric to a single object param, now that it takes
input/actual/expected/rubric/context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(prompt): tune v1.2 input-completion — default-short + stay-in-user-voice

Backtest showed ~3.2x over-generation vs the length users actually accept. Bias
the autocomplete toward that length: default to finishing the current phrase or
sentence, demote long-range continuation to an explicit exception, and drop the
anti-"timid" nudge. Reinforce user-voice under heavy context (never resolve or
decide on the assistant's behalf) while clarifying that the user asking the
assistant a question is still the user's voice (prevents over-abstention).

Prompt version stays v1.2 (still unreleased).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(eval-rubric): retry the LLM judge and keep real 0 scores

The llm-rubric matcher treated a valid score of 0 as "no score" (`!result.score`) and auto-failed on a single missing/errored judge response. Check the score type instead, and retry up to `judgeMaxAttempts` (default 3) on a thrown error or unparseable response so one flaky sample can't reject a good output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(prompt): v1.3 input-completion — kill the assistant-voice role-flip

When the draft tells or asks the assistant to act ("那你改下?", "你能否用 cli 验证?"), the model would complete in the assistant's voice ("我先把…修掉", "我这边可以…吗"). Disambiguate the first person (user's 我 vs assistant's 我), forbid 我先/我来/我这边/我这就/I'll/let me when the draft addresses the assistant, prefer empty for already-complete directives, and reinforce it in the schema description + examples. Lifts the avoid-assistant regression set from ~43% to ~97% on gpt-5.4-mini.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(prompt): input-completion v1.2 — English-only assistant-voice role-flip guard

v1.2 is not released yet, so keep the version label at v1.2 (revert the premature v1.3 bump) and fold the fix into it. Prompt is English-only (no Chinese). Disambiguate the first person — both the user and the assistant say "I", so the role decides who is speaking; when the draft tells/asks the assistant to act, do not continue as the assistant accepting the task ("I'll…", "let me…"), prefer empty for already-complete directives. Reinforced in the schema description + examples. On a 25-case avoid-assistant regression set, gpt-5.4-mini passes ~92-96%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 19:55:26 +08:00
YuTengjing cd4de1cf96 ♻️ refactor: drop dead markUserValidAction business slot (#16018) 2026-06-18 19:27:30 +08:00
AmAzing- eb2978dcfd 🐛 fix(desktop): require re-auth for expired sessions (#16014) 2026-06-18 19:18:05 +08:00
AmAzing- 3f79eefcf7 feat(agent): recommend market skills during agent creation (#16016) 2026-06-18 19:17:31 +08:00
YuTengjing db19e28dea 🐛 fix: gate task template recommendation seed key (#16013) 2026-06-18 18:55:04 +08:00
YuTengjing 6cbfe4389d ♻️ refactor: use server config for business features (#16015) 2026-06-18 18:27:44 +08:00
lobehubbot d5ac9cf0c2 Merge remote-tracking branch 'origin/main' into canary 2026-06-18 08:47:48 +00:00
Arvin Xu a7fac87b02 Revert "🗃️ feat(database): add workspace frozen columns"
This reverts commit f46cc508b5.
2026-06-18 16:46:02 +08:00
lobehubbot 85fe095ce5 Merge remote-tracking branch 'origin/main' into canary 2026-06-18 06:44:45 +00:00
René Wang e92ab2acdd 📝 docs: add product glossary page (#16001)
* 📝 docs: add product glossary page

Add a user-facing glossary of LobeHub concepts (agents, topics, threads, skills, memory, models, billing, and more) at docs/usage/glossary, with terms organized by initial letter and EN + zh-CN versions.

Point the existing developer translation-consistency table (docs/glossary.md) to the new page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs: move glossary to top-level docs/glossary

Move the product glossary from docs/usage/glossary to top-level docs/glossary (EN + zh-CN). Relocate the developer translation-consistency table to docs/development/internationalization/glossary and point it at the new /docs/glossary page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs: make glossary terms headings so they appear in the page TOC

Promote each term to an h3 heading (under its letter h2) so it shows in the On This Page outline, remove the redundant Jump-to row, and repoint synonym cross-references to the specific term anchors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(docs): stop @ tokens autolinking to GitHub in glossary

Wrap literal @mentioning and @LobeHub in inline code so the docs MDX mention-autolinker no longer turns them into github.com/<name> links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs: remove CoT, Discover, DM, and Hands-Free Mode glossary entries

Also drops the now-orphaned Marketplace synonym stub (it only pointed to Discover) and the empty H letter section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:40:51 +08:00
rdmclin2 f46cc508b5 🗃️ feat(database): add workspace frozen columns
Add frozen / frozen_reason / frozen_at columns to the workspaces table with an idempotent migration. Backs the cloud workspace-freeze risk-control feature; OSS column with no desktop behavior attached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:40:29 +08:00
Hardy 0fd4fd6562 feat(database,userMemories): delete persona document when clearing all memories (#15997) 2026-06-18 14:21:42 +08:00
Arvin Xu 7b932a01d0 ♻️ refactor(model-bank): rename model type stt to standard asr (runtime migration, non-breaking) (#16002)
* ♻️ refactor(model-bank): rename model type `stt` to the standard `asr`

`asr` (Automatic Speech Recognition) is the conventional term for the
speech-to-text model category, matching the modern model-runtime `transcribe`
API and the `asr.transcribe` tRPC procedure. Standardize the model-type
taxonomy on `asr` across the type schema, model data, UI, API contract and
i18n — without a bulk DB data migration.

Application layer (full rename, no behavioral impact — `type` is only a
category label; nothing branches on it at runtime):
- model-bank: `AiModelTypeSchema` enum `stt` → `asr`; `AISTTModelCard` →
  `AIASRModelCard`; `openaiSTTModels` → `openaiASRModels`
- UI: provider ModelList tab/count/pricing-case/create-form options + community
  ModelTypeIcon map
- aihubmix: map the platform `stt` identifier to LobeHub `asr`
- i18n: rename `providerModels.tabs.stt` / `type.options.stt` keys (default +
  en-US + zh-CN); other locales left to the auto-i18n CI

Persistence — runtime migration instead of a SQL migration ("don't fetch, don't
touch"):
- read-time mapping: the aiInfra repository normalizes any residual `stt` row to
  `asr` on read (builtin models already get their type from model-bank)
- write-time heal: `AiModelModel` create/update/toggle/batch persist `asr`, so
  data lazily migrates whenever a row is naturally written; new writes are `asr`
- shared `normalizeAiModelType` helper in model-bank

OpenAPI — non-breaking:
- input still accepts `stt` as a deprecated alias (no 4xx for old clients) and
  normalizes it to `asr`; responses always emit `asr`; type filter matches both
  `asr` and legacy `stt` rows

No `ai_models` data migration is shipped; old rows stay valid and convert lazily.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(cli): sync `lh model` type inputs with the `stt` → `asr` rename

`stt` was removed from `AiModelTypeSchema`, so the CLI paths that still
advertised and forwarded `--type stt` broke for users following the help text:
`lh model create/edit --type stt` was rejected by the router schema and
`lh model list --type stt` returned no ASR models.

Advertise `asr` in the `--type` help for list/create/edit, and normalize the
legacy `stt` alias to `asr` before forwarding to the API / filtering results,
so existing scripts keep working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 12:54:58 +08:00
Arvin Xu 68ef8a1cc6 🐛 fix(agent-documents): harden document explorer tree and skill index titles (#15998)
* 🐛 fix(agent-documents): harden document explorer tree and skill index titles

- Dedupe tree nodes by bare name so a folder/file name clash no longer crashes the panel
- Show & rename the skill bundle title for SKILL.md index docs
- Default the right panel to the Skills tab when an agent has no plain documents
- Add an empty-state placeholder (with reachable toolbar) for the documents tree

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

* 🐛 fix(agent-documents): lock skill index page meta instead of renaming via agentDocumentService

Editing the title on a SKILL.md index page routed through the generic
agent-document rename API for the parent bundle, which rejects managed skill
documents (METHOD_NOT_SUPPORTED) — so the skill name never updated. Worse,
PageEditor's own meta save had already written the title to the index row,
overwriting the SKILL.md filename and desyncing the bundle.

Add a `metaReadOnly` flag to PageEditor (threaded through provider → store →
TitleSection) that locks the title/emoji while keeping the body editable, and
set it for skill index docs. The page now shows the bundle title read-only; the
index row is never clobbered. Renames go through the skill APIs elsewhere.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:19:08 +08:00
LobeHub Bot 5862a3ead8 🌐 chore: translate non-English comments to English in scripts (#16000)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:04:08 +08:00
Arvin Xu 4c6e9bbf27 chore: remove LOBE-10456 marker from google.test.ts regression comment (#15995)
Replace the LOBE-10456 issue reference with a plain descriptive regression
comment explaining the null sentinel enum sanitization behavior.

Co-authored-by: LobeHub Bot <lobehub-bot@lobehub.com>
2026-06-18 11:39:10 +08:00
Arvin Xu acbf969cdc 🔥 refactor(stt): remove deprecated /webapi/stt route and frontend mic entry (#15999)
🔥 refactor(stt): remove deprecated /webapi/stt route and frontend mic entry

The /webapi/stt/openai route relied on the deprecated @lobehub/tts server
helper and createBizOpenAI. Voice input via this path is barely used, and
ASR is now served by the modern `asr.transcribe` TRPC procedure
(model-runtime based, multi-provider, fileId upload). Drop the legacy
speech-to-text UI entry points and the webapi route.

- Remove the ChatInput mic (STT) action and unwire it from all chat inputs
  (Thread, FloatingChatPanel, GroupChat DM)
- Delete the /webapi/stt/openai route and its API_ENDPOINTS.stt constant
- Remove the STT service/auto-stop settings panel and the OpenAI whisper
  STT model option; keep all TTS settings intact
- Drop the now-dead per-agent sttLocale field from AgentTTS

Persisted setting fields (sttServer/sttAutoStop/sttModel/sttLocale) are kept
dormant to avoid a settings-schema migration; can be cleaned up separately.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:38:25 +08:00
Arvin Xu a4a5cc93cb feat(topic): show a "[Draft]" hint on topics holding unsent input (#15996)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:13:15 +08:00
Arvin Xu 3d23ccd63f 💄 fix(linear): render get_issue result as a card instead of raw JSON (#15953)
* 💄 fix(linear): render get_issue result as a card instead of raw JSON

Single-entity Linear endpoints (get_issue, save_issue, …) return the
entity directly, often embedding empty sub-collections like
`documents: []`. The render's `extractResultRecords` matched any
`RESULT_ARRAY_KEYS` key, so an empty `documents: []` was treated as the
result set, yielding zero entities and falling back to the raw-JSON
"Raw result" view. Now only `list_*` endpoints scan nested collections;
single-entity results keep the object as the entity so it renders as a
card. Also dedupe the `status` field that duplicates the state tag and
trim ISO timestamps to a compact form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(linear): don't use UUID id as card title; show full date+time

Comments/attachments have no human title, only a UUID id — the render
promoted that UUID to the big card title (duplicating the id tag below).
Now the title block only renders when a real title exists; the id stays a
secondary tag (linked when a url is present) and the comment body carries
the card. Also format timestamps as `YYYY-MM-DD HH:mm:ss` instead of `HH:mm`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(linear): preserve search result cards via structural unwrap

The previous `verb === 'list'` guard stopped unwrapping search wrappers
like `{ results: [...] }` — and Codex routes Linear search through a bare
`search` apiName that parses to `verb: 'other'`, so a verb allowlist can't
catch it either. Switch to a structural check: unwrap a nested collection
only when the object itself has no identity (no id/title/name/subject), so
list/search wrappers expand into cards while single entities (get/save/
create/fetch-one) keep their embedded sub-collections from hijacking them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:32:07 +08:00
Arvin Xu 09ba6cd69b feat(model-runtime): add ASR (transcribe) with OpenAI + Gemini-native backends, expose via tRPC (#15992)
*  feat(model-runtime): add ASR (transcribe) support and route CLI asr through tRPC

- add `transcribe` method to model-runtime (BaseAI, openai-compatible factory,
  ModelRuntime, RouterRuntime) with ASRPayload / ASROptions / ASRResponse types
- add `asr.transcribe` tRPC procedure that resolves the provider key via
  initModelRuntimeFromDB (server env fallback included) and returns { text }
- rewire CLI `generate asr` to call the new tRPC endpoint instead of
  /webapi/stt/openai, dropping the @lobehub/tts dependency for CLI ASR

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(model-runtime): implement Gemini-native transcribe (ASR via multimodal generateContent)

Google/Gemini has no OpenAI-compatible audio/transcriptions endpoint, so ASR is
done through the native multimodal API: audio is passed inline alongside a text
instruction and the model returns the transcript as plain text.

- add `createGoogleTranscription` (src/providers/google/transcribe.ts): inline
  base64 audio + prompt → `client.models.generateContent` → trimmed text;
  mime resolved from blob type, then fileName/file.name extension, then default
- wire `transcribe` onto LobeGoogleAI with the same error mapping as `chat`
  (abort detection + parseGoogleErrorMessage)
- unit tests for transcript trimming, language hint, mime inference, error mapping

Verified end-to-end against the real Gemini API (gemini-2.5-flash via AiHubMix):
transcribed the same .m4a correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(model-runtime): use Gemini Files API for large audio transcribe

Inline audio is capped at Gemini's ~20MB request limit. Audio over ~14MB raw
(base64 inflates ~4/3) now uploads via the Files API, polls until the file is
ACTIVE, then references it by URI; smaller audio stays inline as before.

- add `uploadAudioFile` (files.upload → poll files.get until ACTIVE →
  createPartFromUri) with timeout + abort handling
- unit tests for the Files API path and the PROCESSING→ACTIVE poll loop

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(server): support fileId in asr.transcribe and document the base64 transport

`asr.transcribe` now accepts either an already-uploaded `fileId` (preferred — the
server streams bytes from storage via FileModel + FileService.getFileByteArray,
ownership enforced by the userId-scoped model) or inline base64, validated as
mutually exclusive. Avoids inlining large audio through tRPC entirely.

Keep base64 (not a raw Buffer) for the inline path: the tRPC httpLink uses
superjson/JSON, which has no binary representation for Buffer/Uint8Array — a raw
buffer would serialize to a per-byte JSON object, worse than base64.

- add unit tests: base64 path, fileId→S3 download, XOR validation, NOT_FOUND,
  NoSuchKey handling

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(server): cap inline base64 audio in asr.transcribe, guide large files to fileId

Inline base64 is only for short clips: the whole tRPC request must fit inside the
platform body limit (~4.5MB on serverless) and base64 inflates bytes by ~4/3, so
cap the decoded audio at 3MB. Oversized payloads are rejected (validated on the
base64 string length, before decoding) with a message pointing to `fileId`.

- add unit test for the oversized-inline rejection

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(cli): upload large audio for `generate asr` instead of inlining base64

Audio over 3MB (matching the server inline cap) is now uploaded via the shared
uploadLocalFile flow and transcribed by `fileId`, so large files never travel
inline through tRPC; smaller clips still go inline as base64. URLs are buffered
to a temp file first so they reuse the same path, and the upload progress note is
written to stderr to keep the transcript on stdout clean.

- add common audio mime types (m4a/wav/ogg/flac/aac/webm) to uploadLocalFile
- test the large-file → fileId path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:27:31 +08:00
YuTengjing 04b8d214b9 🐛 fix: cache task template recommendations (#15993) 2026-06-18 03:51:28 +08:00
Arvin Xu 7624ce635e feat(agent): pass audio (mp3) attachments to audio-capable models like Gemini (#15988)
*  feat(agent): pass audio (mp3) attachments to audio-capable models like Gemini

Audio files were classified into the generic `fileList` and only injected as
text via `filesPrompts()`, so Gemini (and any audio-capable model) never
received the actual audio in either server or client runtime. This adds a
first-class audio attachment path mirroring the existing video path:

- types: `ChatAudioItem`, `UIChatMessage.audioList`, `audio_url` content part,
  `ModelAbilities.audio`, `VisualFileType` audio
- resolution: classify `audio/*` into `audioList` in the server resolver,
  `ingestAttachment`, and the database message model (both query paths)
- context-engine: `processAudioList()` emits `audio_url` parts gated on a new
  `isCanUseAudio` capability; `filesPrompts()` / token accounting include audio
- model-runtime: Gemini `buildGooglePart()` handles `audio_url`
  (inlineData / external fileData), defaulting mime to audio/mp3
- capability plumbing: `isCanUseAudio` wired through client + server engines
  and `RuntimeExecutors`
- model-bank: enable `audio` on multimodal Gemini chat models

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(agent): hand off audio URLs as fileData and estimate audio tokens

Address two review findings on the audio attachment path:

- uriParser: allow audio MIME types (aac/aiff/flac/mp3/ogg/wav) in the Gemini
  external-URL whitelist and normalize audio/mpeg|mpg → audio/mp3, so public
  audio URLs are handed off as fileData instead of being downloaded and inlined
  via imageUrlToBase64 (which can time out / exhaust memory on large files)
- attachmentTokenBuckets: add a dedicated `audioTokens` bucket gated by
  `canUseAudio`, mirroring image/video, so audio-capable requests aren't
  severely under-estimated at token/cost preflight

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🍱 chore(model-bank): mark audio ability on Vertex AI & AiHubMix Gemini 3 models

The first commit only backfilled `audio: true` on the first-party `google`
provider cards. Mirror the existing `video: true` placement on the other
providers that expose the same Gemini 3 chat models so audio input also works
when routed through Vertex AI (first-party) and AiHubMix.

Image-generation variants (no `video`) and the conservative vision-only proxies
(githubCopilot / opencodeZen / ollamacloud) are intentionally left unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 feat(agent): render uploaded audio attachments in user messages

Audio uploads had no UI render in the chat bubble (unlike images/videos/files).
Add an AudioFileListViewer (native <audio controls> player, mirroring the video
viewer) and wire it into the user MessageContent so uploaded audio shows an
inline player.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 feat(agent): show audio ability badge and allow audio upload for capable models

Two gaps surfaced after enabling the `audio` ability:

- The model picker rendered no audio capability icon (list rows + detail panel),
  even though model-bank correctly carries `audio: true` and audio pricing. Add
  an audio badge (AudioLines icon) to ModelSelect FeatureTags and the
  ModelDetailPanel ability config, with i18n labels.
- Conversation-mode upload hard-blocked all audio files. Allow audio in the
  upload guard (mirroring video, with an extension fallback for .m4a etc.) and
  gate it by a new `canUploadAudio` ability (isModelSupportAudio selector +
  useModelSupportAudio hook) across the Upload / Plus action bars and the
  drag-upload zone, so audio-capable models (e.g. Gemini 3) accept audio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 02:48:44 +08:00
Arvin Xu dc8f9d79b4 🔧 chore(memory-user-memory): expose prompts subpath exports (#15985)
* 🔧 chore(memory-user-memory): expose prompts/providers/converters subpath exports

The package only exported `.`, `./schemas` and `./types`, so consumers importing
`@lobechat/memory-user-memory/prompts`, `/providers` or `/converters/*` (e.g. the
agent-eval memory scenarios) failed to resolve under strict node_modules. Add the
matching subpath export entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Update package.json

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 02:28:55 +08:00
Arvin Xu 95dc8e00b0 🐛 fix(agent): centralize inbox agent meta fallback across read paths (#15948)
* 🐛 fix(agent): centralize inbox agent meta fallback across read paths

Extract inbox title/avatar fallback into a shared `inboxAgent` util and
apply it consistently wherever agents are read (models, repositories,
openapi service, topic router) so the inbox agent always resolves to its
default title and avatar when the DB row is blank.

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

* ♻️ refactor(agent): push agent reads into the model layer instead of router-level fallback

Routers/services were querying the `agents` table directly and re-applying the
inbox meta fallback on top — a layering leak. Move those reads down so the
normalization happens once, where the data is owned:

- topic.recentTopics: use `AgentModel.getAgentAvatarsByIds` +
  `ChatGroupModel.getMemberAvatarsByGroupIds` instead of inline `.from(agents)`
- add `ChatGroupModel.getMemberAvatarsByGroupIds`, dedupe the duplicated
  member-avatar query that also lived in `HomeRepository`
- add `AgentModel.listMessengerBindableAgents` (virtual-or-inbox filter,
  `updatedAt DESC`, inbox pinned + normalized) and use it from both
  `messenger.listAgentsForBinding` and `MessengerRouter.fetchUserAgents`,
  unifying the inbox title fallback on `DEFAULT_INBOX_TITLE` (was a stray
  hardcoded `'LobeAI'`)

The `inboxAgent` util is now confined to `packages/database`; no router/service
re-patches inbox meta anymore.

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

* ♻️ refactor(agent): construct agent models via ctx instead of in-handler

Follow the router convention of building models in the procedure ctx
middleware rather than ad-hoc inside handlers:

- topic: move `agentModel` / `chatGroupModel` into `topicProcedure` ctx
  (ambient `wsId` scope)
- messenger: the bindable-agents scope is input-driven (the scope picker
  passes the workspace), and the procedure is identity-scoped, so expose a
  workspace-parameterized `getAgentModel` factory on ctx and call it in
  `listAgentsForBinding`

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

* ♻️ refactor(agent): drop dead inbox normalization from openapi agent service

The base commit's blanket sweep added `normalizeInboxAgentMeta` to the
openapi `AgentService`, but it doesn't belong there:

- `queryAgents` filters `virtual=false OR NULL` with no `OR slug=inbox`
  escape hatch, and the inbox agent is `virtual=true`, so it's excluded by
  design — the normalize never sees an inbox row (dead code).
- `updateAgent` only normalizes when updating the inbox via the public REST
  CRUD, which shouldn't manage that virtual/system agent.

Remove both calls (restoring pre-branch behavior) and the util import; this
surface lists/edits real agents only.

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

* ♻️ refactor(agent): own messenger bindable-agent title fallback in the model

- return `isInbox` from `AgentModel.listMessengerBindableAgents` so callers no
  longer recompute it from `slug`
- move the blank-title fallback into the model via an optional `fallbackTitle`
  option; `MessengerRouter.fetchUserAgents` passes "Custom Agent" (text-only
  channel) while `messenger.listAgentsForBinding` omits it and keeps null so the
  web picker applies its own i18n default
- `messenger.listAgentsForBinding` now returns the model rows directly
- revert openapi `agent.service.ts` to canary: the inbox is virtual and excluded
  from that surface, so its earlier normalization was dead code

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 02:18:28 +08:00
Arvin Xu 7f45a8d730 🐛 fix(agent-documents): present documents as clickable links, centralize result strings (#15989)
* 🐛 fix(agent-documents): present created document as a clickable link, not a raw id

When createDocument succeeded, the agent surfaced the internal document id to the
user instead of the clickable URL. Two causes: the tool system prompt explicitly
told the model to "Include key identifiers (document ID/title)", and the tool
result string buried the URL in parentheses while emphasizing "Use id <id> for
further edits". The raw id is not actionable for users and didn't even match the
URL path segment.

- systemRole: instruct presenting the document URL as a markdown link and never
  exposing the internal id to the user
- ExecutionRuntime: reword the create result so the URL is unambiguously the
  user-facing link and the id is clearly internal-only

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(agent-documents): centralize result strings in @lobechat/prompts and surface url everywhere

Two follow-ups to the create-document link fix:

1. Every document-referencing tool result now carries the shareable URL, not
   just createDocument. replace/modify/rename/copy/updateLoadRule build the url
   via a shared buildDocumentUrl helper, and listDocuments includes a per-item
   url so the agent can link any existing document. removeDocument stays id-only
   (the document is gone); readDocument returns raw content untouched.

2. The result content strings are no longer hand-written at each return site.
   They live in @lobechat/prompts (prompts/agentDocuments/formatResults), mirroring
   the existing fileSystem formatters, with a single link/id policy: when a url
   exists the agent relays a clickable markdown link and keeps the internal id to
   itself; otherwise the id is shown as the only handle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(agent-documents): assert full result strings with toBe instead of toContain

Exact-equality assertions make the formatter output verifiable at a glance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:41:12 +08:00
AmAzing- ba2660c8c9 ♻️ refactor: remove client-side community publish/unpublish entry points (#15986)
Publishing to the community is approval-gated server-side — manual
publish/unpublish from the client was rejected with `forbidden`, so the
buttons, modals and supporting code were effectively dead. Remove the
client-side publish flow for agents and groups, keeping only `deprecate`.

- Drop the publish menu item + modals from the agent profile header
  (ForkConfirmModal, PublishResultModal, PublishConfirmModal, useMarketPublish)
- Remove GroupPublishButton and its modals from the group profile
- Drop the publish/unpublish actions from the community user agent/group
  cards, keeping the deprecate action
- Remove useCommunityPublishGuard / useAgentOwnershipCheck hooks and the
  canCurrentAgentPublishToCommunity selector
- Drop the 'publish' MarketAuth scene and publish/submit auth copy
- Remove MarketApiService publish/unpublish methods, the stale
  setAccessToken no-op, and MARKET_ENDPOINTS
- Remove the now-unused marketPublish / submit / publish i18n keys
2026-06-18 01:27:38 +08:00
Arvin Xu 2fdebb9f4e feat(cli): support remote audio URLs in generate asr (#15987)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:55:13 +08:00
Arvin Xu ebb3e53769 chore: clean up LOBE-XXX code comment markers (2026-06-17) (#15943)
chore: remove LOBE-XXX comment markers

Replace all LOBE-XXX (LOBE-10443, LOBE-10445, LOBE-10470, LOBE-10480)
comment references with descriptive inline context:

- LOBE-10443 (multi-device resume race fix): replaced with descriptions of
  the authoritative resume_complete mechanism
- LOBE-10445 (dual-form message chain reader): replaced with inline
  explanations of the reader invariant
- LOBE-10470 (agent write lock acquire): replaced TEMP DIAGNOSTIC comments
  with plain diagnostic descriptions
- LOBE-10480 (session-scoped lease lock): replaced with inlined description
  of the session-aware lock refactor

10 files changed, 18 insertions(+), 18 deletions(-)
2026-06-18 00:14:40 +08:00
YuTengjing 4595f86e41 feat: use Market task template recommendations (#15558) 2026-06-17 23:07:21 +08:00
Rdmclin2 cdcd57970b feat: add use switch workspace hook (#15979)
* feat: add use switch workspace hook

* fix: use workspace comment and test case
2026-06-17 23:01:04 +08:00
AmAzing- 85e2d3c1eb 🐛 fix: prevent blank screen when switching language (#15977)
* 🐛 fix: prevent blank screen when switching language

* 🐛 fix: block cache hydration during scope changes
2026-06-17 22:49:08 +08:00
Arvin Xu a3faf7b1aa 🐛 fix(agent-runtime): load snapshot store via static import, not dynamic require (#15982)
#15841 extracted the snapshot store creation behind a `loadModule(moduleName)`
indirection. The bundler can't statically analyze the indirected `require()`, so
the `@/server/modules/AgentTracing` build-time alias fails to resolve at runtime
(`MODULE_NOT_FOUND`), the `catch {}` swallows it, and `createDefaultSnapshotStore`
returns `null`. Since v2.2.5 this silently disabled ALL production agent trace
snapshots: `CompletionLifecycle` still writes `trace_s3_key` to the DB, but no
partial/final object is ever uploaded to S3 — `agent-tracing inspect` 404s on
every operation.

- statically import `S3SnapshotStore` / `FileSnapshotStore` and `new` them
- keep testability via injected constructor factories, not a module loader
- surface S3 construction failures with `console.error` instead of a silent
  `catch {}` (best-effort: still degrades to null, never breaks the run)
- test the real (non-injected) default path so the regression can't return

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:25:13 +08:00
YuTengjing 9401e6eeac 🐛 fix(runtime): expose missing usage diagnostics (#15973) 2026-06-17 22:00:56 +08:00
Arvin Xu 5e2fdeb342 🐛 fix(agent): stop chat mode from auto-injecting local-system tools (#15981)
* 🐛 fix(agent): stop chat mode from auto-injecting local-system tools

Chat mode and the device track are orthogonal: the Chat/Agent UI toggle
only writes `chatConfig.enableAgentMode` and never touches
`agencyConfig.executionTarget`. A default/stored `local` target therefore
still resolved an `activeDeviceId`, and `buildStepToolDelta` re-injects
`LocalSystemManifest` whenever an active device is set — bypassing the
rule-layer whitelist that chat mode relies on.

Resolve the device decision with chat mode in mind: `resolveExecutionPlan`
now accepts `isChatMode` and degrades the whole plan to `none` (target
included, so the `target === 'local'` manifest-injection path is closed
too). The client `resolveLocalDeviceId` also skips the deviceId round-trip
in chat mode; the server remains authoritative since it auto-activates a
single online device regardless of what the client sends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(agent): centralize tool-mode derivation in resolveToolMode

Move the chat-mode judgment inside resolveExecutionPlan instead of
computing isChatMode at the call site. Add a shared resolveToolMode helper
(toolMode wins; else enableAgentMode) used by resolveExecutionPlan, the
server tools engine, and the client chatConfig selector — one source of
truth for what counts as chat mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(agent): mock isChatModeById in gateway action tests

resolveLocalDeviceId now calls chatConfigByIdSelectors.isChatModeById, but
the gateway test mock only stubbed getChatConfigById / isLocalSystemEnabledById,
so the desktop local-device cases threw "isChatModeById is not a function".
Add the selector to the mock (default false) plus a chat-mode regression
case asserting no deviceId is resolved on a local target in chat mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:58:25 +08:00
René Wang 1d7fc18cdb 🐛 fix: Interface copy consistency for desktop app (#15968)
* 🐛 fix(topic): correct "Copy Session ID" label to "Copy Topic ID"

The topic item dropdown action copies the topic id
(navigator.clipboard.writeText(id) — the same id the surrounding menu
passes to openTopicInNewWindow and SESSION_CHAT_TOPIC_URL), but it was
labeled "Copy Session ID" / "Session ID copied". Correct the English
source strings to "Copy Topic ID" / "Topic ID copied".

English source locale only; the i18n key actions.copySessionId is kept,
and the other locales regenerate from en-US.

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

* 🐛 fix(i18n): correct legacy "session" wording in English UI strings

Follow-up to #15942. Several English strings used "session" where the
code shows the referent is the live conversation, the agent, or where the
word is redundant. "session" is also legitimate elsewhere (auth/sign-in
session, AWS Session Token, Claude Code runtime session, natural English),
and many sessionGroup/defaultSession keys already have correct values, so
only code-verified mislabels are changed:

- chat: "Clear current session messages" / "…clear the current session
  messages…" / "Save current session as topic" → "conversation"
  (the Clear and Save-Topic actions operate on the current conversation)
- setting: "All session messages have been cleared" → "conversation";
  "…display in the session." → "…in conversations."
- setting: "Session Settings" (per-agent page, sibling of "Group
  Settings") → "Agent Settings" / "· {{name}}" / "Agent Profile and chat
  preferences"
- setting: workspace-delete warnings drop the redundant legacy "sessions"
  (agents have their own bucket; topics already listed)

English source locale only; i18n keys unchanged, other locales regenerate
from en-US.

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

* 🐛 fix(i18n): align "Discover Assistants" nav with "Agent" terminology

The desktop nav read "Discover Assistants" while the entire discover
feature uses "Agent" (Community Agents, Featured Agents, Add Agent,
Agent List…). Rename the lone outlier to "Discover Agents".

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

* 🐛 fix(cmdk): merge Image/AI Video into one "Generation" entry

The command palette's Navigate group listed separate "Image" (/image)
and "AI Video" (/video) entries, but the app sidebar combines both into
a single "Generation" item (useNavLayout's bottomMenuItems →
tab.generation → /image). Mirror that in getNavigableRoutes — its only
consumer is the CMDK navigate list — by dropping the separate video
entry and relabeling image to "Generation", merging both keyword sets so
image and video terms still match. Reuses the existing tab.generation key
so the label stays in sync with the sidebar across every locale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(i18n): apply terminology fixes to the default .ts source of truth

The earlier commits changed only locales/en-US/*.json, but those files are
generated from packages/locales/src/default/*.ts via the workflow:i18n step
(.i18nrc.js entry = locales/en-US), so a regeneration would revert them.
Apply the same English-source edits to the .ts defaults so the source of
truth and the en-US mirror agree:

- topic: copySessionId / copySessionIdSuccess → "Copy Topic ID" / "Topic ID copied"
- chat: clearCurrentMessages / confirmClearCurrentMessages / topic.saveCurrentMessages → "conversation"
- setting: danger.clear.success / llm.modelList.desc → "conversation(s)";
  header.session(+WithName/Desc) → "Agent Settings" / "chat preferences";
  workspace-delete strings drop the redundant legacy "sessions"
- electron: navigation.discoverAssistants → "Discover Agents"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(i18n): unify stray terminology (Focus Mode / Agent / Skill / Library)

Continue the English copy-consistency cleanup, aligning straggler strings
to the terms the app already standardizes on (zh-CN already uses these):

- zenMode label "Zen Mode" → "Focus Mode" (the toast; settings/hotkey
  already say Focus Mode, and zh-CN is 专注模式 everywhere)
- "assistant" → "agent": agent channel descriptions (×9), topic move
  actions (×4), labs agent self-iteration
- "Plugin" → "Skill": lobe-agent-management install labels, discover
  user.plugins / user.noPlugins
- "knowledge base" → "Library": file library-import strings, chat agent
  profile counts + search-library tool label
- "service provider" → "provider": new-provider id description

English source (.ts) + en-US mirror; canonical terms already match zh-CN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(i18n): apply confirmed naming decisions (Orchestrator / Group / Clear)

Resolve the remaining English terminology forks per product decision:
- group coordinator role → "Orchestrator" (was Supervisor / Group Host /
  "host" / "moderator"): chat profile + token labels; setting group-chat
  system-prompt strings and enable-orchestrator description
- cmdk search → "Group" / "Groups" (was "Agent Team(s)")
- data-wipe titles → "Clear Data" / "Clear Workspace Data" (was "Wipe…")
- model ability → "Tool Calling" / "tool calls" (was "Tool Usage" /
  "function calls")
- topic import → "Import Topics" / "Importing topics…" (match Export Topics)

English source (.ts) + en-US mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(i18n): standardize auth on "Sign in" + unify conversation wording

Per product decision (match the /signin route path):
- auth / authError / oauth / error / marketAuth / desktop-onboarding:
  "Log in" / "Login" / "Log Out" → "Sign in" / "Sign-in" / "Sign Out"
  across the auth flow (button, errors, guide titles, OAuth consent,
  unlock screen, session-expiry prompts)
- common "SSO Login" → "SSO Sign-in"; discover user menu "Logout" → "Sign out"
- memory analysis modal: unify "chats" / "topics" → "conversations"

English source (.ts) + en-US mirror (regenerated via workflow:i18n).
External-tool logins (GitHub CLI, WeChat) intentionally kept; non-English
locales left to the i18n CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔥 chore(zen-mode): remove the Zen/Focus Mode feature

Zen Mode (hide all chrome to show only the current conversation) is no
longer needed. Remove it end to end:

- delete src/features/ZenModeToast
- drop status.zenMode state, the toggleZenMode action, and the inZenMode /
  showChatHeader selectors (the chat header now always renders)
- remove the ToggleZenMode hotkey (chat-scope hook, const registration,
  HotkeyId type)
- un-mask the panel-visibility selectors (drop the !zenMode guard)
- remove the zenMode / toggleZenMode locale keys (chat, hotkey)
- update the affected store/hook tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: add translation

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:53:23 +08:00
AmAzing- 2c66867f65 🐛 fix(edit-lock): allow same-user generic updates (#15969)
* 🐛 fix(edit-lock): allow same-user generic updates

* 🧪 fix(e2e): mock community marketplace APIs

* 💬 docs(e2e): explain community marketplace mocks

* 🧪 fix(e2e): preserve mixed community trpc batches

* 🧪 fix(e2e): unwrap single batched community input
2026-06-17 17:42:17 +08:00
YuTengjing 8718e1d33f 🐛 fix: reject duplicate custom model ids (#15975) 2026-06-17 17:05:27 +08:00
YuTengjing 7bc47071c4 ♻️ refactor: remove Fable campaign paths (#15960) 2026-06-17 16:58:13 +08:00
YuTengjing c66b1fc8fe feat(model): support GLM-5.2 reasoning effort (#15972) 2026-06-17 16:47:24 +08:00
Innei 46439bbd16 feat(spa): bootstrap app initialization (#15937)
*  feat(spa): bootstrap app initialization

* ♻️ refactor: trim bootstrap registration diff

* 🐛 fix: resolve app layer loading helper import
2026-06-17 16:07:52 +08:00
Arvin Xu 25387ada92 🐛 fix(model-runtime): strip null/empty members from enum for Gemini (#15952)
* 🐛 fix(model-runtime): strip null/empty members from enum for Gemini

The memory tool's `updateIdentityMemory.set.memoryType` enum carried a
trailing `null` sentinel (`[...MEMORY_TYPES, null]`). Gemini's schema proto
only accepts STRING enum members, so the `null` was coerced to `""` and
rejected with `enum[10]: cannot be empty`. OpenAI/Anthropic don't validate
enum members, so it only broke on Gemini.

Two layers:
- Source: drop the `null` sentinel from the memoryType enum; nullability is
  already expressed via `type: ['string', 'null']`.
- Conversion fallback: sanitizeGeminiSchema now filters null/non-string/empty
  enum members (and strips the enum entirely if none remain), so any other
  tool that does the same won't break Gemini.

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

*  test(model-runtime): align Gemini enum tests with null-member stripping

The sanitizeGeminiSchema fix now filters null/non-string members out of enums,
so the two "preserve null enum" assertions no longer match. Update them to
expect the null sentinel stripped while nullability stays in type: ['string', 'null'].

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:52:32 +08:00
Rdmclin2 da3412e202 🔨 feat: support workspace mem (#15971)
feat: support workspace mem
2026-06-17 15:23:14 +08:00
Arvin Xu 7ea84a2695 🐛 fix(server): guard device file mutations against unapproved workspace roots (#15967)
File routes (preview / move / rename / write) received `workingDirectory`
from the same untrusted browser session that supplies the file paths. The
gateway's containment check only proves paths sit inside that directory, not
that the directory itself is legitimate — so `workingDirectory: "/"` passed
trivially and reached any path on the device.

Re-derive approved roots from the server-owned device row (`workingDirs` +
`defaultCwd`) and require the requested root to equal or nest inside one. The
check is built into a shared `workspaceFileProcedure` so every file-mutating
route inherits it and can never forget it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:22:10 +08:00
René Wang 211e8c1f54 feat(changelog): collapse Improvements & Fixes sections by default (#15916)
The changelog modal renders each entry's full body, so the long
"Improvements" and "Fixes" sections take a lot of vertical space. A remark
plugin now wraps those two standardized headings (and their content) in a
collapsible section that renders collapsed by default; "Features" stays open.

- remarkCollapsibleSections: emits a <collapsible-section> element (via
  data.hName) for exact "Improvements"/"Fixes" headings — the react-markdown
  equivalent of an MDX component
- CollapsibleSection: @lobehub/ui Collapse, collapsed by default
- CustomMDX: accept and merge caller-supplied remarkPlugins
- ChangelogContent: opt in to the plugin + map the custom element

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:16:28 +08:00
Arvin Xu 0ef1309b68 🔖 chore(cli): bump @lobehub/cli to 0.0.31 (#15964)
* 🔖 chore(cli): bump @lobehub/cli to 0.0.32

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

* update

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:45:55 +08:00
Arvin Xu 73907480d7 💄 style(chat): rename Agent Gateway menu label and split card title (#15963)
Hardcode "Agent Gateway" as the menu toggle label (brand name, identical
in every language, so no i18n). The info popover keeps a dedicated
cardTitle ("Agent Gateway Mode" / "Agent Gateway 模式").

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:42:08 +08:00
Arvin Xu a38437c1da feat(cli): upload local files via file upload & shared helper (#15949)
*  feat(cli): support uploading local files via `file upload` and shared helper

Add `lh file upload [source]` support for local paths (positional or `-f/--file`)
alongside the existing URL mode, and extract the local-upload logic shared with
`kb upload` into a reusable `uploadLocalFile` helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ️ perf(cli): skip S3 upload when local file hash already exists

`uploadLocalFile` now calls `file.checkFileHash` before the S3 PUT and, when
the same bytes are already stored (and the object still exists), reuses the
existing url instead of re-uploading. Mirrors the dedup short-circuit of the
URL upload mode. Benefits both `file upload` and `kb upload`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:08:31 +08:00
Arvin Xu de207a65c2 🐛 fix(search): drop citations with empty url to avoid Invalid URL crash (#15954)
OpenRouter's built-in web search (and other OpenAI-compatible providers) may
emit empty citation objects like `{}`. These propagated unfiltered through the
OpenAI grounding stream branches, then crashed both the renderer
(`new URL(undefined)` in SearchGrounding) and message persistence (Zod requires
`url` to be a string in GroundingSearchSchema).

- model-runtime: add a shared `filterValidCitations` helper and apply it to all
  OpenAI grounding branches (url_citation / messages / xAI / XiaomiMiMo), plus
  reuse it for the existing Perplexity/Zhipu filter. Guard `url_citation?.*` too.
- SearchGrounding: filter out citations without a url and parse the favicon host
  defensively so a malformed entry can never crash the render.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:21:27 +08:00
lobehubbot 8ec55f5941 🔖 chore(release): release version v2.2.6 [skip ci] 2026-06-17 03:16:17 +00:00
Arvin Xu 63f13c2a31 🚀 release: 20260617 (#15947)
# 🚀 LobeHub Release (20260617)

**Release Date:** June 17, 2026  
**Since v2.2.5:** 42 commits · 42 merged PRs · 8 contributors

> This weekly release deepens server-side agent orchestration, brings
desktop file and worktree capabilities to the web through device RPC,
and smooths out the everyday rough edges — cold-start boot, connector
credential safety, and chat refresh feedback.

---

##  Highlights

- **Server-side Group Orchestration** — Agents can now call other agent
members server-side, enabling multi-agent collaboration without a
desktop in the loop. (#15870)
- **Desktop File Ops on the Web** — Project file operations and `git
worktree` listing now replicate from desktop to web via device RPC, so
cloud sessions can read and act on local working directories. (#15885,
#15889)
- **Fleet Running-Tasks Dashboard** — A lab-gated dashboard for
in-flight Fleet tasks, with running topics re-synced each time the
Observation tab opens. (#15817, #15922)
- **callAgent as a Deferred Tool** — The agent loop now runs `callAgent`
as a deferred tool, giving cleaner sub-agent invocation and tool-chain
handling. (#15765)
- **Connector Credential Safety** — Editing a connector no longer risks
silently wiping saved credentials; they are restored in edit mode and
preserved on save. (#15909)
- **Smoother Cold Start** — Boot now shows one continuous loading screen
instead of a brand-logo flash on cold start. (#15926)

---

## 🏗️ Core Agent & Architecture

- Improved connector, document, and Fleet agent workflows. (#15936)
- Scoped the agent conversation subtree to an explicit `agentId` for
clearer multi-agent boundaries. (#15866)
- Added a role-aware dual-form message-chain reader to the conversation
flow. (#15908)
- Anchored the server-side main chain to a run's real last tool in
heterogeneous agents. (#15883)
- Drove resume completion off the authoritative Durable Object status in
the gateway client. (#15919)
- Corrected target `agentId` and refreshed the sidebar in gateway mode
for the agent builder. (#15888)
- Forwarded model extend params on the server-side agent runtime.
(#15891)
- Preserved preference-memory receipt routing in agent signals. (#15892)
- Filtered the `.tool-results` archive out of document lists by default.
(#15935)
- Optimized the agent document list query. (#15904)

---

## 📱 Devices & Platforms

- Locked a run to the explicitly selected device, never offering
device-switching mid-run. (#15914)
- Exposed `deviceRouter` on the mobile router. (#15925)
- Opened a new Home tab from the desktop tab bar "+" button. (#15825)
- Added support for approved external local file previews on desktop.
(#15895)
- Removed web onboarding aliases from the desktop build. (#15902)
- Consolidated auth SPA loading. (#15903)

---

## 🖥️ User Experience

- Fixed assorted workspace problems and clarified workspace copy/move
actions. (#15928, #15897)
- Showed a cached message-refresh hint with breathing room around it.
(#15901, #15906)
- Added an unread-reply indicator on collapsed project groups. (#15915)
- Anchored the sidebar spacer immediately after the accordion block.
(#15871)
- Capped nested thread-list height with scroll overflow. (#15861)
- Removed the ParamsPanelToggle control icon from the chat header.
(#15860)
- Defaulted the React Scan scanner UI to off. (#15934)
- Refined top-up best-value and referral reward-rules copy. (#15924,
#15923)
- Only enforced the chat upload file-type whitelist in chat mode.
(#15884)

---

## 🔒 Reliability

- Deduped unread-count polling to reduce redundant requests. (#15881)
- Aligned dayjs locale imports. (#15896)

---

## 👥 Contributors

Huge thanks to the **8 contributors** who shipped **42 merged PRs**
across **42 commits** this cycle.

@arvinxx · @Innei · @tjx666 · @LiJian · @AmAzing129 · @sudongyuer ·
@rivertwilight · @Rdmclin2

---

**Full Changelog**:
https://github.com/lobehub/lobehub/compare/v2.2.5...release/weekly-20260617
2026-06-17 11:10:44 +08:00
Arvin Xu 3f82033939 feat(agent): improve connector, document, and fleet workflows (#15936)
*  feat(agent): allow removing unauthorized connectors from the auth alert

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

* 💄 style(chat): add Beta tag and info popover to Gateway Mode toggle

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

* 💄 style(fleet): render OpStatusTray seamlessly when no reply panel is attached

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

* 🐛 fix(fleet): show skeleton rows while the running-task sidebar loads

The sidebar fell straight through to the "no running tasks" empty state
during the initial fetch, so a brief flash of "empty" hid tasks that were
actually loading. Thread the SWR loading flag in and render placeholder
rows until the first result lands.

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

* 🐛 fix(fleet): open existing agents from the board picker & fix reply-tray chrome

- AddColumnButton: selecting an agent now opens its main conversation
  (topicId null) instead of minting a throwaway empty topic via an async
  createTopic that could silently fail; dedupes + scrolls to an already-open
  column. Matches "open this agent" elsewhere in the app.
- AgentColumn: stop double-rendering OpStatusTray while the reply panel is open
  (ChatInput owns its own overlay tray); lift the collapse button above that
  floating tray so it no longer cuts the tray's top border; give ChatList its
  own flex region so the seamless tray isn't squeezed/clipped.
- OpStatusTray: keep a hairline top divider in seamless mode so the running tray
  still reads as separated from the conversation above.
- RunningTaskSidebar / AddColumnButton: harden scroll-into-view with double rAF
  so the (re-)added column reliably scrolls into view after it commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(fleet): add one-click close-all-idle-columns action

Adds a `removeColumns` batch store action and wires the running-board
header button (committed earlier in e47228c6f7) to it, so users can clear
every non-running column in one click. Idle is derived from the board's
own columns against the live running set; running columns are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  add agent document share URLs

*  add standalone agent document page

*  open agent documents as pages

* 💄 style(chat): polish Agent Gateway mode popover

* 🐛 fix(fleet): stabilize reply area and panel collapse state

* 🐛 fix: restore agent document portal opening

* 💄 style: adjust agent document header actions

* 🐛 fix: handle workspace document links and fleet idle state

* 🐛 fix(portal): import WideScreenContainer in document Body

The full-page document view rendered <WideScreenContainer> without
importing it, breaking type-check (TS2304: Cannot find name).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:45:27 +08:00
Arvin Xu 95db11309e 🐛 fix(agent-documents): filter .tool-results archive from document lists by default (#15935)
* 🐛 fix(agent-documents): filter `.tool-results` archive from document lists by default

The auto-created `.tool-results` archive folder leaked into the user-facing
documents panel because `listDocuments` / `listDocumentsForTopic` did not apply
the `excludeArchivedToolResults` filter that other read paths already use.

Make the service filter the archive by default, and let the agent
document-listing tool (server + client runtimes) explicitly opt back in via
`includeArchivedToolResults`, preserving agent archive discovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(agent-documents): hide archived tool result in current-topic lists

The `.tool-results` archive folder is created by mkdir but only the archived
file is associated with the topic (see archiveToolResultIfNeeded), so the
folder row never appears in the current-topic list. `excludeArchivedToolResults`
derived the archive folder id from the list alone, leaving the set empty and
leaking the archived `.txt` into `scope: 'currentTopic'` results.

Look the `.tool-results` root folder up directly in listDocumentsForTopic and
pass its id into the filter via the new explicit `archiveFolderIds` argument.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(agent-documents): assert runtime opts into archived tool results

The server runtime now calls listDocuments with
`{ includeArchivedToolResults: true }`; update the expectation accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 03:09:25 +08:00
Innei 6e0cd5f299 🐛 fix(react-scan): default scanner UI to off (#15934) 2026-06-17 01:28:23 +08:00
Arvin Xu 91d684878f feat(project-file): replicate desktop file operations to web via device RPC (#15885)
*  feat(project-file): replicate desktop file operations to web via device RPC

Project file tree operations only ran over Electron IPC, so remote/web
devices could browse files but not move, rename, or edit them. This wires
move/rename/write through the device RPC the same way getProjectFileIndex
already does, reusing the host-agnostic @lobechat/local-file-shell impls.

- device-control: whitelist moveLocalFiles/renameLocalFile/writeLocalFile + dispatch cases
- deviceGateway: moveProjectFiles/renameProjectFile/writeProjectFile (mutations throw on failure, no silent degrade)
- device router: matching device.* procedures
- projectFileService: deviceId-aware chokepoint methods (IPC locally vs RPC remotely)
- saveLocalFile now routes through projectFileService; remote LocalFile editor is no longer read-only

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(provider): rename ClientMode to CustomProviderDetail

ClientMode is a leftover from the old client-mode (IndexedDB) vs
server-mode (Postgres) DB split; there is no ServerMode counterpart and
the name no longer reflects what the component does — it renders the
detail view for a custom (user-created) provider, fetched by id. Rename
it to CustomProviderDetail and update the debugId + import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔒 fix(project-file): contain remote file mutations to the workspace root

Guard the move/rename/write device RPCs against paths escaping the project
root: these routes accept absolute paths from an untrusted browser session,
so the gateway now confirms every path stays within the working directory
(Windows-aware) before forwarding to a device. Thread `workingDirectory`
through the service and tRPC layers.

Also scope edit buffers by tab identity (device + working directory + path)
instead of bare file path, so the same path opened on two devices/workspaces
keeps independent unsaved content, and surface write failures so a failed
save keeps the buffer dirty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 01:09:05 +08:00
Rdmclin2 2d897cea73 🐛 fix: workspace related problems (#15928)
* fix: page editor right panel loading

* feat: add page agent aquire lock

* feat: add hook self locker

* feat: add page copilot lock

* fix: session cope lock

* feat: add page draft and editor state fresh

* fix: multiple same person edit lock and recover

* fix: edit same tab

* chore: update i18n files

* fix: lint error

* fix: edit lock owner check

* chore: reuse owner id

* chore: viewer cannot see right panel
2026-06-17 00:01:42 +08:00
Innei 99785d3cc7 ♻️ refactor(auth): consolidate auth SPA loading (#15903)
* ♻️ refactor(auth): consolidate auth SPA loading

* ♻️ refactor(auth): restore auth loading visuals

* 💄 style(auth): use skeleton for OAuth consent loading
2026-06-16 23:37:55 +08:00
YuTengjing e7f1f73e27 💄 style: add top-up best value copy (#15924) 2026-06-16 21:24:23 +08:00
Arvin Xu cd93856561 🐛 fix(boot): one continuous loading screen instead of brand-logo flash on cold start (#15926)
🐛 fix(boot): keep one continuous loading screen instead of flashing the brand logo on cold start

CacheHydrationGate held the routed app behind its own full-screen
ProductLogo while the SWR IndexedDB cache tier hydrated. Because it only
gated once auth resolved, the boot painted the app shell first, then
flipped to the logo when auth switched the scope anon→user (triggering a
cache reload), then back to the app — an app→logo→app flicker.

Now the gate renders nothing while booting and keeps the static HTML
#loading-screen visible, then removes it in the same layout pass that
mounts the children — one continuous loader → app hand-off, no second
in-React logo. It also gates through the pre-auth phase so the scope flip
no longer causes a mid-boot flash. SPAGlobalProvider no longer removes
#loading-screen on mount; that is now owned by the gate.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:21:47 +08:00
YuTengjing 3c907ff0fd 💄 style(referral): update reward rules copy (#15923) 2026-06-16 20:08:49 +08:00
Tsuki 85d94f2f74 feat(mobile): expose deviceRouter on mobileRouter (#15925)
Mounts the existing `deviceRouter` (from lambda) on the mobile tRPC root
so the mobile app can call `device.listDevices` (and other device RPCs)
to drive the chat input's execution-target picker — aligning mobile
device handling with the web `HeteroDeviceSwitcher` UX.

`deviceProcedure` only uses `authedProcedure + serverDatabase`, both of
which the mobile route already provides via `createLambdaContext`, so no
context changes are needed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-16 19:38:13 +08:00
Arvin Xu 69dedc4eeb 🐛 fix(fleet): re-sync running topics each time the Observation tab opens (#15922)
* 🐛 fix(fleet): re-sync running topics each time the Observation tab opens

The board seeded its columns through a `seeded` flag on the session-singleton
store, so the seed effect only ran once per app load. Desktop tabs are in-SPA
navigations that remount FleetView, but the flag stayed true — so re-opening
the tab showed a column set frozen from its first open, and topics that started
running afterward never appeared.

Replace the once-per-load seed with a per-mount sync: on each open, add a
column for every currently-running topic via the idempotent addColumn, tracking
already-synced keys so newly-running topics still pop in, manually-closed
columns stay closed, and manual/reordered columns are preserved. Remove the now
dead seedColumns/seeded from the store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(conversation): init agent config in ChatList so author titles resolve

ChatList now self-inits its conversation's agent config into the agent
store, so message author titles resolve via useAgentMeta instead of
falling back to "未命名助理". Secondary mounts (each Fleet column, the
share page) never went through the route-level init that populates
agentMap. Idempotent via SWR key dedup; gated on isLogin.

Also gate the Fleet column reply bar on messages being loaded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(fleet): move create-task to a pinned footer, count as a tag, rename to Running Board

- Move the "create task" entry out of the cramped header into a full-width
  button pinned at the bottom of the sidebar (added an optional `footer` slot
  to SideBarLayout) — it was easy to miss at the top-right.
- Show the open-column count as a Tag beside the title, hidden when zero.
- Rename the sidebar title from "Running Tasks" to "Running Board" (运行看板).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(fleet): update sidebar doc comment for footer create-task

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(fleet): move create-task to the top of the sidebar body

Place the "create task" button at the top of the running-board list instead of
a pinned footer, and drop the now-unused footer slot from SideBarLayout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(fleet): shrink create-task button to default sidebar size

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(fleet): persist board columns, refetch on focus, polish drag UI

Make the running board a live, durable overview:

- Refetch the running-topic set on focus near-instantly (focusThrottleInterval
  1s) so newly-running topics appear the moment the user looks at the board.
- Persist the column set (manual pins + kept running topics) and per-column
  closures to localStorage, replacing the once-per-mount syncedKeys seed with a
  syncRunningColumns reconciliation that only appends: new running topics pop
  in, manual pins and ordering stay put, and a column closed while still
  running won't immediately re-add (dismissal clears once it stops).
- Columns now stay until the user closes them; a topic that drops out of the
  running set reads as "idle" (StatusDot accepts an absent status).
- Round the dragged column's corners (8px) and clip its contents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(fleet): move running-topics SWR key into the central registry

Replace the inline literal/local const with a dedicated `fleet` domain in the
SWR key registry (`fleetKeys.runningTopics()` → ['fleet:runningTopics']), so the
board's cache key follows the `<domain>:<resource>` convention and the tiered
cache provider / matchDomain('fleet:') treat it as its own namespace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(fleet): turn collapse-reply control into a labeled text button

Replace the bare collapse ActionIcon with a centered text Button carrying the
"Collapse" label so the reply-collapse affordance reads clearly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(fleet): live topic status, collapsible reply, row layout & pin controls

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(fleet): use running-op start time for sidebar elapsed clock

The sidebar running-time readout anchored on topic.createdAt, so it counted
from topic creation (hours off) instead of the current run. Switch to the same
baseline the sidebar topic row uses — operationSelectors
.getAgentRuntimeStartTimeByContext, the running operation's metadata.startTime —
falling back to the StatusDot label when no running op is loaded.

Also widen the OpenAI Responses `create` spy assignment (overloaded signature
isn't assignable to the generic MockInstance fallback under tsgo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:53:13 +08:00
Arvin Xu c10be159c3 🐛 fix(agent-gateway-client): drive resume completion off authoritative DO status (#15919)
* 🐛 fix(agent-gateway-client): drive resume completion off authoritative DO status

A fresh subscriber (no lastEventId) on a hibernated DO replays zero events.
The client used to guess "completed" from a 3s silence and emit session_complete,
which cleared the shared topic.metadata.runningOperation and cancelled the run
on every device — opening a topic on a 2nd device killed the 1st device's run
(LOBE-10443).

Consume the new `resume_complete` message (the DO's stored status, which
survives hibernation) as ground truth: still running / waiting → stay connected
and keep streaming; terminal → complete. The destructive 3s empty-replay
timeout is removed entirely — completion is never guessed from silence. If
`resume_complete` never arrives (e.g. a rolled-back DO), the client just waits,
a safe and recoverable state, with heartbeat loss still forcing reconnect.

Requires agent-gateway#8 (DO sends resume_complete), which deploys first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(agent-gateway-client): opt into resume_complete via wantStatus flag

Set `wantStatus: true` on the resume message so the (revised, non-destructive)
gateway hands back the authoritative session status only to clients that
understand it. A legacy gateway ignores the flag and replays only; this client
then relies on live events and never guesses completion from silence.

Pairs with agent-gateway forward-fix (opt-in gating, no synthesized
session_complete). Safe against both old and new gateways, in either deploy
order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:36:05 +08:00
Arvin Xu 9face81ef3 💄 style(topic): unread-reply indicator on collapsed project groups (#15915)
*  feat(topic): show unread-reply indicator on collapsed project groups

When a project topic group is collapsed, surface an aggregated unread
indicator (animated ripple dot) if any child topic has an unread
completed generation, so users notice replies without expanding.

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

* 🐛 fix(device): stabilize device ordering with createdAt tie-break

`lastSeenAt` is written from a JS `new Date()` (ms precision), so two
rapid registers can tie on it and leave ordering undefined. Break ties
by `createdAt` (DB-side now(), µs precision) for stable ordering.

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

* 💄 style(tool-ui): drop redundant Request section from Linear render

The Inspector already surfaces tool inputs, so rendering request args
again in the Linear result view is redundant.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:05:42 +08:00
Arvin Xu 10b3cbda3e ♻️ refactor(agent): run callAgent as deferred tool (#15765)
* ♻️ refactor(agent): delegate callAgent via server runner

* ♻️ refactor(agent): run callAgent as deferred tool

*  test(agent): cover server callAgent deferred flow
2026-06-16 14:29:36 +08:00
Arvin Xu b6ad1ed4be ♻️ refactor(conversation-flow): role-aware dual-form message-chain reader (#15908)
* ♻️ refactor(conversation-flow): role-aware dual-form message-chain reader

Make the read side role-aware so both persisted chain shapes parse to
equivalent display output (LOBE-10445 phase 1):

- tool-anchored (legacy): next step's assistant hangs off the previous
  step's last tool result
- assistant-anchored (new): next step's assistant hangs off the most
  recent non-tool message, so a tool result and the next assistant are
  siblings under one assistant

Two invariants drive a single reader: a `tool` message is always inline
data of its assistant; a branch is >=2 non-tool siblings under one parent.
The continuation walk now looks for the next spine assistant among the
assistant's own non-tool children as well as its tools' children;
group detection keys on "has >=1 tool child"; branch detection counts
non-tool children only.

Pure read-side, no write-path change — ships independently. Verified
against 5 fixture classes (old / new / mixed / parallel-tool /
regenerate-branch) asserting flatList + contextTree parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(conversation-flow): guard assistant-anchored chain continuation

Address two edge cases in the dual-form continuation finder where seeding
the candidate set with the assistant's own id (the new-form path) bypassed
logic the per-tool path already had:

1. Regenerated continuation: a tool-using assistant can have two non-tool
   assistant children beside its tool result. The finder flattened all
   candidates and returned the earliest, ignoring the parent's
   activeBranchIndex and dropping the other branch. Route >1 non-tool-child
   sets through BranchResolver before picking a linear continuation.

2. Async-task summary: when a tool spawned tasks but the follow-up summary
   uses the assistant-anchored parent (summary.parentId === assistant.id),
   the assistant seed bypassed the task/AgentCouncil fan-out guard and the
   summary got folded into the AssistantGroup before the tasks aggregation.
   Apply the same fan-out guard to the assistant-anchored candidate so the
   group -> tasks -> summary order is preserved.

Both the flat (findFlatChainContinuation) and tree (findChainContinuationNode)
variants share a resolveActiveContinuationId helper; BranchResolver is now
injected into MessageCollector. Adds two fixtures (⑥ regenerated branch,
⑦ async-task summary). conversation-flow: 143 passed, type-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:26:28 +08:00
Arvin Xu 5c5a719186 🐛 fix(device): lock a run to the explicitly selected device, never offer device-switching (#15914)
When a device is explicitly selected (`boundDeviceId`), the run must stay on
it and the model must never be able to activate / switch to another machine.

The remote-device (activate-device) tool was gated only by `!autoActivated`.
That left a hole: when the selected device went OFFLINE the plan became
`device-unrouted`, `autoActivated` flipped to false, and the activate-device
tool resurfaced — letting the model silently hop onto a *different* online
device. That is exactly the "auto-replace to another device after offline"
behavior we want gone.

Also suppress the tool whenever `boundDeviceId` is set, regardless of online
status. An explicitly selected device now locks the run: the tool is never
offered, so the run stays unrouted until that device comes back instead of
switching machines. The unbound case is unchanged — the tool is still offered
so the model/user can pick a device.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:24:55 +08:00
LobeHub Bot 3d594e77f5 🌐 chore: translate non-English comments to English in openapi-remaining-services (#15913)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 13:43:26 +08:00
Arvin Xu ac8f324a3b chore: remove LOBE-XXX markers from code comments (2026-06-16) (#15905)
chore: remove LOBE-XXX markers from code comments

Replace LOBE-XXX ticket references in code comments with descriptive
context from the corresponding Linear issues. The markers served as
internal tracking anchors during development but are inappropriate
for the open-source codebase.

Files changed:
- AgentRuntimeService.ts: LOBE-10385 → async sub-agent suspend/resume
  stability hardening context
- observability-otel/agent-runtime/index.ts: same LOBE-10385 context
- buildRunLifecycle.ts: LOBE-10378/10379/10382 → run lifecycle and
  transport unification context
- streamingExecutor.ts: LOBE-10378 reference removed
- modelExtendParams.test.ts: LOBE-10442 → Gemini 3 Pro reasoning token
  context

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>
2026-06-16 13:38:09 +08:00
René Wang 53e82d2e13 📝 docs: add June 15 weekly changelog (#15907)
* 📝 docs: add June 15 weekly changelog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 docs: restructure last 12 changelog entries into Features/Improvements/Fixes

Normalize section headings, split improvements from fixes, plainer wording, and fewer em-dashes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs: remove Claude Fable 5 from June 15 changelog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🍱 docs: add cover image for June 15 changelog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:00:03 +08:00
LiJian bbbe1a96d7 🐛 fix(connector): restore credentials in edit mode, prevent silent wipe on save (#15909)
* 🐛 fix(agent-builder): correct target agentId and refresh sidebar in gateway mode

In gateway mode, AgentBuilder's tool calls (updateConfig / updatePrompt /
installPlugin) were targeting the builtin builder agent instead of the agent
being edited, and the left-sidebar never refreshed after a successful write.

Two root causes fixed:

1. **Wrong agentId on server** — `executeGatewayAgent` only sent `context.agentId`
   (= the AgentBuilder builtin) to the server. The editing target was only held
   in `chatStore.activeAgentId` (synced by AgentBuilderProvider) but never
   forwarded. Now, when `scope === 'agent_builder'`, the client sends
   `appContext.editingAgentId = chatStore.activeAgentId`. The tRPC Zod schema
   and `ExecAgentAppContext` type both accept the new field, and
   `aiAgent/index.ts` uses it to override the operation's `agentId` so
   `state.metadata.agentId` (and therefore `ctx.agentId` in the server
   executor) points to the correct editing target.

2. **No sidebar refresh** — In client mode the `AgentManagerRuntime` directly
   calls `agentStore.optimisticUpdateAgentConfig()`, which triggers a Zustand
   re-render. In gateway mode the update happens server-side so no Zustand
   mutation ever fires. Fixed by adding an `onAfterCall` hook to
   `AgentBuilderExecutor`: after any successful write it reads the editing
   agent ID from `chatStore.activeAgentId` and calls
   `getAgentStoreState().internal_refreshAgentConfig()` to re-fetch and
   re-render the sidebar.

Closes LOBE-10441

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(agent-builder): isolate editingAgentId to avoid message ownership desync

Per code review: the previous fix overrode state.metadata.agentId with the
editing target, but messages are already written with persistAgentId =
resolvedAgentId (the builder builtin). AgentRuntimeService.queryUiMessages
reads metadata.agentId to filter messages, so overriding it would cause the
gateway handler to snapshot the wrong topic and desync the builder conversation.

Correct approach: keep agentId as the builder builtin throughout. Carry
editingAgentId as a separate metadata field that only flows through to
ToolExecutionContext, where the AgentBuilder server runtime reads it via
ctx.editingAgentId ?? ctx.agentId. No other part of the pipeline is affected.

Changes:
- apps/server/src/services/aiAgent/index.ts: revert agentId override; keep
  editingAgentId as an independent appContext field (conditional spread)
- apps/server/src/services/toolExecution/types.ts: add editingAgentId to
  ToolExecutionContext
- apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts: forward
  state.metadata.editingAgentId into the ToolExecutionContext
- apps/server/src/services/toolExecution/serverRuntimes/agentBuilder.ts:
  use ctx.editingAgentId ?? ctx.agentId in all three write methods

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(connector): restore credentials in edit mode, prevent silent wipe on save

Three bugs caused custom connector headers / bearer tokens to be lost silently:

1. Dead-code branch in edit-mode save: `authType === 'header'` could never be
   true (the auth radio only has none/bearer/oauth2), so every save with
   `authType === 'none'` hit `patch.credentials = null` and wiped whatever
   was stored — including valid header credentials.  Fixed by mirroring the
   create-mode logic: `authType !== 'oauth2'` → check Advanced headers → save
   `{type:'header'}` if present, null otherwise.

2. `list` API strips credentials entirely, so `editValue` always computed
   `authType = 'none'` and `headers = undefined`, leaving the edit form blank
   even when credentials were saved.  Added `getForEdit` tRPC query that
   returns the decrypted user-set credentials (bearer token, custom headers)
   while still excluding machine-managed OAuth tokens and DCR client secrets.
   `CustomConnectorModal` now fetches this on open and builds `editValue`
   from the real data.

3. `DevModal` seeded the form once on mount (`useEffect([], [])`).  Since
   credentials are loaded asynchronously after open, the form was already
   seeded with empty data before the fetch completed.  Changed to a
   `seededRef`-guarded effect on `[open, value]`: resets on close, seeds once
   when the value arrives, and never overwrites user edits mid-session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:06:17 +08:00
Arvin Xu a94b3a4ce2 ️ perf: optimize agent document list query (#15904) 2026-06-16 10:47:19 +08:00
Innei 30a62ab478 🐛 fix(desktop): remove web onboarding aliases (#15902) 2026-06-16 10:42:27 +08:00
LiJian d3fbc19473 🐛 fix(agent-builder): correct target agentId and refresh sidebar in gateway mode (#15888)
* 🐛 fix(agent-builder): correct target agentId and refresh sidebar in gateway mode

In gateway mode, AgentBuilder's tool calls (updateConfig / updatePrompt /
installPlugin) were targeting the builtin builder agent instead of the agent
being edited, and the left-sidebar never refreshed after a successful write.

Two root causes fixed:

1. **Wrong agentId on server** — `executeGatewayAgent` only sent `context.agentId`
   (= the AgentBuilder builtin) to the server. The editing target was only held
   in `chatStore.activeAgentId` (synced by AgentBuilderProvider) but never
   forwarded. Now, when `scope === 'agent_builder'`, the client sends
   `appContext.editingAgentId = chatStore.activeAgentId`. The tRPC Zod schema
   and `ExecAgentAppContext` type both accept the new field, and
   `aiAgent/index.ts` uses it to override the operation's `agentId` so
   `state.metadata.agentId` (and therefore `ctx.agentId` in the server
   executor) points to the correct editing target.

2. **No sidebar refresh** — In client mode the `AgentManagerRuntime` directly
   calls `agentStore.optimisticUpdateAgentConfig()`, which triggers a Zustand
   re-render. In gateway mode the update happens server-side so no Zustand
   mutation ever fires. Fixed by adding an `onAfterCall` hook to
   `AgentBuilderExecutor`: after any successful write it reads the editing
   agent ID from `chatStore.activeAgentId` and calls
   `getAgentStoreState().internal_refreshAgentConfig()` to re-fetch and
   re-render the sidebar.

Closes LOBE-10441

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(agent-builder): isolate editingAgentId to avoid message ownership desync

Per code review: the previous fix overrode state.metadata.agentId with the
editing target, but messages are already written with persistAgentId =
resolvedAgentId (the builder builtin). AgentRuntimeService.queryUiMessages
reads metadata.agentId to filter messages, so overriding it would cause the
gateway handler to snapshot the wrong topic and desync the builder conversation.

Correct approach: keep agentId as the builder builtin throughout. Carry
editingAgentId as a separate metadata field that only flows through to
ToolExecutionContext, where the AgentBuilder server runtime reads it via
ctx.editingAgentId ?? ctx.agentId. No other part of the pipeline is affected.

Changes:
- apps/server/src/services/aiAgent/index.ts: revert agentId override; keep
  editingAgentId as an independent appContext field (conditional spread)
- apps/server/src/services/toolExecution/types.ts: add editingAgentId to
  ToolExecutionContext
- apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts: forward
  state.metadata.editingAgentId into the ToolExecutionContext
- apps/server/src/services/toolExecution/serverRuntimes/agentBuilder.ts:
  use ctx.editingAgentId ?? ctx.agentId in all three write methods

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 10:19:30 +08:00
Arvin Xu 11f0083074 💄 style(chat): add breathing room around message refresh hint (#15906)
* 💄 style(chat): add breathing room around message refresh hint

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(chat): keep refresh hint top flush, widen bottom gap to 24px

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:19:21 +08:00
Arvin Xu 89b74fd7eb 🐛 fix(chat): show cached message refresh hint (#15901)
* 🐛 fix(chat): show cached message refresh hint

* 🐛 fix(chat): show refresh hint for store-backed cache

* 🐛 fix(chat): wait for model config before agent notice
2026-06-16 01:47:06 +08:00
Arvin Xu 68aaa2f6f2 🐛 fix(agent-runtime): forward model extend params on server-side agent runtime (#15891)
* 🐛 fix(agent-runtime): forward model extend params on server-side agent runtime

Share the model extend-params resolution between the client chat service and
the server-side agent runtime so reasoning/thinking params (e.g. Gemini's
thinkingLevel) actually reach the request. Previously only the client resolved
them, so server-driven agent runs returned empty thought summaries.

- extract applyModelExtendParams into @lobechat/model-runtime
- client resolveModelExtendParams delegates to the shared core
- server RuntimeExecutors resolves extendParams (with canonical-card fallback
  for aggregation providers like lobehub) and forwards them in the payload

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(agent-runtime): mock applyModelExtendParams in agent-runtime suites

The executor now imports applyModelExtendParams from @lobechat/model-runtime,
which these suites mock as a fixed object. Add the new named export (returning
an empty result, preserving prior payload behavior) so the mocked module
resolves and call_llm can run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 01:37:49 +08:00
Arvin Xu 906e385e8a feat(desktop): support approved external local file previews (#15895)
*  feat(desktop): support approved external local file previews

* 🐛 fix: keep external local tabs in close scope
2026-06-16 01:24:47 +08:00
AmAzing- 91d8025421 💄 style(agent): clarify workspace copy and move actions (#15897) 2026-06-16 01:13:04 +08:00
Innei 3e261ca2c9 🐛 fix(sidebar): anchor spacer immediately after the accordion block (#15871)
* 🐛 fix(sidebar): anchor spacer immediately after the accordion block

The home sidebar spacer (`__spacer__`) drifts away from the recents+agent
accordion block in two reachable cases: (1) the dropdown-menu "move recents/
agent up/down" leaves the spacer floating above the accordion, and the
CustomizeSidebarModal then silently relocates it on the next drag; (2)
`withAllKnownKeys` appends every missing default to the tail, so any future
top-group default would land in the bottom group for existing users.

Enforce a single invariant in the selector: the spacer always sits right
after the last accordion item. `normalizeSpacerPosition` re-anchors on read
so legacy state self-heals, `withAllKnownKeys` splits backfilled defaults
into top vs bottom by their position in `DEFAULT_SIDEBAR_ITEMS`, and
`reorderSidebarItems` normalizes its result and returns the input reference
when the move is a visible no-op so callers' `next === items` short-circuit
still fires.

* 🐛 fix(sidebar): keep customize drag overlay within modal context

* 🐛 fix(sidebar): apply customization after confirm
2026-06-16 01:10:33 +08:00
Innei 2746a4b454 🐛 fix(locale): align dayjs locale imports (#15896) 2026-06-16 01:10:21 +08:00
Arvin Xu a15ef2e19d feat(git): add git worktree listing across local and gateway paths (#15889)
Add a listGitWorktrees read that powers a worktree picker on both the
local desktop (IPC) and remote device (gateway) paths, mirroring the
existing branch/working-tree read plumbing.

- local-file-shell: parse `git worktree list --porcelain -z`, mark the
  current worktree and attach dirty-file status per worktree
- desktop GitController IpcMethod + electron client service
- deviceGateway.listGitWorktrees + device.listGitWorktrees TRPC procedure
- DeviceGitWorktreeListItem type + useFetchGitWorktrees SWR hook

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:02:00 +08:00
Arvin Xu 7bceba5c19 feat(fleet): lab-gated Fleet running-tasks dashboard (#15817)
*  feat(fleet): add lab-gated Fleet running-tasks dashboard

Side-by-side board of all running tasks across the account. The running-task
list is portaled into the NavPanel (replacing the standard nav rail), and each
task renders as a resizable, reorderable conversation column with its own
ChatInput. Columns default to every running task, support drag reorder, width
resize (persisted), close and a "+" to re-add. Gated behind the `enableFleet`
lab flag (Settings → Advanced → Labs); the title-bar entry is hidden by default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(fleet): add store unit tests for column reorder/add/remove/width

Covers seedColumns (seed-once), addColumn (dedupe), removeColumn,
reorderColumns (the dnd onDragEnd path) and setWidth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(fleet): running-topic data source, X-axis drag lock, hover border, full-width back bar

- Data source switched from the task running-group to all topics whose
  status is `running` (one column per running topic). getAllTopics is
  filtered client-side; a server-side getRunningTopics query is a planned
  follow-up for accounts with many topics.
- Reorder drag is now locked to the horizontal axis (inline dnd-kit modifier).
- The resize-handle highlight only shows when hovering the handle itself,
  not anywhere on the column.
- Back-to-home now lives in a full-width SideBarHeaderLayout top bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(fleet): server-side queryTopics, drag border, loading-state input

- Replace getAllTopics with a `queryTopics` query that filters by status
  server-side (topicModel.queryTopics + lambda TRPC + topicService). The
  board now pulls only running topics instead of the full topic set, and the
  unused getAllTopics procedures (lambda + mobile) and queryAll are removed.
- Dragging a column shows a primary border ring instead of dimming the column.
- ChatInput renders its loading skeleton while the column's messages load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 fix(fleet): reuse StatusDot, topic-first header, on-demand reply, create-task entry

- Status: reuse the app's StatusDot (running = warning spinner) instead of a
  bespoke badge; drop StatusBadge/status.ts.
- Column header: topic title is now the primary line; agent name + avatar +
  status moved to a smaller secondary line.
- Reply: each column's always-on ChatInput is replaced by a "Reply" button
  that reveals the input on click (lower pressure).
- Sidebar: add a "Create task" button (createTaskModal) above the list.
- Drag: dragging a column shows a fill tint + 1px border instead of the 2px
  primary ring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 feat(fleet): observation tab, status spinner reuse, column menu/op-tray/workdir, agent-picker add

- Create-task entry moved into the sidebar header (next to the title).
- Column "open in chat" icon replaced by a ⋯ menu; the action now opens a
  new Electron tab via electron addTab.
- Fleet route shows "Observation Mode" as its tab title (fleetRouteMeta).
- Each column shows its topic working directory + live git branch under the
  agent name (useFetchGitInfo).
- Dragging a column is opaque now (solid bg + 1px border), not see-through.
- OpStatusTray added to each column to surface running-op progress / tokens.
- Trailing "+" opens an agent picker (AssigneeAgentSelector); selecting an
  agent creates a fresh topic and opens it as a new column. Empty board keeps
  the "+" available.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(fleet): align topic creation params

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:43:55 +08:00
Arvin Xu 984815cfc6 🐛 fix(file): only enforce chat upload file-type whitelist in chat mode (#15884)
* 🐛 fix(file): only enforce chat upload file-type whitelist in chat mode

The chat upload file-type whitelist rejects files that agents can readily
parse via tool calls (zip, html, provisioning profiles, files without an
extension, etc.), which hurts agent and heterogeneous-agent workflows where
the whitelist adds no value.

Scope the whitelist to plain chat mode only: `uploadChatFiles` now takes the
conversation's agent id and skips type validation when that agent has agent
mode enabled or is heterogeneous (Claude Code / Codex, etc.). The decision is
keyed off the input/conversation agent id via the by-id selectors rather than
the global current agent, because the chat input can be scoped to a different
agent than activeAgentId (e.g. another desktop tab). Closes #15770.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🚨 fix: sort imports in file chat action to satisfy lint

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:41:48 +08:00
AmAzing- b873f26a8c 🐛 fix(agent-signal): preserve preference memory receipt routing (#15892) 2026-06-16 00:03:39 +08:00
Arvin Xu 294400383d feat(group): server-side group orchestration (call agent member) (#15870)
*  feat(group): add server-side group orchestration (call agent member)

Mirror the client GroupOrchestrationRuntime on the server: the supervisor's
own durable QStash operation drives the loop, with lobe-group-management
registered as a server deferred tool. speak/broadcast/delegate run members in
the shared group session via execAgentMember; executeAgentTask(s) reuse the
isolated sub-agent thread. A K=N member barrier backfills the group tool
message and resumes (or finishes, for skipCallSupervisor/delegate) the parked
supervisor through the existing async-tool bridge + CAS. Adds the
group-member-callback QStash webhook for queue mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(group): address PR review (finish disposition, ephemeral prompt, task timeout)

- finish-vs-resume now scans ALL pending tools, not just pending[0], so a
  group skipCallSupervisor/delegate call that isn't the first deferred tool in
  a batched turn no longer wrongly schedules a resume.
- in-group member instructions are injected as ephemeral LLM context
  (execAgent: suppressUserMessage + new ephemeralUserMessage) instead of being
  persisted as real `role: 'user'` group messages — matches the client's
  virtual supervisor instruction.
- isolated executeAgentTask(s) now enforce the requested timeout: a watchdog
  interrupts the member and bridges a `timeout` completion so the supervisor
  resumes/finishes instead of staying parked indefinitely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:14:44 +08:00
Arvin Xu 9b93c47415 🐛 fix(hetero): anchor server-side main chain to run's real last tool (#15883)
Server-triggered heterogeneous-agent runs forked the message chain on a
remote-device WS reconnect: several consecutive, distinct main-agent steps
all parented onto the run's FIRST tool message instead of chaining linearly,
leaving orphan sibling assistants.

The chain rule (`computeTurnParentId = lastToolMsgIdEver ?? currentAssistantId`)
relies on in-memory reducer state. On a non-sticky / cold replica the state is
rebuilt from DB by `refreshMainStateFromDb`, which anchored off
`getLastChildToolMessageId(currentAssistantId)`. When `heteroCurrentMsgId` is
not yet bound to the operation, `currentAssistantId` regresses to the seeded
placeholder assistant, so the anchor collapses to the seed's first child tool
and every later step opens off that same node. The class already documents the
"must be sticky to a single replica" caveat — the remote-device path breaks it.

Anchor the chain to the run's real latest main-thread tool instead, read from
the DB and ordered by createdAt, independent of currentAssistantId. Scope to
the run via the seed assistant's createdAt floor (messages carry no operationId,
and a topic runs at most one operation at a time). This also sidesteps the
multi-tool-batch hazard where an earlier tool's result_msg_id is backfilled
before a later tool row's JSONB is rewritten.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:52:02 +08:00
LobeHub Bot f341507fa9 🌐 chore: translate non-English comments to English in src-helpers (#15856)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 22:04:13 +08:00
Arvin Xu eb31b7d8b9 feat(electron): open a new Home tab from the tab bar "+" button (#15825)
Previously the "+" button ran the active route's createNewTab handler, so
on an agent/group/page tab it created a new topic/page of that same kind.
Make it always open Home instead, and remove the now-dead createNewTab
route-meta machinery.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:03:33 +08:00
Arvin Xu 97df98b269 fix: remove ParamsPanelToggle (control icon) from chat header (#15860)
fix: remove ParamsPanelToggle icon from chat header
2026-06-15 22:02:27 +08:00
Arvin Xu cdb0280cd6 ♻️ refactor(agent): scope agent conversation subtree to explicit agentId (#15866)
* 🐛 fix(agent): stop background config fetch from hijacking the active agent

Switching to or opening an agent tab could flash the conversation
header/welcome back to the inbox "Lobe AI" identity. Two causes:

- `useFetchAgentConfig.onData` set the global `activeAgentId` to whatever
  config resolved, so a background/secondary fetch (the inbox config from
  the home input, a side-panel copilot, or another open tab) hijacked the
  routed agent. It now only adopts the fetched agent when none is active;
  route-level sync (AgentIdSync on desktop/mobile, the popup pages' own
  setState) owns `activeAgentId`.
- `AgentInfo` (the agent conversation welcome) read the global
  `currentAgentMeta` / `isInboxAgent`. Scope it to the conversation's agent
  via `useConversationStore(contextSelectors.agentId)` + `*ById` selectors,
  so it renders the routed agent even if the global races.

Also remove the dead `Conversation/AgentWelcome/{index,OpeningQuestions}`
(the conversation welcome is `AgentHome`/`AgentInfo`; this variant was
unreferenced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(agent): scope agent conversation subtree to explicit agentId (LOBE-10402 phase 1)

Replace global `current*` selectors (which implicitly read the hijack-prone
`agentStore.activeAgentId`) with `*ById(agentId)` in the agent conversation
subtree and two shared features. The agentId is sourced explicitly:

- inside the ConversationProvider → `useConversationStore(contextSelectors.agentId)`
  (MainChatInput, AgentConfigError, HeterogeneousChatInput, ToolAuthAlert, TTS,
  ShareImage, History)
- ConversationArea → its own `context.agentId`
- above the provider → `useChatStore(s => s.activeAgentId)` (route-driven via
  AgentIdSync) — ChatConversation, AgentSummary
- already-available id → prop (AgentTopicManager/Header) or resolved context
  (ShareModal/ShareDataProvider)

Add the missing `getAgentTTSVoiceById` and `getAgentConfigErrorById` byId
selectors (+ tests). The `current*` selectors are left in place for now; they
are removed in the final phase once every caller is migrated.

Refs LOBE-10402.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(agent): pass the scoped conversation agentId into the hetero guards

`useHeteroAgentCloudConfig` and `useRemoteAgentDeviceGuard` read the global
`activeAgentId` internally, so when the conversation agent differs from it
(the tab-hijack scenario), the cloud-credential and bound-device checks
validated a different agent than the one `agencyConfig`/`isDeviceExecution`
were computed from — the input could be enabled without the routed Claude Code
agent's credential check, or blocked with the wrong device status.

Both hooks now take the conversation `agentId` explicitly and read that agent's
agencyConfig by id, keeping every hetero check on the same routed agent.

Refs LOBE-10402.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:58:42 +08:00
Arvin Xu 45dfc4cf87 💄 style(thread-list): cap nested thread list height with scroll overflow (#15861)
* 💄 style(thread-list): cap nested thread list height with scroll overflow

When an active topic has many threads, the nested list grows unbounded and
pushes the rest of the topic list off-screen. Cap it at ~9 rows and scroll
the overflow within the list itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(thread-list): scroll the active thread into view in the capped list

The new max-height scroll container always mounts at scrollTop=0, so a thread
restored from the ?thread= query that sits below the visible rows stayed out of
view — and since the topic row isn't highlighted while a thread is active, the
sidebar showed no selection at all.

Add a shared useScrollActiveThreadIntoView hook that nudges the capped list so
the active row (marked via data-thread-id) is visible, keyed off the list-ready
signal so it also fires once async-fetched threads mount. Wired into both the
agent and group ThreadList variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(ux): add list selection-visibility & in-progress-edit rules

Distill two UX learnings from the capped thread-list work into the ux skill:
restoring an off-screen selection in a scrolled/capped/virtualized list must
scroll it into view, and editors must back up in-progress input locally so an
accidental exit, crash, or failed save can't vaporize the user's work.

Reorganize the checklist by interaction type (Read / Edit / Act / Feedback /
Grow) instead of a flat list, and use English-only headings and value tags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(thread-list): make the capped nested list actually scroll

The cap was on the container but never engaged: the list is a flex column,
so the rows (default flex-shrink) compressed to fit max-height instead of
overflowing. Pin each row to min-height 36 so the content overflows, and
swap the wrapper to ScrollShadow so the cut-off shows an edge fade instead
of an invisible hard clip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:50:58 +08:00
YuTengjing 6461f4053c ️ perf: dedupe unread count polling (#15881) 2026-06-15 20:18:14 +08:00
lobehubbot 1fa6f47fc9 🔖 chore(release): release version v2.2.5 [skip ci] 2026-06-15 11:39:57 +00:00
LiJian fb5566cdbc 🚀 release: 20260615 (#15877)
# 🚀 LobeHub Release (20260615)

**Release Date:** June 15, 2026
**Since v2.2.4:** 48 merged PRs · 5 contributors

> This cycle lands the Composio integration as the new connector
backbone, a unified tiered client cache, and a deep round of
agent-runtime reliability hardening for cold-replica and sub-agent
flows.

---

##  Highlights

- **Composio integration** — New Composio integration layer replaces
Klavis as the connector backbone for third-party skills. (#15461)
- **Tiered client cache** — Unified localStorage + IndexedDB cache
provider with per-scope isolation, plus a registry-wide convergence of
SWR keys for predictable invalidation. (#15844)
- **Gateway mode in chat config** — Gateway mode now lives in chat
config, making it per-conversation rather than a global toggle. (#15714)
- **Bulk move topics** — Move multiple topics to another assistant in
one action. (#15809)
- **Skills row actions** — View / rename / delete row actions in the
working sidebar, plus edit / uninstall for connectors in Skill detail.
(#15864, #15829)
- **Token usage cache rate** — Conversations now surface the
prompt-cache hit rate alongside token usage. (#15812)

---

## 🏗️ Core Agent & Architecture

- **Run lifecycle** — Extracted client run-completion into a shared
`buildRunLifecycle`, with a characterization net over agent-runtime
run-lifecycle. (#15854, #15843)
- **Sub-agent resilience** — Hardened async sub-agent suspend/resume
against missed wakeups. (#15855)
- **Cold-replica correctness** — Fixed main-turn idempotency and now
mark topics failed on terminal errors; persist sub-agent turn id so cold
replicas don't fragment a turn; dedupe sub-agent thread creation after
finalize. (#15838, #15808, #15849)
- **Stream routing** — Drop sub-agent-tagged events from the main
gateway stream handler, and preserve `subAgentId` / `documentId` in the
message bucket key context. (#15814, #15865)
- **Heterogeneous agents** — Forward bot / IM image attachments to
heterogeneous agents. (#15868)
- **Agent state** — Stop background config fetch from hijacking the
active agent, and warn when agent mode is on but the model lacks tool
calling. (#15862, #15828)
- **Tracing** — Enable S3 tracing by default in production. (#15841)

---

## 🔌 Integrations & Skills

- **Skill panel** — Dedupe skill-panel rows and allow deleting pending
integrations; stop connected integrations from duplicating in the
chat-input skill panel. (#15872, #15869)
- **Connectors** — Edit / uninstall buttons for connectors in Skill
detail. (#15829)

---

## 🖥️ Chat & User Experience

- **Topics** — Server-side status filter via a new `queryTopics` query,
and per-agent topic search scoped by `agentId`. (#15822, #15798)
- **Message rendering** — Render mixed assistant blocks in natural
order, fold short mixed tool blocks together, and render mention names
from the serialized attribute instead of falling back to "unknown".
(#15810, #15857, #15831)
- **Tool workflow** — Tool-workflow collapse no longer shows "in
progress" once content renders below it. (#15815)
- **Token usage** — Derive operation token usage from messages rather
than a parallel accumulation. (#15819)
- **Reconnect** — Normalize reconnect `startTime` to epoch ms. (#15811)
- **Home & editor** — Hide the agent-mode notice while config is
loading, and isolate the page-editor copilot context from global
agent/document state. (#15846, #15826)
- **Polish** — base-ui modal fixes the provider delete-confirm z-index,
the updater renders release notes as Markdown, revert-confirm and toast
copy tightened. (#15845, #15867, #15813)
- **Desktop** — Tray double-click opens the main window. (#15816)

---

## 🔒 Reliability

- **Auth gating** — Gate the `listDevices` request behind login state so
it no longer fires before authentication. (#15876)

---

## 🔧 Tooling & Internal

- **SWR convergence** — Converged store-, UI-, and straggler SWR keys
into the `swrKeys` registry, fixing a stale prefetch key along the way.
(#15863, #15858, #15853, #15850, #15848)
- **Tests** — Characterization coverage for parked states and
post-persist title wiring; removed stale `LOBE-XXX` markers; updated
testing skill rules. (#15847, #15852, #15807)
- **Docs** — Added the ux design-values / execution-checklist skill and
a capability-gated feature checklist. (#15823, #15832)
- **Misc** — Fixed workspace prefix handling; bumped
`@vitest/coverage-v8` to v3.2.6. (#15837, #15802)

---

## 👥 Contributors

Huge thanks to **5 contributors** who shipped **48 merged PRs** this
cycle.

@arvinxx · @LiJian · @Innei · @tjx666 · @Rdmclin2

Plus @lobehubbot and renovate[bot] for maintenance.

---

**Full Changelog**: v2.2.4...release/weekly-20260615
2026-06-15 19:37:10 +08:00
Arvin Xu e444a886ff 🐛 fix(device): gate listDevices request behind login state (#15876)
Devices are served by an authed lambda procedure, but the client fired
`device.listDevices` unconditionally — `useEffectiveWorkingDirectory`
(broadly mounted in chat) and `WorkingDirectoryPicker` both called
`useFetchDevices()` with no argument, so logged-out web users sent a bare
request that 401s. The settings `DeviceList` queried it directly with no
`enabled` gate too.

Thread `isLogin` (|| isDesktop, matching `useInitUserState`) into all three
call sites and flip `useFetchDevices`'s default to `false` so the safe
default is opt-in.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:05:44 +08:00
LiJian aa7bc81fbc 🐛 fix(skill): dedupe skill panel rows & allow deleting pending integrations (#15872)
* 🐛 fix(skill): dedupe skill panel rows and allow deleting pending integrations

Two related fixes for the chat-input "+" → Skills panel:

1. Dedupe by key: the same app can be sourced from more than one list
   (a Composio/LobeHub integration item plus an installed plugin sharing the
   same identifier), which rendered the row multiple times. Add a key-based
   dedup pass on the final skill list, keeping the first (richer) occurrence.

2. Deletable pending integrations: a Composio server that exists but isn't
   ACTIVE (pending auth / re-authorize — e.g. after closing the OAuth popup)
   only rendered a Connect/Re-authorize link with no "..." menu, so it could
   never be removed. Give these rows a delete-only policy menu (via the "..."
   button and right-click) backed by removeComposioConnection, while keeping
   the Re-authorize action. renderPolicyMenu gains a `deleteOnly` mode that
   hides the meaningless Pinned/Auto options for not-yet-connected entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(skill): drop optimistic plugin id when deleting a Composio connection

handleConnect adds the new server id to the agent's plugins before OAuth
completes, so removeComposioConnection alone left an orphan id in the config:
the row stayed counted as pinned, and a later reconnect's togglePlugin flipped
the freshly-connected skill back off. Wrap removal so it also unpins the id via
togglePlugin(id, false) (a no-op when absent), for both active and pending
delete paths.

Addresses Codex review feedback on #15872.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔨 chore(skill): make Composio plugin-id cleanup best-effort on delete

Swallow togglePlugin failures so the optimistic plugin-id cleanup can never
break the actual connection removal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(skill): allow removing orphaned Composio entries with no server

A Composio app whose id lingers in the agent's plugins but has no server yet
(added optimistically, never authorized) rendered a plain "Connect" row with no
"..." menu, so it couldn't be removed. Surface such ids in the list and give
them the same delete-only menu (via "..." and right-click) as pending servers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:05:17 +08:00
Arvin Xu ced1d5dec5 🐛 fix(agent): forward bot/IM image attachments to heterogeneous agents (#15868)
Bot/IM channels (Slack, Telegram, …) deliver attachments as raw `files`
buffers, while the SPA gateway delivers pre-uploaded `fileIds`. The
heterogeneous-agent branch of `execAgent` forked early and only handled
`fileIds`, so images sent through a bot were silently dropped — the CLI
(Claude Code / Codex) received text only.

Unify the turn setup so both branches share one implementation:
- Extract `resolveRunAttachments` (raw `files` → S3 via ingestAttachment +
  `attachedFileIds` → resolveAttachmentsByFileIds), returning
  {fileIds, imageList, videoList, fileList, warnings}; attachment resolution
  is non-fatal.
- Hoist attachment ingestion + user-message + assistant-placeholder creation
  above the hetero/normal fork; both branches consume the same records.
- Exclude the freshly-created turn from `loadHistoryMessages` via a
  `selfMessageIds` set so the prompt isn't double-counted in the LLM context.
- Assistant-placeholder fields stay conditional (hetero seeds provider only;
  the CLI reports the real model later). Agent Signal stays normal-only.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:30:46 +08:00
Arvin Xu 4b72bcfe99 feat(skills): add view/rename/delete row actions in working sidebar (#15864)
Add hover-revealed action buttons and a shared right-click context menu to
skill rows across project, agent, and user skill lists in the working
sidebar, plus a shared RenameSkillModal.

- SkillsList: per-row `getRowActions` descriptor drives both the hover icon
  cluster and the context menu; disabled actions render greyed for
  not-yet-supported operations
- User skills: view (detail modal), rename (user-authored only), delete
- Agent skills: view/rename/delete via the agent-document service
- Project skills: view (local only); rename/delete stubbed "coming soon"
  until the filesystem-mutation IPC lands

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:26:32 +08:00
LiJian 6f07089ea7 🐛 fix(skill): stop connected integrations duplicating in chat-input skill panel (#15869)
The chat-input "+" → Skills panel listed connected integrations (Gmail,
Google Calendar, Google Drive, etc.) twice: once as a brand-icon item under
the LobeHub group, and again as a generic plug-icon "community plugin".

Root cause: community plugins were filtered with a blacklist
(`type !== 'customPlugin'`), so integration gateway plugins whose source is
`'self'`/`'builtin'` leaked into the community group. The /settings/skill
page already avoids this by whitelisting `type === 'plugin'`. Align the
chat-input panel with the same whitelist.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:59:22 +08:00
Arvin Xu 5770ba67a8 ♻️ refactor(chat): extract client run-completion into buildRunLifecycle (#15854)
Introduce the unified store/UI run-lifecycle contract (AgentRunLifecycle) + a
buildRunLifecycle factory, and wire the CLIENT streaming runtime through it.
Behavior-preserving (strategy A): the client completion effects are relocated
verbatim into the factory hooks, so the characterization net stays green.

- runLifecycle/types.ts — AgentRunLifecycle contract: 9 lifecycle hooks incl.
  onRunParked/onRunResumed, carrying a runId that survives across operations and
  a runScope gate. Explicitly separate from the runtime-internal BLOCKING hooks.
- runLifecycle/buildRunLifecycle.ts — factory implementing the client effect set
  (afterCompletion → drain/requeue → completeOperation/markUnread → normalized
  client.runtime.complete signal → desktop notification). normalize/findCompletion
  helpers relocated here.
- streamingExecutor — completion block replaced by completeRun + afterRunComplete
  calls; dead emit closure removed.

Gateway/hetero adapters + hoisting the assembly to the sendMessage seam land in
LOBE-10379. No behavior change: streamingExecutor net 43/43, sibling suites 79/79,
type-check + eslint clean.

Part of LOBE-10376
Closes LOBE-10378

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:38:24 +08:00
Arvin Xu b684305667 chore: remove LOBE-XXX markers from characterization test comments (2026-06-15) (#15852)
chore: remove LOBE-XXX markers from streamingExecutor characterization tests

- Replace LOBE-10377 with cross-transport baseline description
- Replace LOBE-10382 with parked/resumed/terminal signal normalization context
- Preserve test semantics — comments now explain intent without Linear ticket references

Co-authored-by: Arvin Xu <arvinx@lobehub.com>
2026-06-15 14:56:05 +08:00
Arvin Xu 8ad6c2180d 🐛 fix(chat): preserve subAgentId/documentId in message bucket key context (#15865)
* 🐛 fix(chat): preserve subAgentId/documentId in message bucket key context

`replaceMessages` and `internal_getConversationContext` rebuilt the
conversation context with a hand-picked field whitelist, silently dropping
`subAgentId` (and others). Since `messageMapKey` uses `subAgentId` as the
group_agent scope subTopicId, group-agent writes collapsed into the wrong
bucket. Spread the whole context instead and only special-case the fields
that need a fallback/assertion (agentId, topicId), so every bucket-key
field carries through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(database): deterministic ordering in topic.duplicate test

Both seed messages were inserted in one transaction with no explicit
createdAt, so they shared the same `now()` default. `duplicate`'s
`orderBy(createdAt)` then returned the tied rows in arbitrary order,
making the positional assertions flaky. Give them distinct createdAt
(user before assistant) so the order is well-defined.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:57:09 +08:00
Arvin Xu b8ed49ce5b 🐛 fix(updater): render update release notes as Markdown instead of raw source (#15867)
🐛 fix(updater): render release notes as Markdown instead of raw source

The update modal injected release notes via dangerouslySetInnerHTML, but
the content is a Markdown source string (e.g. `## Canary Build`, GFM
tables), so headings/tables/bold were shown literally as raw text.

Render it with @lobehub/ui's <Markdown> component instead. Also handle the
`ReleaseNoteInfo[]` shape of `releaseNotes` by rendering each note.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:56:52 +08:00
Arvin Xu 2df87284cb 🐛 fix(agent): stop background config fetch from hijacking the active agent (#15862)
Switching to or opening an agent tab could flash the conversation
header/welcome back to the inbox "Lobe AI" identity. Two causes:

- `useFetchAgentConfig.onData` set the global `activeAgentId` to whatever
  config resolved, so a background/secondary fetch (the inbox config from
  the home input, a side-panel copilot, or another open tab) hijacked the
  routed agent. It now only adopts the fetched agent when none is active;
  route-level sync (AgentIdSync on desktop/mobile, the popup pages' own
  setState) owns `activeAgentId`.
- `AgentInfo` (the agent conversation welcome) read the global
  `currentAgentMeta` / `isInboxAgent`. Scope it to the conversation's agent
  via `useConversationStore(contextSelectors.agentId)` + `*ById` selectors,
  so it renders the routed agent even if the global races.

Also remove the dead `Conversation/AgentWelcome/{index,OpeningQuestions}`
(the conversation welcome is `AgentHome`/`AgentInfo`; this variant was
unreferenced).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:44:55 +08:00
Arvin Xu 3f7f50edef ♻️ refactor(swr): converge the last straggler SWR keys + fix stale prefetch key (#15863)
* ♻️ refactor(swr): converge the last straggler SWR keys + fix stale prefetch key

Final cleanup of the SWR key convergence. Migrates the remaining ad-hoc keys
that earlier grep-based sweeps missed (they hid behind non-obvious const names
like SWR_KEY / FETCH_*_KEY / SWR_RESOURCES, template-literal keys, the electron
store, and assorted one-off hooks):

- hooks: usePrefetchAgent, useHomeDailyBrief, useGatewayReconnect
- features: OpenInAppButton, Recommendations/useHeteroDetections,
  RecommendTaskTemplates, ResourceManager search
- routes: provider ClientMode + DisabledModels (useSWRInfinite), memory
  analysis task, sidebar task groups, imessage bridge status, Review git patches
- store: user initState + checkTrace, builtin agent init, file resources,
  electron settings/gateway/sync

New registry domains: home, taskTemplate, resource, provider, recommendations,
openInApp, gateway, user, builtinAgent, imessage, sidebar, electron — plus
extensions to aiModel (disabledModelsPage), device (gitReviewPatches /
gitRemoteBranches), userMemory (analysisTask).

🐛 Fix: usePrefetchAgent warmed `['FETCH_AGENT_CONFIG', agentId]`, which never
matched what `useFetchAgentConfig` reads. It now warms
`augmentKey(agentConfigKeys.config(agentId), getActiveWorkspaceId())` — the
exact workspace-scoped key the consumer subscribes to, so hover-prefetch
actually populates the cache.

No tiering/caching change: every new prefix is kept out of CACHE_TIERS
(names avoid the cached agent:/task:/brief: tiers). The electron factory roots
retain their original `electron:getXxx` strings, so those cache identities are
unchanged.

After this, the only ad-hoc SWR keys left are in `packages/*` (can't import
`@/libs/swr/keys`); every `src/` SWR call site now routes through the registry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(swr): drop suspense: true from data-fetching hooks

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(swr): update refreshUserState assertion to registry key

Follow-up to the prior commit: the auth-slice test still expected
mutate('initUserState'); refreshUserState now passes userKeys.initState()
(['user:initState']). Assert against the factory so it tracks the registry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:11:21 +08:00
Arvin Xu dcff321290 🐛 fix(assistant): fold short mixed tool blocks together (#15857) 2026-06-15 12:42:30 +08:00
Arvin Xu b28e3672f6 ♻️ refactor(swr): converge UI-layer SWR keys into swrKeys registry (#15858)
Completes the SWR key convergence by migrating the remaining UI-layer ad-hoc
keys (features / routes / components) into the central registry. New domains:
stats, messenger, verify, inbox, share, fork, portal, favorite, changelog,
onboarding, agentHome, agentProfile, agentSignal, ollama, auth, cron,
topicAction — plus extensions to discover (mcpAgents/skillAgents/market),
device (gitBranches/repoType), session (createSession), group (queryAgents*).

- Shared keys (availablePlatforms, agentsForBinding, bindingScopes,
  shared-topic, favorite-status, openNewTopicOrSaveTopic, portal-document-header,
  inbox notifications/unread) are routed through one factory at every call site
  so they still dedupe to a single cache entry.
- The notifications useSWRInfinite getKey and the userMemory-style matcher
  invalidations were migrated in lockstep with their fetch keys.
- No tiering/caching change: every new prefix is kept out of CACHE_TIERS, and
  names avoid the cached prefixes (share:/portal:/agentHome:/agentProfile: etc.
  instead of topic:/document:/agent:). Behavior preserved.
- Folds in the lone cross-layer `cronTopicsWithJobInfo` store mutate.

Packages (builtin-tool *) keep their local keys — they can't import from
`@/libs/swr/keys`; left as-is intentionally.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:05:18 +08:00
LiJian 866af8d2f0 feat(composio): add Composio integration layer as Klavis replacement (#15461)
*  feat(composio): add Composio integration layer as Klavis replacement

- Add @composio/core SDK client factory (src/libs/composio)
- Add COMPOSIO_API_KEY server config + enableComposio flag
- Add COMPOSIO_APP_TYPES const with 21 curated apps (appSlug-based)
- Add lambda/composio tRPC router (createConnection, deleteConnection, getConnection, updateComposioPlugin)
- Add tools/composio tRPC router (executeAction, listActions, getActions)
- Add ComposioService with executeComposioTool + getComposioManifests
- Add composioStore Zustand slice (7 files: types, initialState, action, selectors, index, test)
- Wire composioStore into ToolStore state and action tree
- Add composioStoreSelectors to tool selectors index
- Add handleComposioInstall to AgentManagerRuntime
- Extend CustomPluginParams with composio field
- Add enableComposio to GlobalServerConfig types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🔥 refactor(klavis): remove Klavis integration and migrate all references to Composio

- Delete all Klavis source files (libs, config, const, routers, services, store, UI components)
- Rename KlavisX components to ComposioX equivalents
- Replace all Klavis store selectors, types, and action names with Composio counterparts
- Fix authConfigId to be server-side managed (auto-fetch/create from Composio API)
- Update DB customParams.klavis → customParams.composio throughout
- Fix ToolSource type: 'klavis' → 'composio'
- Fix TaskTemplateSkillSource: 'klavis' → 'composio'
- Fix RecommendedSkillType.Klavis → RecommendedSkillType.Composio
- Remove klavis npm package dependency
- Update builtin-tool-creds: connectKlavisService → connectComposioService
- Update RuntimeExecutors: KLAVIS_SERVICES_LIST → COMPOSIO_SERVICES_LIST
- All Composio-related type errors: 0 remaining

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(composio): complete the klavis→composio migration and wire the OAuth callback

The composio branch had renamed the klavis modules but left consumers
half-migrated, so the OAuth connect link did not work end-to-end. Finish it:

- Add the missing OAuth callback route `/api/composio/oauth/callback` (Composio
  uses managed auth, so it only lands the user back and closes the popup; the
  opener then polls getConnection and syncs tools). Allowlist it as a public
  cross-site redirect landing in the proxy define-config.
- Remove leftover `import { type Klavis } from 'composio'` (non-existent package)
  and type the prop as `string`.
- Fix undefined `oauthUrl` → `redirectUrl` in every OAuth popup opener.
- Map `serverName` to `appSlug` (API) / `label` (display); unify every
  createComposioConnection call to `{ appSlug, identifier, label }`.
- Compare against the `ComposioServerStatus` enum instead of the `'ACTIVE'`
  string literal.
- Use the renamed store fields `composioServers` / `isComposioServersInit`.
- executeComposioTool: `toolName` → `toolSlug`.
- Rename onboarding `KlavisServerItem.tsx` → `ComposioServerItem.tsx` to match
  its import.

* 🐛 fix(composio): use connectedAccounts.link for Composio-managed OAuth

`connectedAccounts.initiate` is no longer supported for Composio-managed OAuth
auth configs (HTTP 400), which broke connecting apps like Gmail. Switch to
`connectedAccounts.link` (POST /api/v3/connected_accounts/link) — same
`{ callbackUrl }` options and `{ id, redirectUrl }` result, so it is a drop-in.

Also treat Composio's `status=failed` callback query param as a failed
authorization in the OAuth callback page.

* 🐛 fix(composio): correct tool sync, execution, callback build, and list dedup

Four fixes found while testing the Composio integration end-to-end:

- listActions: use `getRawComposioTools` (raw defs with slug/inputParameters)
  instead of `tools.get()` (provider-wrapped, name/params under `.function`).
  The wrapped shape left every synced tool with an empty name, so they all
  collapsed to `${identifier}____` and the LLM rejected the request with
  "Tool names must be unique."
- tools.execute: pass `dangerouslySkipVersionCheck: true` (manual execution
  otherwise throws ComposioToolVersionRequiredError when the toolkit version
  resolves to "latest"). Applied to both the executeAction router and the
  ComposioService used by the agent runtime.
- OAuth callback route: escape only `<`/`>`/`&` for the inline-script payload;
  the previous regex embedded literal U+2028/U+2029 line separators which broke
  the regex literal at build time ("Unterminated regular expression").
- installed-plugin selectors: filter out `customParams.composio` (was still
  checking the old `customParams.klavis`), so a connected Composio app no longer
  shows up twice in the skill picker / tool discovery list.

*  feat(composio): pin auth config id per toolkit via env

Add `COMPOSIO_AUTH_CONFIG_IDS` (JSON map of `identifier -> authConfigId`) so a
pre-created Composio auth config (e.g. a custom/white-label OAuth app set up in
the dashboard) can be used directly per toolkit. `createConnection` now resolves
the pinned auth config first, then falls back to discovering an existing one for
the toolkit (matched case-insensitively), and only auto-creates a
Composio-managed config when nothing is configured.

* 🐛 fix(composio): update plugin invoke test to composio + sort tool initialState imports

- action.test.ts: the action was renamed invokeKlavisTypePlugin → invokeComposioTypePlugin
  (Klavis is being removed); update the test to call the composio action and drop
  the klavis-era naming/mock field.
- store/tool/initialState.ts: order the composioStore import before connector to
  satisfy simple-import-sort/imports.

* 🐛 fix(composio): stop client deleting remote connections by static allowlist

useFetchUserComposioConnections no longer deletes remote connections/plugins
for identifiers outside the compile-time COMPOSIO_APP_TYPES list — an outdated
client bundle would silently destroy a legitimate connection. Unknown
identifiers are now only hidden locally.

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

* 🐛 fix(composio): resolve connectedAccountId server-side in executeAction

executeAction now takes `identifier` and looks up the connectedAccountId from
the caller's own user-scoped plugin record (PluginModel), instead of trusting a
connectedAccountId supplied by the client — which would let a user drive
another user's connection. Callers (callComposioTool, composioExecutor) pass
identifier accordingly.

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

* 🐛 fix(composio): enable plugin only after OAuth succeeds

Move enablePluginForAgent into the ACTIVE and post-auth-success branches so a
cancelled/timed-out authorization no longer leaves an enabled-but-unauthorized
Composio tool on the agent.

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

* 🔥 fix(composio): drop dead OAuth callback postMessage

The lobe-composio-oauth postMessage had no consumer — the OAuth wait uses
polling + window.closed detection. Remove it and its escaping helpers.

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

* 🐛 fix(composio): resolve type-check errors after canary merge

- Guard authConfigId to a definite string before persisting/returning it
  (createConnection), fixing the string|undefined assignment in both the
  server router and the composio store server object.
- Replace leftover KLAVIS_SERVER_TYPES with COMPOSIO_APP_TYPES in AgentTool.
- Update SkillAuthRow test to a composio source/provider (klavis is removed
  from TaskTemplateSkillSource).

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

* ♻️ refactor(composio): remove leftover klavis naming after migration

Klavis is deprecated and fully replaced by Composio. The migration kept the
underlying composio wiring but left klavis-named identifiers, comments, prompt
tags, i18n keys, and files throughout. Sweep them to composio:

- Code identifiers/comments across ~70 files (isKlavisEnabled→isComposioEnabled,
  allKlavisServers→allComposioServers, klavisManifests→composioManifests, etc.)
- LLM prompt tags (<klavis_tools>→<composio_tools>, KLAVIS_SERVICES_LIST→
  COMPOSIO_SERVICES_LIST) — kept consistent across definition and substitution
- i18n keys tools.klavis.*→tools.composio.* + user-facing "Klavis"→"Composio"
  brand strings, in default setting.ts and all locale setting.json files
- Rename useKlavisOAuth→useComposioOAuth, useKlavisServerActions→
  useComposioServerActions (+ imports)
- klavis.ai homepage URLs → composio.dev
- Remove the dead `klavis` npm peerDependency; swap .env.example Klavis section
  for Composio; update product docs

Changelog history left untouched. Pure rename — no behavior change.

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

* 🐛 fix(composio): remove duplicate composio key in CustomPluginParams

The klavis→composio rename collapsed the deprecated klavis param block onto
the live composio one, producing a duplicate `composio` property. The klavis
shape (instanceId/serverName/serverUrl/isAuthenticated) is dead — no code reads
it — so drop it and keep the live composio shape.

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

* 🐛 fix(composio): let pending/errored connections re-authorize or be deleted

A Composio connection link (lk_...) expires after a while. Previously a
pending/errored row only offered to reopen the stored — now expired —
redirectUrl, and the delete action existed only for ACTIVE connections, so an
expired link left the tool permanently stuck: unauthenticatable and
unremovable.

- Add reauthorizeComposioConnection store action: best-effort delete the stale
  connection, then mint a fresh link (replaces the record in place)
- Settings skill item + chat toolbar item: PENDING/ERROR now render a ··· menu
  with Re-authorize (fresh link) and Delete
- Onboarding: pending/errored row click re-mints a fresh link instead of
  reopening the stale one
- i18n: add tools.composio.reauthorize (en-US + zh-CN)

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

* 🐛 fix(composio): return auth link instead of opening popup from agent

connectComposioService runs from the agent's response, which carries no user
gesture, so window.open was blocked by the browser and the flow always failed
with "Authorization was cancelled or timed out". Instead of opening the popup
ourselves, return the authorization redirectUrl in the tool result so the agent
can surface a clickable link — the user's click is a real gesture and completes
the OAuth normally. Drops the now-unused popup/poll helper.

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

* 💄 fix(composio): match pending toolbar item to sibling authorize affordance

The ··· dropdown I added to the chat-toolbar Composio item was a bare icon
(inconsistent color/size with the app's standard menus), its popup was
mis-anchored/offset, and replacing the visible "authorize" cue with a ···
made an un-authorized (pending) row look connected.

Match the sibling LobehubSkillServerItem instead: render a clickable
"Re-authorize" text + external-link icon for PENDING/ERROR. Clicking re-mints a
fresh link (the prior one may have expired) and opens it. No dropdown, so no
offset; the explicit affordance makes it clear the row still needs auth. Delete
stays on the settings page (siblings have no inline delete here either).

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:53:48 +08:00
Arvin Xu f362dcb5db 🐛 fix(agent-runtime): harden async sub-agent suspend/resume against missed wakeups (#15855)
* 🐛 fix(agent-runtime): harden async sub-agent suspend/resume against missed wakeups

The server callSubAgent async park/resume chain (#15481) had a one-shot,
no-retry recovery: a single transient miss left the parent stuck in
waiting_for_async_tool forever. Harden the resume barrier and watchdog
(LOBE-10385 parts 1-3, 5; the park-side deadline fallback follows separately):

- Read-your-writes barrier: completeSubAgentBridge passes the just-backfilled
  toolMessageId to the barrier, which trusts that local write instead of
  re-reading message_plugins from a possibly-stale read replica.
- Bounded backoff watchdog: verifyAsyncToolBarrier now re-arms with exponential
  backoff (15s→30s→60s→120s→240s, 5 attempts) until the barrier passes or the
  op is terminal, replacing the single 15s shot that never re-armed.
- Plug silent bails: !state and pending.length===0 now warn + emit a metric;
  the empty-pending case also arms a fallback verify for snapshot-persist lag.
- Observability: new agent_runtime_async_tool_resume_total counter keyed by
  outcome (resumed/barrier_held/no_pending/no_state/lost_cas/verify_exhausted)
  so missed wakeups surface instead of accumulating silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(hetero): reconstruct queued upload files from filesPreview on run continuation

When continuing a heterogeneous agent run with remaining queued messages, rebuild
the upload file items from filesPreview metadata instead of passing bare { id }
stubs, so file context (name/type/preview) survives the continuation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:50:05 +08:00
Arvin Xu 3c43d55c69 🐛 fix: render mention name from serialized attribute instead of falling back to unknown (#15831)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:46:19 +08:00
Arvin Xu fa17f00d56 ♻️ refactor(swr): converge remaining store-layer SWR keys (discover/tool/global/userMemory) (#15853)
♻️ refactor(swr): converge remaining discover/tool/global/userMemory store keys

Completes the store-layer SWR key convergence into the central registry
(batch3 only partially covered discover). Migrates the remaining ~39 ad-hoc
keys:

- discover: model/plugin/provider/skill/mcp/groupAgent list+detail+categories
  and user profile (the `.join('-')` string keys → registry array factories).
- tool: agentSkills, installedPlugins, builtin uninstalled-tools, lobehubSkill
  store, mcpPluginList, klavis store. (The dynamic `plugins`-array key is left
  as-is — it's data-derived, not a named key.)
- global: latest/server version, system status.
- userMemory: retrieve / memoryDetail / activities / contexts / experiences /
  identityList / preferences. The `purgeAllMemories` invalidation was rewritten
  from `startsWith('useFetch…')` string matchers to array `key[0] === *.root`
  matchers, in lockstep with the fetch keys.

No tiering/caching change: all new prefixes (discover/tool/global/userMemory)
are kept out of CACHE_TIERS, so everything stays memory-only as before.
Behavior preserved (key identity, mutate match sets, personal-vs-workspace).
UI-layer keys + the cross-layer `cronTopicsWithJobInfo` remain for the next PR.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:12:35 +08:00
Arvin Xu c9c57bb7ba 🐛 fix(hetero): dedupe subagent thread create on cold replica after finalize (#15849)
The subagent run coordinator keys thread creation purely on the in-memory
`runs` map. On a cold serverless replica / BatchIngester retry the map is
empty, and `refreshSubagentRunsFromDb` only rehydrates `Processing` isolation
threads — a spawn that already finalized (thread flipped `Active`) is excluded.
So a replayed first-event for a finished subagent hits the `!existing` branch of
`ensureRun` and forks a SECOND thread with the identical title ("一模一样的两个
thread"). Sibling of #15838 (main-turn) / #15808 (subagent-turn), but for the
thread-create step.

Fix: give thread creation a DB-homed, status-independent idempotency guard keyed
by `sourceToolCallId`.
- `SubagentRunsState` gains `finalizedParents: Set<string>`; `finalizeRun`
  records the parent there (instead of just deleting the run), so `ensureRun`
  returns a no-op for a replayed finished spawn — no duplicate thread or message.
- `refreshSubagentRunsFromDb` seeds `finalizedParents` from this operation's
  `Active` isolation threads (without resurrecting them as live runs, which would
  mint empty assistants / re-finalize churn).

Regression: subagent reducer unit test (finalize → replay first event → 0
intents) + handler cold-replica test (finished subagent replay → still 1 thread).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:02:21 +08:00
Arvin Xu d6ca168199 ♻️ refactor(swr): converge remaining store-layer SWR keys into swrKeys registry (#15850)
♻️ refactor(swr): converge remaining store-layer keys into swrKeys registry

Migrate all ad-hoc SWR keys still living in the store/service layer onto the
central registry (src/libs/swr/keys.ts), under the uniform `domain:resource`
naming. New domains: discover, eval (agent eval), ragEval, knowledgeBase,
device (incl. git), userMemory, agentKnowledge, agentBot, file, chatTool.

- Pure key convergence: no tiering/caching change. The new prefixes are kept
  deliberately OUT of CACHE_TIERS, so every migrated key stays memory-only
  exactly as before (agentKnowledge:/agentBot: avoid the cached `agent:` tier).
- Behavior preserved: key array shapes, mutate matchers (key[0] === *.root),
  and personal-vs-workspace match semantics are unchanged; string-join keys
  (discover assistant/social) become arrays with equivalent identity.
- UI-embedded SWR keys (features/routes/components/packages) intentionally left
  for a later pass.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:55:24 +08:00
Arvin Xu ae88d7535f ♻️ refactor(swr): centralize session/thread/recent/group keys into swrKeys registry (#15848)
* ♻️ refactor(swr): migrate session/thread/recent/group-list keys into swrKeys registry

Batch 1 of the SWR key centralization: add session/thread/recent keys and
group:list to the registry under the domain:resource convention, migrate call
sites + mutate matchers, update the localStorage tier patterns (recent:list,
group:list), and update tests. Removes the ALL_RECENTS_DRAWER_SWR_PREFIX export
in favor of recentKeys.allDrawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(swr): version+unify message key, drop isLogin from keys, migrate agent/aiModel/image/video/serverConfig

- message: drop `listLegacy`; both stores use the accurate `message:list` key,
  now carrying MESSAGE_CACHE_VERSION; fix the chat store `refreshMessages` to
  invalidate the real key via a context matcher (was a dead key, never matched).
- keys: remove the redundant `isLogin` arg from all list factories (the app is
  always authenticated); drop the now-unused isLogin param from useFetchSessions.
- migrate agent config/available/search, aiModel, image+video generation, and
  serverConfig keys into the registry; update call sites, mutate matchers, tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(swr): restore isLogin arg in list keys

Re-introduce the isLogin argument across the session/agent/group/recent/brief
list key factories and their call sites (incl. useFetchSessions). The key must
vary with auth state so login/logout transitions invalidate the cached list
instead of serving another user's snapshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(swr): harden tiered cache flush + scope re-hydration

- localStorageProvider: flush both tiers on visibilitychange→hidden (and
  pagehide) instead of beforeunload. IndexedDB writes are async and can't be
  awaited on teardown; flushing while the page is still alive (hidden) gives
  them time to land before unload.
- Query: reset the new scope's hydration readiness before reloadScope() (in a
  layout effect), so the boot gate keeps blocking through the async IDB re-load
  instead of rendering stale data from a previously-visited scope.
- CacheHydrationGate: render the brand logo while gating instead of returning
  null, keeping the hand-off from the static loading screen seamless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 03:04:29 +08:00
Arvin Xu 66370675ab test(chat): characterize parked states + post-persist title wiring (#15847)
Fills the three refactor-critical holes left in the characterization net
(LOBE-10377) — exactly the invariants LOBE-10378/10379/10382 will rewrite.

- client (streamingExecutor): waiting_for_async_tool leaves the op UNcompleted
  (no switch case) and emits an undefined complete-signal status (normalize
  falls through); waiting_for_human completes-for-UI but does NOT drain queue
  or mark unread (parked != terminal).
- gateway (gatewayEventHandler): waiting_for_async_tool park is currently
  treated as a completed + unread terminal (no pause short-circuit), and shares
  the `interrupted` reconciliation branch (preserve streamed content vs DB
  refetch, uiMessages SoT takes precedence).
- lifecycle (conversationLifecycle): post-persist summaryTopicTitle fires on the
  CLIENT path (new-topic OR empty-title gate) and is NOT invoked on the GATEWAY
  path (early return; title handled server-side).

Tests-only; characterization (locks current behavior, incl. suspected gaps with
comments). 135 tests pass across the 3 files.

Part of LOBE-10376
2026-06-15 02:33:57 +08:00
Arvin Xu 457b4638c1 🐛 fix(home): hide agent-mode notice while config is loading (#15846)
Home InputArea computed isAgentConfigLoading but never passed it to
DesktopChatInput, so AgentModeNotice flashed the "model unsupported"
warning during hydration. Forward isConfigLoading like every other
call site so the notice only appears after config loads.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 02:16:24 +08:00
Arvin Xu edf058e325 feat(swr): unified tiered cache provider (localStorage + IndexedDB) with scope isolation (#15844)
*  feat(swr): unified tiered cache provider (localStorage + IndexedDB) with scope isolation

Route SWR persistence to a tier chosen centrally by key — IndexedDB for large
business entities (messages, topics, tasks, documents, agents), localStorage for
small list shells (recents) — instead of stuffing everything into one ~5MB
localStorage blob. Partition every tier by identity scope (`${userId}:${workspaceId}`)
so users/workspaces sharing an origin never collide, and add a boot hydration gate
so local-first data is present before the routed app mounts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(swr): centralize IndexedDB-tier keys into swrKeys registry with domain:resource naming

Introduce src/libs/swr/keys.ts as the single source of truth for SWR cache keys,
named uniformly as `<domain>:<resource>` (e.g. message:list, topic:list,
task:detail). Migrate the IndexedDB-tier domains (message, topic, agent, group,
task, document/page/notebook, brief) off scattered local consts/inline literals
onto registry factories, updating call sites, mutate matchers, and tests. The
tiered cache provider now routes by `domain:` prefix instead of ad-hoc
substrings, and matchDomain() enables refreshing a whole domain at once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 02:02:09 +08:00
Innei c740b13021 🐛 fix(provider): correct delete confirm z-index by switching to base-ui modal (#15845)
* 🐛 fix(provider): use base-ui modal so delete confirm stacks above the config dialog

Closes #15836

* 💄 style(provider): split delete confirm into short title and description

* 🌐 chore(i18n): sync delete confirm title/description across all locales
2026-06-15 01:53:27 +08:00
Arvin Xu f9e7ca5b68 test(chat): characterization net for agent runtime run-lifecycle (#15843)
*  test(chat): characterization net for agent runtime run-lifecycle

Lock the CURRENT client / gateway / heterogeneous run-completion behavior
across all terminal branches BEFORE the unified run-lifecycle refactor
(LOBE-10376), so any behavioral drift is caught by tests.

- client (streamingExecutor): afterCompletion fires on error terminal;
  complete-signal status=failed on error; queue-drain + markUnread skipped
  on error (negative); desktop-notification gating (content && !tools)
- gateway (gatewayEventHandler): error event completes op WITHOUT markUnread
  (asymmetry vs agent_runtime_end); completeOperation double-call idempotency
- hetero (heterogeneousAgentExecutor): notification + dock badge on success;
  updateTopicMetadata-rejection behavior; queue-drain gating
  (success / !aborted / !error); error & abort paths fire no notification/drain
- entry points: regenerate-hetero (imageList + parentOperationId +
  onRegenerateComplete), continue-hetero early-return, rejectAndContinue
  client dual-op, submitHeteroIntervention IPC submit + GC fallback

Tests-only; no implementation changes. 255 tests pass across the affected files.

Part of LOBE-10376
Closes LOBE-10377
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(chat): surface executor rejections in hetero completion helpers

The clean-completion `runToComplete` helper (and its sibling `runToError`)
awaited the executor with `.catch(() => {})`, swallowing any rejection. Both
paths resolve today, so this only masked future regressions: a happy/error
run that starts rejecting after some side effects would still pass — the
isDesktop=false "no notification" negative assertion is especially vulnerable
since an early rejection before the notification step trivially satisfies it.

Await the executor promise directly so a rejection fails the characterization
test instead of passing silently. 70/70 still green (both paths resolve today).

Part of LOBE-10376

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:49:48 +08:00
Arvin Xu 542197d8ab 🐛 fix(hetero): correct cold-replica main-turn idempotency and mark topic failed on terminal errors (#15838)
* 🐛 fix(server): dedupe replayed main-turn newStep on a cold replica

The main-agent coordinator cuts a turn purely on the adapter's `newStep` signal and minted a fresh random assistant id each time, with no DB-homed idempotency key for the turn (unlike the subagent path after #15808). On a cold serverless replica the in-memory `processedKeys` dedupe is empty, so a BatchIngester retry reprocesses the `newStep` and `openTurn` forks a second assistant — orphaning the first as a usage-only empty shell (the remote-CC "空壳" bubble).

Mirror #15808 onto the main chain: the adapter emits the turn's CC `message.id` on `stream_start{newStep}`; the reducer records it as `currentMainMessageId` and treats a same-id `newStep` as a replay (no-op); the server stamps it on `metadata.mainMessageId` and recovers it on a cold replica. Backward-compatible: a `newStep` without a message id opens a turn as before.

Regression: HeterogeneousPersistenceHandler.mainTurnRehydration.test.ts (cold-replica retry: 2 assistants + empty shell -> 1) plus 4 mainAgentCoordinator reducer unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(cli): mark topic failed when remote CC relays a terminal error on a clean exit

Claude Code relays API/rate-limit errors as an in-stream terminal `error`
event but still exits 0. The CLI derived the heteroFinish result from the
process exit code alone, so such runs reported `result: 'success'` →
`reason: 'done'` and the topic/task was wrongly marked completed instead of
failed (the error was only persisted on the message).

Track whether a terminal `error` event was pushed to the ingester and force
`result: 'error'` even on a clean exit, mirroring the desktop executor where
the stream error drives both the message error and the topic status. Also
surface the terminal error message as the finish error detail (CC relays these
on stdout, so stderr is empty in this case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:00:19 +08:00
Arvin Xu 507f251ac5 🔨 chore(agent-runtime): enable S3 tracing by default in production (#15841)
 feat(agent-runtime): enable S3 tracing by default in production
2026-06-15 00:56:20 +08:00
Rdmclin2 d3cc667c97 fix: workspace preifx (#15837)
chore: remove workspace prefix
2026-06-14 22:17:19 +08:00
LiJian 346d5be27c feat(connectors): add edit/uninstall buttons for connectors in SkillDetail (#15829)
* 🐛 fix: clear credentials on URL change; gate Edit button to http connectors

P1 (AddConnectorModal): when handleEdit detects a URL change, pass
credentials: null so the server drops the old OAuth token — a stale token
from the previous server must not be sent to the new one. The server-side
update mutation now also clears tokenExpiresAt in the same round-trip
whenever credentials are set to null.

P2 (ConnectorDetail): narrow the Edit button (and the modal mount) from
isMcpConnector to isMcpConnector && connector.mcpConnectionType === 'http'.
stdio connectors have no mcpServerUrl, so the URL-edit dialog would open
with an empty field and mislead the user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

*  feat(connectors): add edit/uninstall buttons for SkillDetail connectors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix: re-enable OAuth in edit mode + pre-fill bearer/header credentials

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix: resolve TypeScript errors in CustomConnectorModal edit mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix: add clientId/clientSecret to mcp.auth type to resolve TS error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix: correct description field location in editValue

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:55:49 +08:00
Arvin Xu 43c91caf6a 📝 docs: add capability-gated feature checklist to ux skill (#15832)
* 📝 docs: add capability-gated feature checklist to ux skill

Guide designers to fulfil the reminder obligation when a selected model
or its still-loading config can't deliver a feature's required capability
(e.g. agentic tool calling): surface a soft, reactive, load-gated warning
with the remedy, rather than failing silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs: broaden ux skill trigger to any UI work

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs: simplify ux skill description

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:38:31 +08:00
Arvin Xu 602e768419 🐛 fix(page-editor): isolate page copilot context from global agent/document state (#15826)
* 🐛 fix(page-editor): isolate page copilot context from global agent/document state

Two independent bugs both rooted in the page conversation context leaning on
process-global singletons that can't express multiple tabs/documents:

- Heterogeneous agents (Claude Code / Codex) leaked into the page copilot:
  `selectedAgentId` only excluded empty and chat-group ids, so navigating from
  a heterogeneous agent tab made the page right panel run that external agent.
  Also fall back to the page agent when the active agent is heterogeneous.

- `documentId` was lost in multi-tab scenarios because the conversation context
  carried no documentId and relied on the `pageAgentRuntime` singleton, which
  represents only one open document and is cleared on tab switch — causing
  "PageAgent server runtime received a tool call without documentId". Inject the
  editor's `pageId` straight into `context.documentId` so the send-time guard
  uses a deterministic value instead of the singleton.

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

* 🐛 fix(page-editor): include documentId in the page conversation key

The previous fix injected `documentId` into the conversation context, but all
state isolation (messages, operations, input-loading/runtime selectors,
replaceMessages) is keyed through `messageMapKey(context)`, which dropped
`documentId` entirely for page scope. Two documents sharing the page agent thus
collapsed into one `page_<agent>_new` bucket — document B could inherit A's
copilot history or be queued behind A's running operation while tool calls now
target B.

Carry `documentId` into the page-scoped key (as subTopicId) so each open
document gets its own isolated bucket; topicless page keys avoid emitting a
literal `null` segment, and the no-document case still falls back to
`page_<agent>_new` without colliding with document-specific keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:29:42 +08:00
Arvin Xu 87966afec8 🐛 fix(chat): warn when agent mode is on but the model lacks tool calling (#15828)
 feat(chat): warn when agent mode is on but the model lacks tool calling

Show a warning above the desktop chat input when Agent mode is enabled
but the selected model does not support function/tool calling, suggesting
switching to a model with agent capability.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:17:58 +08:00
Arvin Xu 8b59a71f29 feat(topic): add queryTopics query with server-side status filter (#15822)
*  feat(topic): add queryTopics query with server-side status filter

Adds `topicModel.queryTopics({ statuses?, pageSize? })`, a lambda `queryTopics`
TRPC procedure, and `topicService.queryTopics` — filtering topics by status
server-side (e.g. to list actively-running topics across all agents without
pulling the full topic set to the client).

Removes the now-unused `getAllTopics` procedures (lambda + mobile),
`topicModel.queryAll`, and the `getAllTopics` service method.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(topic): ownership isolation tests for queryTopics; authed mobile getTopics

- queryTopics: assert it only returns the model user's topics (a status filter
  must not leak another user's data) and that personal vs workspace scopes stay
  isolated.
- mobile getTopics: switch from publicProcedure to the authed topicProcedure
  (drops the manual userId guard + ad-hoc TopicModel construction).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:43:32 +08:00
Arvin Xu 097987a262 📝 docs(skills): add ux design-values & execution-checklist skill (#15823)
Define LobeHub's four product design values — 自然 Natural / 意义感 Meaningful /
确定性 Certainty / 生长性 Growth (adapted from Ant Design's values) — in a
dedicated reference file (references/design-values.md), and keep the skill index
focused on per-aspect execution checklists, each tagged with the value it serves:

- Flow & momentum: push the user forward; success state = primary "go to result".
- States: empty / loading / error all designed; empty is a purpose-built page.
- Buttons & focus: exactly one primary button per surface.
- Lists at scale: design for 1 → 10k rows (virtual scroll / pagination / batch).
- Option visibility: pickers list all valid targets (e.g. the virtual inbox).
- Loading visuals: no antd Spin; use NeuralNetworkLoading / project loaders.
- Discoverability & growth: progressive disclosure; surface next capability in context.
- Entity lifecycle completeness: no display-only features — design full CRUD +
  lifecycle, with the operation set scoped to the entity's source (official =
  read-only, community = install/uninstall, custom = full CRUD).

Also: react skill points to ux for loading components, and AGENTS.md references
the ux skill for designing/reviewing user-facing flows.
2026-06-14 19:37:17 +08:00
Arvin Xu 455c25ed1b feat(topic): add bulk move topics to another assistant UI (#15809)
*  feat(topic): add bulk move topics to another assistant UI

Surface the batch-move feature in the per-agent Topics manager:

- `MoveToAgentButton`: a bulk action that opens an assistant picker
  (excludes the source agent) and moves the selected topics over.
- Wire it into `BulkActionBar` next to favorite/archive/delete.
- `batchMoveTopicsToAgent` store action: calls `topicService.batchMoveTopics`,
  optimistically drops moved topics from the current list, refreshes, and
  switches away if the active topic was moved.
- i18n keys (en-US source + zh-CN) for the move action, picker, and toast.

Depends on the server `topic.batchMoveTopics` mutation (already on canary).
Part of LOBE-10330

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(topic): add per-topic move menu + confirm/progress move modal

Address review feedback on the move-topics UI:

- Add a "move to another assistant" item to the per-topic dropdown menu in
  the left sidebar topic list (single-topic move).
- Introduce a shared MoveTopicsModal (base-ui) with a pick → confirm →
  moving → done state machine: a confirmation step before the move, an
  in-progress "Moving…" view that locks dismissal, and a "moved" completion
  view. Both the bulk action and the per-topic menu open this modal.
- BulkActionBar's move button now opens the modal instead of a popover +
  toast, so multi-select moves get the confirm + progress + done flow.
- i18n: add management.moveModal.* + actions.moveToAgent (en-US + zh-CN);
  drop the now-unused management.bulk.moveSuccess toast keys.

Part of LOBE-10330

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(topic): allow moving topics to the inbox (LobeAI) assistant

The move picker sourced agents from the sidebar list, which excludes the
virtual inbox agent — so the default "LobeAI" assistant could never be
chosen as a move target (picker showed "no other assistants"). Prepend the
inbox agent to the target list (unless it is the source), mirroring
AssigneeAgentSelector. The DB-layer ownership check already accepts the
inbox agent, so moving into it is valid.

Part of LOBE-10330

* 💄 style(topic): use NeuralNetworkLoading for the move-in-progress state

Replace the antd Spin in the move modal's "moving" step with the project's
NeuralNetworkLoading, matching the product loading visual. Also document the
rule in the react skill: antd Spin is forbidden — use NeuralNetworkLoading
(or the other src/components loaders) instead.

Part of LOBE-10330

* 💄 style(topic): add "go to target assistant" action on move success

On the move modal's done step, make "Done" a secondary (weak) button and add
a primary "Go to <target>" button that navigates to the assistant the topics
were moved into, so the user can jump straight to the relocated topics.

Part of LOBE-10330

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:41:52 +08:00
Arvin Xu 6c8bcf0c8a 🐛 fix(conversation): stop tool workflow collapse showing 'in progress' once content renders below it (#15815)
🐛 fix(conversation): stop tool workflow collapse showing "working" once content renders below it

When an assistant group is still generating, a workflow segment can have a real
answer segment rendered below it — most notably an errored tool block, which
splits into a folded workflow (the tools) plus a trailing answer segment (the
error text). The group-level `workflowChromeComplete` only accounts for the
promoted-final-answer path (`postToolTailPromoted`), so in these cases the
collapse kept rendering its streaming "working" header even though the model had
already moved past it and content was visible below.

Derive completeness from segment ordering: a workflow segment that has any
rendered content after it is no longer the active step. Add
`hasRenderedContentAfter` and OR it into the per-segment `workflowChromeComplete`.

Guard the shortcut with `hasPendingIntervention`: `areWorkflowToolsComplete`
ignores pending-intervention tools and the "awaiting confirmation" UI only shows
while streaming, so a segment still awaiting user confirmation must keep its
streaming chrome even with content below it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:31:16 +08:00
Arvin Xu 32cf754ae3 🐛 fix(chat): derive operation token usage from messages, not a parallel accumulation (#15819)
The operation status tray maintained its OWN running token total by summing
every `turn_metadata` event's usage (`addUsageToOperationMetrics`), separate
from the per-message usage written via `recordUsage`. The two diverged badly:
in an agentic Claude Code loop the tray showed ~8M while the per-message bubbles
summed to ~2.2M.

Root cause is two computations for one number:
- `recordUsage` OVERWRITES each assistant message's usage (last turn wins when
  multiple turns map to one message).
- the tray ADDED every turn's usage — and each turn's `totalTokens` includes
  `cache_read_input_tokens`, so a re-read context got counted once per turn.

Make the per-message usage the single source of truth: `OpStatusTray` always
derives the total via `calculateOperationUsageMetrics(messages)` (previously
only a fallback), and the parallel `addUsageToOperationMetrics` accumulation is
removed from both the heterogeneous-agent executor and the gateway handler. The
tray now equals the sum of the bubbles and refreshes as messages do.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:22:09 +08:00
Arvin Xu 8ee5f1c806 🐛 fix(chat): drop subagent-tagged events from the main gateway stream handler (#15814)
On a live gateway / remote-CC stream, a subagent (Claude Code `Agent`/`Task`)
inner-tool event is tagged with `data.subagent` and belongs to an isolation
Thread, not the main bubble. The gateway path fed raw events straight into
`createGatewayEventHandler` (main-agent-only), so a subagent `tools_calling`
chunk appended the inner tool onto the MAIN assistant's `tools[]` — the tools
"leaked" into the parent bubble DURING streaming, then snapped back when the
terminal `fetchAndReplaceMessages` pulled correct DB state (where they live
under the Thread). Classic "流式时漏出来、结束后正常".

The local desktop executor already drops `data.subagent` events before
forwarding (`heterogeneousAgentExecutor`); the gateway path didn't. Drop them at
the top of the handler — one place that covers every gateway caller, and a
no-op for the local executor (which already pre-drops). DB persistence is
unaffected: the server writes subagent rows under the Thread regardless, so they
still appear — correctly under their Thread — after the terminal fetch.

Regression: a subagent-tagged `tools_calling` chunk no longer dispatches onto
the main assistant (verified red without the drop); a non-subagent chunk still
dispatches.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:02:50 +08:00
Innei 536290973b feat(desktop): tray double-click opens main window (#15816)
Single click on the tray still starts the Quick Composer capture
session, but is now debounced 250ms so a follow-up double-click can
pre-empt it. Double-click surfaces the main window via
browserManager.showMainWindow(). macOS / Windows only; Linux trays
under AppIndicator do not emit click events and remain unaffected.
2026-06-14 16:44:28 +08:00
Innei 46b379f446 💄 style(chat): tighten revert confirm and toast copy (#15813)
* 💄 style(chat): tighten revert confirm and toast copy

Trim the file-revert Popconfirm description from a two-sentence warning
to a single line ("This can't be undone."), and switch the success toast
from full {{filePath}} to just {{fileName}} so it doesn't span the screen
for deep paths. Updated across all 18 locales.

* ♻️ refactor(chat): migrate file revert from Popconfirm to base-ui confirmModal

Per @lobehub/ui/base-ui-first convention. Drops the local confirmOpen/reverting
state and the data-force-visible CSS pin (no longer anchored to the trigger),
and lets confirmModal handle the OK button's in-flight loading.
2026-06-14 16:29:13 +08:00
Arvin Xu 97708c3fbb 🐛 fix(conversation): render mixed assistant blocks in natural order (#15810)
* 🐛 fix(conversation): render mixed assistant blocks in natural order

Drop the `shouldPromoteMixedBlockContent` heuristic that relocated a
tool-bearing block's prose below its tool when the text scored as
"final-answer-like". Within one assistant message the model's text always
precedes its tool_use (tool_use ends the turn; post-tool prose lands in a
separate, tool-less block), so a mixed block's content is always a preamble
and must stay above its tool. This fixes Claude Code turns (e.g.
askUserQuestion) that rendered the tool card above its own explanatory text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(conversation): keep mixed multi-tool preamble outside the workflow fold

A mixed block's prose is a preamble, so in a multi-tool turn lift the full
text into a visible answer segment above the workflow and leave only the
tool(s) in the fold. Previously `leadingSentenceSplit` kept only the first
sentence visible and pushed the remaining prose into the WorkflowCollapse
body, which defaults to collapsed once complete — hiding most of the
explanation until the user expanded it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:26:11 +08:00
YuTengjing 5872468c17 🔨 chore: update testing skill rules (#15807) 2026-06-14 15:33:23 +08:00
Arvin Xu bc9a7cfab8 feat(gateway): move gateway mode to chat config (#15714)
*  feat(gateway): move gateway mode to chat config

*  feat(gateway): add agent gateway env flag
2026-06-14 15:18:40 +08:00
lobehubbot d62843b90b Merge remote-tracking branch 'origin/main' into canary 2026-06-14 07:05:23 +00:00
Arvin Xu 9f1ab92242 🐛 fix(chat): normalize reconnect startTime to epoch ms (#15811)
* 🐛 fix(chat): normalize reconnect startTime to epoch ms

After a DB rehydrate (quit + relaunch), an assistant message's `createdAt`
can arrive as an ISO string / Date rather than epoch ms (the message service
casts rows `as unknown` without converting). The gateway reconnect path
anchored a running operation's `startTime` to that value verbatim, so the
running-elapsed-time label computed `Date.now() - startTime` as NaN and
rendered "NaN:NaN" in the topic list.

Normalize `createdAt` to epoch ms and only set `startTime` when the result is
finite; otherwise fall back to `startOperation`'s default `Date.now()`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(chat): assert reconnect omits startTime via matcher

Avoid indexing mock.calls (TS2532/TS2493 on the untyped spy tuple); use
toHaveBeenCalledWith + expect.not.objectContaining instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:01:58 +08:00
Arvin Xu 73b58d5bba feat(chat): show token usage cache rate (#15812) 2026-06-14 14:44:24 +08:00
renovate[bot] 729393ca1b Update dependency @vitest/coverage-v8 to v3.2.6 (#15802)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-14 14:33:01 +08:00
Arvin Xu 3335072bdb 🐛 fix(topic): scope per-agent topic search by agentId (#15798)
* 🐛 fix(topic): scope per-agent topic search by agentId

The per-agent Topics search resolved agentId→sessionId and filtered only
by the container (sessionId/groupId). Topics created by the new agent
system carry `agentId` directly with a null sessionId, so they were never
matched — the search showed "No topics match these filters" even though
the topics list (filtered by agentId) and global search displayed them.

`queryByKeyword` now accepts an agentId-aware scope mirroring `query`'s
precedence (groupId > agentId > containerId), matching `topics.agentId`
directly while still matching the resolved sessionId for legacy
un-migrated rows. The lambda searchTopics router passes the agentId
through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(topic): align keyword search scope with the topics list

Address review on #15798:
- Drop the resolved-sessionId fallback in the agent branch. The topics list
  (`query`) scopes by agentId only, so the fallback (a) surfaced un-migrated
  rows the list hides and (b) leaked topics owned by another agent that shares
  the same session mapping. `matchKeywordScope` now mirrors `query` exactly:
  groupId > agentId > containerId (the last only for legacy/mobile string args).
- Topic inbox no longer exists, so no isInbox handling is threaded through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:32:33 +08:00
Arvin Xu 01278efdde 🐛 fix(server): persist subagent turn id so cold replicas don't fragment a turn (#15808)
On a cold serverless replica the subagent run is rebuilt from DB, but the run's
turn identity — CC's per-turn `message.id` (`currentSubagentMessageId`) — was the
one field with no DB home, so rehydration hard-set it to ''. The subagent reducer
detects in-thread turn boundaries by comparing that id, so the first event of
every cold batch satisfied `'' !== realId` → a SPURIOUS turn boundary. One CC
subagent turn then fragmented across multiple in-thread assistant rows (text on
one, tools on another), spawned empty-shell assistants (only usage, no
content/tools), and mis-anchored siblings under the same old tool.

Give the turn id a DB home: stamp it on the in-thread assistant's
`metadata.subagentMessageId` at creation (`CreateMessageIntent.subagentMessageId`
→ server interpreter), and recover it in `buildSubagentSnapshot` →
`SubagentRunSnapshot.currentSubagentMessageId` → `rehydrateSubagentRunsState`. A
continuation is then recognized as the SAME turn — no spurious boundary, no
fragmentation, no empty shells. `MessageModel.update` deep-merges metadata, so
later usage/content writes don't clobber the stored id.

Follow-up to #15788 (subagent thread rehydration): that fixed the thread-
duplication half of cold-replica recovery; this fixes the turn-boundary half.

Regression: a CC turn continued on a fresh replica now yields exactly one
in-thread assistant (verified red without the recovery).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:21:40 +08:00
1644 changed files with 73604 additions and 20842 deletions
+10 -6
View File
@@ -111,7 +111,7 @@ First check the repo root for `.env`:
Do not start the standalone e2e server as the product under test.
Use `scripts/init-dev-env.sh`. It follows the e2e setup pattern — Postgres,
migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
Redis, migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
skill and starts the repo's dev server (`pnpm run dev:next` / `bun run dev`),
not `e2e/scripts/setup.ts --start`. The script hard-blocks when root `.env`
exists, so it cannot accidentally override a user's local config. When `.env`
@@ -132,19 +132,19 @@ fi
Bootstrap flow when no `.env` exists:
```bash
# From repo root. Managed DB flow requires Docker Desktop.
# From repo root. Managed Postgres/Redis flow requires Docker Desktop.
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
If using an existing Postgres instead of the managed Docker DB, set
`DATABASE_URL` and skip `setup-db`:
`DATABASE_URL` and `REDIS_URL`, then skip `setup-db`:
```bash
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
For backend-only checks, `dev-next` is available, but Web smoke needs the
@@ -170,6 +170,9 @@ Default script env:
- `APP_URL=http://localhost:3010`
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres`
- `DATABASE_DRIVER=node`
- `AGENT_RUNTIME_MODE=queue` so backend-only agent runtime checks use the
same queued execution path as production
- `REDIS_URL=redis://localhost:6380` for queue-mode agent runtime state
- `FEATURE_FLAGS=-agent_self_iteration` so local smoke does not require QStash
- Local QStash defaults (`QSTASH_URL`, `QSTASH_TOKEN`, signing keys) are exported;
run `init-dev-env.sh qstash` in a separate terminal when the path under test
@@ -177,6 +180,7 @@ Default script env:
- `KEY_VAULTS_SECRET`, `AUTH_SECRET`, auth verification off
- S3 mock vars
- Managed DB container: `lobehub-agent-testing-postgres`
- Managed Redis container: `lobehub-agent-testing-redis`
`seed-user` creates `agent-testing@lobehub.com` / `TestPassword123!` with
onboarding already completed, plus a local API key in
@@ -112,9 +112,14 @@ secret: don't paste it into shared logs, PRs, or commit it anywhere.
1. `$SCRIPT status --surface web` — green? Start testing. Do not ask for a Cookie header.
2. Not green and using the seeded local env → `$SCRIPT web-seed`.
3. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
4. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:`**Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
5. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
3. If repo-root `.env` exists and `web-seed` fails, do **not** seed or modify the current DB; treat it as an existing local environment and use Cookie injection.
4. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
5. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:`**Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
6. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
`ENABLE_MOCK_DEV_USER` is not Web auth. It only affects server-side API context
and does not satisfy Better Auth or stop the SPA from redirecting to `/signin`.
Do not use it as a substitute for `status --surface web` or Cookie injection.
### Using the authenticated session
@@ -48,14 +48,15 @@ curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
```bash
# Start backend only.
# With root .env: use the existing local config.
pnpm run dev:next
# Agent runtime queue mode is required to mirror production async execution.
AGENT_RUNTIME_MODE=queue pnpm run dev:next
# Without root .env: use the self-contained agent-testing env.
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
# Full-stack SPA + backend. Required for Web smoke.
# With root .env:
bun run dev
AGENT_RUNTIME_MODE=queue bun run dev
# Without root .env:
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
@@ -91,6 +92,8 @@ in doubt.
| `ECONNREFUSED` | Server not running — start it |
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
| Stale data / old behavior | Server needs a restart to pick up code changes |
| Agent call runs inline | Set `AGENT_RUNTIME_MODE=queue`, make sure `REDIS_URL` is configured, then restart the server |
| Queue mode needs Redis | Run `init-dev-env.sh setup-db`, or provide `REDIS_URL=redis://...` for an existing Redis |
| QStash workflow failures | Start `init-dev-env.sh qstash` and make sure dev server inherited the script's `QSTASH_*` env |
Marketplace/community endpoints are not part of the local agent-testing auth
@@ -12,16 +12,16 @@
# Usage:
# init-dev-env.sh env # print shell exports
# init-dev-env.sh write [file] # write a source-able env file
# init-dev-env.sh setup-db # start local Postgres and run migrations
# init-dev-env.sh setup-db # start local Postgres/Redis and run migrations
# init-dev-env.sh migrate # run DB migrations against the configured DB
# init-dev-env.sh seed-user # seed the baseline test user + CLI API key
# init-dev-env.sh qstash # run local Upstash QStash dev server
# init-dev-env.sh dev-next # exec `pnpm run dev:next` with this env
# init-dev-env.sh dev # exec `bun run dev` with this env
# init-dev-env.sh clean-db # remove the managed Postgres container
# init-dev-env.sh clean-db # remove the managed Postgres/Redis containers
#
# Overrides:
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres QSTASH_DEV_PORT=8080
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres REDIS_PORT=6380 REDIS_CONTAINER=lobehub-agent-testing-redis QSTASH_DEV_PORT=8080
set -euo pipefail
@@ -32,6 +32,9 @@ SERVER_PORT="${SERVER_PORT:-3010}"
DB_PORT="${DB_PORT:-5433}"
DB_CONTAINER="${DB_CONTAINER:-lobehub-agent-testing-postgres}"
DATABASE_URL="${DATABASE_URL:-postgresql://postgres:postgres@localhost:${DB_PORT}/postgres}"
REDIS_PORT="${REDIS_PORT:-6380}"
REDIS_CONTAINER="${REDIS_CONTAINER:-lobehub-agent-testing-redis}"
REDIS_URL="${REDIS_URL:-redis://localhost:${REDIS_PORT}}"
ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-dev.env"
CLI_ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-cli.env"
AGENT_TESTING_API_KEY="${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}"
@@ -54,6 +57,7 @@ guard_no_root_env() {
}
apply_env() {
export AGENT_RUNTIME_MODE="${AGENT_RUNTIME_MODE:-queue}"
export APP_URL="${APP_URL:-http://localhost:${SERVER_PORT}}"
export AUTH_EMAIL_VERIFICATION="${AUTH_EMAIL_VERIFICATION:-0}"
export AUTH_SECRET="${AUTH_SECRET:-agent-testing-local-auth-secret-32chars}"
@@ -69,6 +73,7 @@ apply_env() {
export QSTASH_NEXT_SIGNING_KEY="${QSTASH_NEXT_SIGNING_KEY:-$QSTASH_LOCAL_NEXT_SIGNING_KEY}"
export QSTASH_TOKEN="${QSTASH_TOKEN:-$QSTASH_LOCAL_TOKEN}"
export QSTASH_URL="${QSTASH_URL:-http://127.0.0.1:${QSTASH_DEV_PORT}}"
export REDIS_URL
export S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-agent-testing-access-key}"
export S3_BUCKET="${S3_BUCKET:-agent-testing-bucket}"
export S3_ENDPOINT="${S3_ENDPOINT:-https://agent-testing-s3.localhost}"
@@ -78,6 +83,7 @@ apply_env() {
env_keys() {
printf '%s\n' \
APP_URL \
AGENT_RUNTIME_MODE \
AUTH_EMAIL_VERIFICATION \
AUTH_SECRET \
DATABASE_DRIVER \
@@ -92,6 +98,7 @@ env_keys() {
QSTASH_NEXT_SIGNING_KEY \
QSTASH_TOKEN \
QSTASH_URL \
REDIS_URL \
S3_ACCESS_KEY_ID \
S3_BUCKET \
S3_ENDPOINT \
@@ -137,6 +144,15 @@ wait_for_db() {
printf '\n'
}
wait_for_redis() {
printf ' waiting for Redis'
until docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; do
printf '.'
sleep 1
done
printf '\n'
}
start_db() {
require_docker
@@ -157,6 +173,25 @@ start_db() {
wait_for_db
}
start_redis() {
require_docker
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
ok "Redis container already running: $REDIS_CONTAINER"
elif docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker start "$REDIS_CONTAINER" > /dev/null
ok "started existing Redis container: $REDIS_CONTAINER"
else
docker run -d \
--name "$REDIS_CONTAINER" \
-p "${REDIS_PORT}:6379" \
redis:7-alpine > /dev/null
ok "created Redis container: $REDIS_CONTAINER"
fi
wait_for_redis
}
migrate_db() {
apply_env
cd "$REPO_ROOT"
@@ -327,9 +362,11 @@ cmd_status() {
apply_env
echo "agent-testing local dev env:"
note "APP_URL=$APP_URL"
note "AGENT_RUNTIME_MODE=$AGENT_RUNTIME_MODE"
note "DATABASE_URL=$DATABASE_URL"
note "PORT=$PORT"
note "QSTASH_URL=$QSTASH_URL"
note "REDIS_URL=$REDIS_URL"
if command -v docker > /dev/null 2>&1; then
ok "docker CLI available"
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
@@ -337,6 +374,11 @@ cmd_status() {
else
note "managed Postgres is not running: $DB_CONTAINER"
fi
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
ok "managed Redis running: $REDIS_CONTAINER"
else
note "managed Redis is not running: $REDIS_CONTAINER"
fi
else
bad "docker CLI is not available"
fi
@@ -373,6 +415,15 @@ cmd_clean_db() {
else
note "Postgres container not found: $DB_CONTAINER"
fi
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker stop "$REDIS_CONTAINER" > /dev/null
fi
if docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker rm "$REDIS_CONTAINER" > /dev/null
ok "removed Redis container: $REDIS_CONTAINER"
else
note "Redis container not found: $REDIS_CONTAINER"
fi
}
usage() {
@@ -391,6 +442,7 @@ case "$COMMAND" in
write) shift; write_env "${1:-}" ;;
setup-db)
start_db
start_redis
migrate_db
;;
migrate) migrate_db ;;
@@ -81,6 +81,7 @@ SERVER_URL="${SERVER_URL:-$(default_server_url)}"
SESSION="${SESSION:-lobehub-dev}"
AUTH_DIR="${AUTH_DIR:-$HOME/.lobehub-agent-testing}"
STATE_FILE="$AUTH_DIR/web-state.json"
ROOT_ENV_FILE="$REPO_ROOT/.env"
CLI_HOME_NAME="${LOBEHUB_CLI_HOME:-.lobehub-dev}"
CLI_HOME="$HOME/${CLI_HOME_NAME#/}"
CLI_CREDENTIALS_FILE="$CLI_HOME/credentials.json"
@@ -481,8 +482,13 @@ PY
if [[ ! "$code" =~ ^[23] ]]; then
bad "seed user sign-in failed at $SERVER_URL/api/auth/sign-in/email (http_code='$code')"
note "make sure the seed user exists:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
if [[ -f "$ROOT_ENV_FILE" ]]; then
note "root .env exists; do not seed or modify this DB for Web auth."
note "Use Chrome Cookie injection instead: $0 open-chrome, then pbpaste | $0 web"
else
note "make sure the seed user exists:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
fi
return 1
fi
@@ -517,6 +523,7 @@ cmd_web_verify() {
bad "failed to open $SERVER_URL in agent-browser session '$SESSION'"
return 1
fi
agent-browser --session "$SESSION" wait --load networkidle > /dev/null 2>&1 || true
local url
url=$(agent-browser --session "$SESSION" get url 2> /dev/null || true)
if [[ -z "$url" ]]; then
+5 -1
View File
@@ -38,7 +38,7 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
## Default Debug Order
1. Prove whether the raw CLI output is correct before touching UI code.
1. Prove whether the raw CLI output is correct before touching UI code. The app records every real session — read the most recent one via `cat .heerogeneous-tracing/.last-live-trace` rather than hand-rolling a `claude -p` repro (see references/debug-workflow\.md §2).
2. If raw output is correct, compare it with adapter output. In dev, `executeHeterogeneousAgent` exposes `window.__HETERO_AGENT_TRACE`.
3. If adapted events look correct, inspect `persistToolBatch`, `persistToolResult`, step transitions, and subagent routing.
4. Turn the repro into a focused test before fixing.
@@ -77,6 +77,10 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
look for `tool_result for unknown toolCallId` and missing `result_msg_id` backfill.
- Subagent tools show up in the main bubble:
check for subagent chunks reaching the main gateway handler.
- Wrong terminal-error guide (e.g. "usage limit reached" shown for a network drop):
a classifier is branching on a structured field whose mere presence isn't its meaning.
Grep the field across all event states in a real trace before trusting it — see
references/debug-workflow\.md §8 (CC `rate_limit_info` rides on `status: "allowed"` too).
## References
@@ -3,12 +3,13 @@
## Contents
1. Pipeline map
2. Capture raw CLI traces first
2. Capture raw CLI traces first (incl. in-app live traces)
3. Compare raw and adapted events
4. Check step boundaries before persistence
5. Check tool persistence invariants
6. Focused tests
7. Repro-to-fix workflow
8. Verify a structured-field classifier against a real trace
## 1. Pipeline Map
@@ -27,6 +28,54 @@ Start at the leftmost broken layer. Do not jump straight to UI rendering unless
## 2. Capture Raw CLI Traces First
### In-app live traces (the faithful capture — prefer this)
The running app already records every CLI session it spawns. This is the most
faithful trace you can get, because it captures the **exact** spawn args, env
keys, cwd, `--resume`/`--mcp-config` flags, model, and stdin that the app used —
things a hand-rolled `claude -p` / `codex exec` repro will not reproduce. Reach
for this before reproducing manually. The recorder lives in
`apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
(`createCliTraceSession`, `shouldTraceCliOutput`, `resolveTraceRootDir`).
When it records:
- **Dev build** (`!app.isPackaged`): always.
- **Packaged build**: only when the user flips the Help-menu developer toggle
(`heteroTracingEnabled`). Off by default so normal runs aren't polluted.
- Never under `NODE_ENV=test`.
Where it writes:
- Toggle **off** (plain dev run): `<cwd>/.heerogeneous-tracing/` — i.e. inside
the repo you're running against. (Yes, the dir name is misspelled
`heerogeneous`; it is the real path.)
- Toggle **on**: `<appStoragePath>/heteroAgent/tracing/` — keeps traces out of
the user's project. This is the only path packaged builds ever use.
Layout per session — `.../<agentType>/<YYYYMMDD-HHMMSS>-<sessionId>/`:
- `meta.json` — spawn `args`, `command`, `cwd`, `envKeys`, `model`,
`resumeSessionId`/`agentSessionId`, attachment summaries. **Read this first**
to know exactly how the CLI was invoked.
- `stdin.txt` — the stream-json request fed to the CLI.
- `stdout.jsonl` — the raw provider NDJSON (the trace you actually read).
- `stderr.log` — CLI stderr.
- `exit.json``{ code, signal, finishedAt }`.
`.heerogeneous-tracing/.last-live-trace` always points at the most recent
session dir, so the fast path to "what just happened" is:
```bash
dir=$(cat .heerogeneous-tracing/.last-live-trace)
cat "$dir/meta.json" # how the CLI was spawned
wc -l "$dir/stdout.jsonl" # raw event count
```
Reproduce the same session yourself by reusing the recorded `meta.json` `args`
together with `stdin.txt` (the args already include `--resume <sessionId>`),
instead of guessing flags.
### Codex raw JSONL
Use a read-only prompt and save traces under the repo-local scratch directory `.heerogeneous-tracing/`.
@@ -244,3 +293,55 @@ When the bug comes from a real trace, distill it into the closest existing test
6. Only then do an Electron smoke test with the `agent-testing` skill if UI confirmation is still needed.
Do not start with a broad Electron repro if a raw trace or adapter test can prove the fault zone faster.
## 8. Verify A Structured-Field Classifier Against A Real Trace
Whenever the adapter **branches on a structured field** from the raw stream —
`status`, `usage`, `rateLimitType`, `stop_reason`, `parent_tool_use_id`,
`subtype`, etc. — do not trust your mental model of the wire format. The field
you key on almost always also appears on **benign / non-target** events, and a
classifier that ignores the surrounding state will misfire on those.
The procedure (recurring — run it every time):
1. Pull the most recent real session: `dir=$(cat .heerogeneous-tracing/.last-live-trace)`.
2. Grep the field across **every** event state, not just the failing one, and
count by co-occurring state. Example:
```bash
# Which event statuses carry a rate_limit_info block?
grep -o '"status":"[a-z]*"' "$dir/stdout.jsonl" | sort | uniq -c
grep -c 'rate_limit_info' "$dir/stdout.jsonl"
```
3. If the field rides on states you did not account for, the classifier needs an
extra gate. Add the trace as a fixture/assertion to the adapter test so the
regression can't come back.
### Worked example: CC usage-limit vs. transient throttle (`fix/cc-rate-limit-quota-misclassify`)
- **Symptom:** an unrelated terminal failure (e.g. an `ECONNRESET` network drop)
rendered a bogus "usage limit reached, resets at X" guide.
- **What the trace showed:** Anthropic stamps a `rate_limit_info` block —
carrying `resetsAt` and `rateLimitType` (e.g. `seven_day`) — onto events even
when the request **goes through** (`status: "allowed"`). In real traces those
reset-window fields appear on \~all `rate_limit_info` blocks, the vast majority
of which are `allowed`, not `rejected`. So the window is rolling-window
_metadata for an allowed call_, NOT evidence the limit was hit.
- **The bug:** `isUserQuotaRateLimit` keyed only on the presence of a reset
window (`info.resetsAt != null || info.rateLimitType != null`). A later
terminal error inherited the last allowed event's window → false positive.
- **The fix:** require `status === 'rejected'` **and** a concrete reset window.
A bare `rejected` with no window is the transient server throttle → leave it
to the overloaded (retry) classifier. Status codes (429 / 529) and message
text are deliberately not consulted — only this structured signal decides the
guide.
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts` →
`isUserQuotaRateLimit`
- regression assertions in
`packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
The general lesson: a field's **presence** is not its **meaning**. Confirm which
event states a discriminator field co-occurs with in a real recorded trace
before branching on it.
+7
View File
@@ -53,6 +53,12 @@ For Modal specifically, see the dedicated **modal** skill — use the imperative
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
| Navigation | Burger, Menu, SideNav, Tabs |
## Loading indicators
**Do NOT use antd `Spin` / `<Spin />`.** Use a project loader
(`NeuralNetworkLoading`, `DotsLoading`, …) — see the **ux** skill ("Loading
visuals") for the component table and when to use each.
## State
When a feature component manages more than 3 pieces of state (`useState`/`useReducer`/derived state), extract the logic into a custom hook (e.g. `useXxx`). Keep the component focused on rendering — the hook holds state and handlers, so logic can be unit-tested without rendering the component.
@@ -112,6 +118,7 @@ errorElement: <ErrorBoundary />;
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
| antd `Spin` / `<Spin />` for loading | Use `NeuralNetworkLoading` / project loaders (see the **ux** skill) |
| `import { Select } from '@lobehub/ui'` | `import { Select } from '@lobehub/ui/base-ui'` |
| `import { Modal } from '@lobehub/ui'` + `<Modal open>` declarative | `createModal` / `confirmModal` from `@lobehub/ui/base-ui` (see modal skill) |
| `import { DropdownMenu/Popover/Switch } from '@lobehub/ui'` | Import same name from `@lobehub/ui/base-ui` instead |
+2 -2
View File
@@ -18,8 +18,8 @@ Periodic review of the project-local skill set under `.agents/skills/`. The goal
Build a fresh census of all SKILL.md files. Do NOT trust any prior cached list.
```bash
find .agents/skills -name SKILL.md | wc -l # total count
find .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length
find -L .agents/skills -name SKILL.md | wc -l # total count, including symlinked skills
find -L .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length, including symlinked skills
```
Group by domain in a mental table (DB / state / UI / agent / testing / workflow / docs / etc.). Note new arrivals since last audit (`git log --since="1 week ago" -- .agents/skills/`).
+3
View File
@@ -43,6 +43,9 @@ cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only'
2. **Tests must pass type check** - Run `bun run type-check` after writing tests
3. **After 1-2 failed fix attempts, stop and ask for help**
4. **Test behavior, not implementation details**
5. **Regression tests for bug fixes** - After fixing a bug, add a regression test that fails before the fix and passes after, to prevent recurrence
6. **No new component tests** - Only update existing React component tests. Complex logic should be extracted into hooks and tested there instead
7. **All source changes before any test changes** - Complete all source file edits first, then update tests in a separate pass. Interleaving disrupts reasoning about the source changes, especially across many files
## Basic Test Structure
+318
View File
@@ -0,0 +1,318 @@
---
name: ux
description: 'LobeHub product design values / principles / checklists. Load this skill whenever the work touches user-interface features or implementation — designing or building any user-facing flow — to get better UX results.'
user-invocable: false
---
# UX — Design Values & Execution Checklists
How LobeHub products should feel, and concrete rules to get there. Use this when
**building or reviewing** any user-facing flow. For component/styling choices see
**react**, for wording see **microcopy**, for imperative modal wiring see **modal**.
## Design values
LobeHub follows four product design values — **Natural・Meaningful・Certainty・
Growth**. Read them before designing:
**[references/design-values.md](references/design-values.md)** (definitions +
conflict priority).
> The checklists below are the execution layer. Each item is tagged with the
> value(s) it serves; for what those values mean, see the file above.
## How this is organized
The checklists are grouped by **interaction type** — the kind of thing the user
is doing. Jump to the module that matches the surface you're building (reading a
list, editing content, running an action, …); each module collects the rules
specific to that interaction. The same surface often spans several modules (an
editable list is Read + Edit + Act) — walk each that applies.
---
## 1. Read — viewing data & lists
Any surface that **displays** records, lists, or detail. Covers the states a data
view can be in, behavior at scale, and keeping the user's place visible.
### 1.1 Data states: empty / loading / error・Meaningful・Certainty
Every data surface has **four** states — design all of them, not just "has data".
- [ ] **Empty state is a purpose-built page, not a blank screen.** It explains what
this is, why it's empty, and gives a clear next action (CTA + value props).
✅ Devices: an empty "Connect your first device" page with primary/secondary
connect paths and "what you can do once connected" cards — ❌ not a bare title
over skeleton rows or a blank body. _(Meaningful)_
- [ ] **Distinguish the empty variants** — "no data yet" (onboarding CTA) vs
"no match for filters" (clear-filters affordance) are different screens. _(Certainty)_
- [ ] **Always-rendered chrome still needs a body empty state.** When a surface
keeps its toolbar / header mounted even with no data (so a create / `+`
affordance stays reachable), the **body** below it must still render an empty
placeholder — persistent chrome is not an excuse to leave the content area
blank. ✅ The agent **Documents** tab keeps its new-folder / new-doc toolbar
and renders an `Empty` below it when there are no documents — ❌ not a toolbar
over dead space. _(Meaningful)_
- [ ] **Loading state** designed (skeleton / NeuralNetworkLoading), not a flash of
blank or layout shift. _(Natural)_
- [ ] **Error state** designed — surface the reason and a retry/back path. _(Meaningful)_
### 1.2 Lists at scale・Certainty・Natural
A list/data page must be designed for its **whole range of sizes**, not just the
demo data.
- [ ] **Walk the scale: 1 / 2 / 5 / 20 / 100 / 1k10k rows.** Pick the right
mechanism per range — plain render → load-more / pagination → virtual scroll;
add batch-select / bulk actions once counts get large. _(Certainty)_
- [ ] **Co-design empty / loading / error with the data state** (see §1.1). A list
isn't done until all four render well. _(Natural)_
### 1.3 Selection visibility in scrolled lists・Certainty・Natural
A capped / scrollable / virtualized list mounts at `scrollTop = 0`. If the
active item sits below the fold, the user lands on a valid selection that is
**off-screen** — and reads it as "nothing is selected" or a broken page. Any
list that can open with a pre-selected item must **scroll that item into view**.
This is an easy case to miss: it only shows up once the list is long enough and
the selection is restored rather than freshly clicked.
- [ ] **Scroll the active item into view on mount / restore.** When the selection
is restored from a URL query, deep link, or persisted state (not a fresh
click), bring it into view — the container starts at the top otherwise. ✅
The nested thread list is capped to \~9 rows; a thread restored from
`?thread=` below the fold is scrolled into view on mount. _(Certainty)_
- [ ] **Hardest when the selection has no other anchor.** If the parent/container
row isn't highlighted while a child is active (no breadcrumb, no header
echo), an off-screen active row means **zero** visible feedback — design
for exactly this case. _(Meaningful)_
- [ ] **Use `block: 'nearest'` (or equivalent).** Only scroll when the row is
actually off-screen; an already-visible selection must not jump. _(Natural)_
- [ ] **Re-run once async rows mount.** The active id is usually known before the
list finishes loading; key the scroll off a list-ready signal (e.g. row
count), not only off the id, so a restored selection still lands when the
data arrives. _(Certainty)_
- [ ] **Mirror it across duplicated list variants** so the behavior can't regress
in just one (e.g. parallel agent / group lists). _(Certainty)_
### 1.4 Option visibility in pickers・Certainty・Meaningful
- [ ] **Pickers list every valid target.** Watch for options dropped by backend
list queries (pagination, `virtual` flags, scope filters) and add them back.
✅ The default "LobeAI" (inbox) agent is `virtual` and excluded from the
sidebar list, so the move picker re-adds it. An empty picker must mean
"genuinely none", never "we filtered out the only option". _(Meaningful)_
### 1.5 Default view reflects entry intent & data state・Certainty・Meaningful
A surface with multiple tabs / views / panels has a **landing** selection. Don't
hardcode it to "the first tab" — derive it from **(a) how the user got here** (the
intent their navigation carried) and **(b) which views actually have data**. A
static default that lands the user on an empty tab while a sibling holds exactly
what they came for reads as broken. This pairs with §1.1: the empty state is the
fallback _within_ a view; this rule is about not landing on that empty view in the
first place when a better one exists.
- [ ] **Open on the tab the entry implies.** When navigation carries intent — the
user clicked a Skill, a file, a record of a specific type — land on the view
that shows it, not the static first tab. ✅ Opening a document page by clicking
a **skill** lands the right panel on the **Skills** tab; opening a plain
document lands on **Documents**. _(Meaningful)_
- [ ] **Fall back to a populated view when the default would be empty.** If the
default tab has no data but a sibling does, default to the populated one so
the surface opens on content. ✅ An agent with only skills (no documents)
opens the panel on **Skills** instead of an empty **Documents** tab. _(Certainty)_
- [ ] **Decide from resolved state, not mid-load.** Compute the default once the
data has loaded — choosing off an empty _in-flight_ list flips the tab as data
arrives. Hold the static default while loading, switch on resolved-empty. _(Certainty)_
- [ ] **A manual choice wins and sticks.** Once the user picks a tab, stop
auto-selecting — track "user-picked" separately (e.g. a nullable `pickedTab`
that overrides the derived default) so later data changes don't yank them off
their choice. _(Natural)_
---
## 2. Edit — entering & changing content
Any surface where the user **types or edits**. Input is expensive effort; the
overriding rule is **never lose it**.
### 2.1 Protect in-progress edits・Certainty・Meaningful
Typed / edited content is real user effort; losing it is one of the most
infuriating outcomes a product can produce. Whenever an editor holds unsaved
input, assume the exit can be **accidental** — a misclick, a refresh, a crash, a
navigation, a failed save — and build a safety net: back the draft up locally and
recover it.
- [ ] **Back up the draft locally as the user types.** Persist to
localStorage / IndexedDB / store so a refresh, crash, accidental close, or
navigation doesn't vaporize the content. _(Certainty)_
- [ ] **Restore on return.** Coming back to the same editing context auto-restores
(or offers to restore) the unsaved draft, rather than showing a blank field. _(Meaningful)_
- [ ] **Guard destructive exits.** Closing / navigating / switching items away
from a dirty editor warns or auto-saves — never silently discards. _(Certainty)_
- [ ] **Survive a failed save.** If the save errors, keep the user's content in
the field / draft and let them retry; never clear the input on failure. _(Meaningful)_
- [ ] **Scope the draft to its target** (per topic / message / item id) so drafts
don't bleed across entities or resurrect on the wrong item. _(Certainty)_
---
## 3. Act — operations, flows & buttons
Any surface where the user **performs an action** — a single op, a bulk op, or a
multi-step flow. Covers momentum, focus, and full entity lifecycle.
### 3.1 Flow & momentum・Natural・Meaningful
Every action chain must **push the user forward**, never dead-end or block the flow.
- [ ] **Forward momentum** — after any operation, lead the user to the next step,
don't just stop. _(Meaningful)_
- [ ] **Success state = primary "go to result", secondary "dismiss"** — the strong
button is the forward action (take me to the result); "Done" is the weak/
secondary button. ✅ After moving topics: primary = "Go to «target»", secondary
\= "Done". _(Meaningful・Natural)_
- [ ] **Bulk ⇄ single-item parity** — an action on a multi-select toolbar must also
be reachable on a single item (its context menu), and vice versa. _(Certainty)_
- [ ] **Confirm → in-progress → done, in one surface** — bulk/irreversible/async
ops use a modal state machine: a confirm step stating exactly what happens →
an in-progress view with **dismissal locked** → a done (or error) view in the
same modal. Never fire-and-forget with only a toast; never leave a dead
spinner. _(Certainty・Meaningful)_
### 3.2 One primary button per surface・Certainty
- [ ] **One primary button per surface.** The single primary CTA tells the user the
core action; everything else is secondary/tertiary. Never a pile of primary
buttons competing for attention. _(Certainty)_
### 3.3 Entity lifecycle completeness・Meaningful・Certainty
The recurring trap: a feature ships only the **display** of a list, but edit /
delete / management are never built — so the user can add something and then be
stuck with it. For every entity a user can see, design its **full lifecycle**:
create / read / update / delete, plus state transitions (enable/disable,
connect/disconnect, install/uninstall). A read-only list the user can't manage
breaks the flow.
**The allowed operation set depends on the entity's source / ownership** — decide
it explicitly _before_ building. Worked example, the tools/connectors list:
| Entity class | Add | Edit | Remove |
| ----------------------------------- | ------- | --------- | ------------------ |
| Official / built-in (skills, tools) | — | — | ✗ not removable |
| Community (installed MCP) | install | configure | uninstall / remove |
| User-custom (custom connector) | create | edit | delete |
- [ ] **No display-only features.** For every listed entity, enumerate CRUD +
lifecycle ops and build the ones that apply. _(Meaningful)_
- [ ] **Operation set per source/ownership class** — built-in may be read-only;
anything the user _installed_ must be removable; anything the user _created_
must be editable **and** deletable. _(Certainty)_
- [ ] **Each item exposes its allowed ops** (hover action / context menu / detail
page), and there's a clear entry point to add/create where applicable. _(Natural)_
- [ ] **An intentionally-absent op is a documented decision, not an oversight**
(e.g. official tools can't be deleted — by design). _(Certainty)_
---
## 4. Feedback — loading & system response
How the product **answers back** while and after the user acts — loading visuals
and proactive guardrails.
### 4.1 Loading visuals・Natural
**Never use antd `Spin`** — it doesn't match the product's loading visual. Use a
project loader:
| Need | Component |
| --------------------------- | ----------------------------------------------------------------------------- |
| Default loading (in-flight) | `NeuralNetworkLoading` from `@/components/NeuralNetworkLoading` (`size` prop) |
| Inline dots | `DotsLoading` / `BubblesLoading` from `@/components` |
| Branded full-page | `Loading` from `@/components/Loading/BrandTextLoading` |
| List / card placeholder | a skeleton (e.g. `SkeletonList`) |
When in doubt, reach for `NeuralNetworkLoading` — it's the default in-flight
indicator (e.g. modal "in progress" states).
### 4.2 Capability-gated features・Certainty・Meaningful
A feature can be fully built and still produce a broken result when the selected
model — or its still-loading config — **can't deliver the capability the feature
depends on** (for example, an agentic run on a model without tool calling). This
is usually the user's configuration choice, not a defect; but if the product stays
silent the user reads it as the product being broken. When a feature's success
depends on a capability the current config may lack, the product owes a
**proactive, non-blocking reminder** — a guardrail, not a gate.
- [ ] **Surface the mismatch, don't fail silently.** When a feature needs a model
capability (tool calling, vision, reasoning, long context) the current model
lacks, show a soft inline warning at the point of action — never a hard block
or a modal that stops the user. _(Meaningful)_
- [ ] **Stay reactive.** The reminder clears the moment the user switches to a
capable model — derive it from live state, not a one-shot check. _(Natural)_
- [ ] **Don't warn while config is loading.** A capability that hasn't resolved yet
looks "unsupported"; warning then is a false alarm — exactly the glitch users
mistake for a product bug. Warn only on a _resolved_ unsupported state. _(Certainty)_
- [ ] **Scope to the mode that needs it.** Show only when the capability-dependent
mode is on; one reminder per root cause, never a pile of overlapping notices. _(Natural・Certainty)_
- [ ] **State the problem and the remedy.** The copy says what's wrong _and_ what
the user should do about it. _(Meaningful)_
---
## 5. Grow — discoverability & progressive disclosure
How the product **deepens** as the user's needs deepen.
### 5.1 Progressive disclosure・Growth
The product should grow with the user — deeper power shows up as needs deepen.
- [ ] **Progressive disclosure** — keep the novice path clean; reveal advanced
capabilities as the user gets there, don't dump everything at once. _(Growth・Natural)_
- [ ] **Surface related actions at the moment of need** — make the next capability
discoverable in context (e.g. after the first item exists, offer what to do
with it), not buried in a far-off menu. _(Growth・Meaningful)_
---
## Quick review checklist
**Read — viewing data & lists**
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA. Always-rendered chrome (toolbar/header) still gets a body empty state.
- [ ] List designed across 1 → 10k rows (virtual scroll / pagination / batch as needed).
- [ ] Capped/scrollable/virtualized list scrolls the restored active item into view on mount (`block: 'nearest'`, re-run after async rows mount).
- [ ] Pickers show all valid targets (default/inbox included); empty = truly none.
- [ ] Multi-tab/view surface lands on the tab the entry intent implies (and falls back to a populated view, decided from resolved state); a manual pick sticks.
**Edit — entering & changing content**
- [ ] Editors back up in-progress input locally and recover it after refresh/crash/failed-save; destructive exits warn, never silently discard.
**Act — operations, flows & buttons**
- [ ] Action leads the user forward; success offers a primary "go to result".
- [ ] Bulk action has a single-item entry (and vice versa).
- [ ] Async/bulk/irreversible action: confirm → in-progress (locked) → done/error.
- [ ] Exactly one primary button per surface.
- [ ] Listed entities have their full lifecycle (not display-only); ops match source (built-in / installed / custom).
**Feedback — loading & system response**
- [ ] No antd `Spin`; use `NeuralNetworkLoading` / project loaders.
- [ ] Capability-gated feature warns (soft, reactive, load-gated) when the model can't deliver it; copy gives the remedy.
**Grow — discoverability & progressive disclosure**
- [ ] Advanced capability is progressively disclosed / discoverable at the moment of need.
## Related skills
- **modal** — imperative `createModal` state-machine wiring for confirm/progress/done.
- **microcopy** — wording for confirm / done / empty / error states.
- **react** — component priority, `Button` usage, styling.
@@ -0,0 +1,51 @@
# LobeHub Design Values (设计价值观)
The philosophy behind every LobeHub interface. Read this before designing or
reviewing a flow; the per-aspect execution rules live in the parent
[SKILL.md](../SKILL.md) and each checklist item is tagged with the value(s) it serves.
Adapted from Ant Design's design values
(<https://ant.design/docs/spec/values-cn>, <https://zhuanlan.zhihu.com/p/44809866>).
LobeHub adopts all four.
## 自然 (Natural)
Minimise cognitive load. Digital products keep getting more complex while human
attention stays scarce — so the interface should feel as effortless as the
physical world. The next step should be obvious without thinking; the product
proactively carries the user forward (sensible defaults, AI-assisted decisions,
smooth transitions) rather than making them stop and figure things out.
## 意义感 (Meaningful)
Every screen is rooted in the user's real goal, not an isolated feature. Make the
objective clear, give immediate feedback on the result of each action, and always
point at the next meaningful step. Calibrate difficulty — neither a patronising
over-simplification nor an overwhelming wall — so the user keeps a sense of
progress and accomplishment.
## 确定性 (Certainty)
Low-entropy, predictable interactions. Reuse the same patterns, components, and
wording so behaviour is never surprising. Keep a single clear focus per surface,
and design **every** state (empty / loading / error / success) so nothing is left
undefined. Restraint over cleverness: fewer, consistent rules beat many bespoke
ones.
## 生长性 (Growth)
The product grows together with the user. As needs deepen and roles evolve,
surface advanced capabilities progressively and make related features
discoverable at the moment they become relevant — without crowding the novice
path. Bridge product value to the user's changing scenarios and aim for
humanmachine symbiosis (人机共生): the user and the agent co-evolve, each making
the other more capable over time.
## Priority when values conflict
For moment-to-moment interaction decisions: **意义感 ≳ 自然 > 确定性** — never
sacrifice the user's goal or forward momentum just to keep things uniform.
**生长性 (Growth)** is a longer-horizon lens: weigh it when shaping how a feature
is discovered and how it scales with the user, not when resolving a single-screen
layout trade-off.
+14 -5
View File
@@ -425,14 +425,14 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# MCP_TOOL_TIMEOUT=60000
# #######################################
# ######### Klavis Service ##############
# ######### Composio Service ############
# #######################################
# Klavis API Key for accessing Strata hosted MCP servers
# Get your API key from: https://klavis.io
# Composio API Key for accessing hosted integrations (Gmail, Slack, etc.)
# Get your API key from: https://composio.dev
# IMPORTANT: This key is stored server-side only and NEVER exposed to the client
# When this key is set, Klavis integration will be automatically enabled
# KLAVIS_API_KEY=your_klavis_api_key_here
# When this key is set, Composio integration will be automatically enabled
# COMPOSIO_API_KEY=your_composio_api_key_here
# #######################################
# #### Message Gateway (IM Integration) ##
@@ -445,6 +445,15 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
# #######################################
# ######### Agent Gateway Mode ##########
# #######################################
# Enable Gateway Mode for self-hosted deployments. Requires AGENT_GATEWAY_URL.
# ENABLE_AGENT_GATEWAY=1
# AGENT_GATEWAY_URL=https://agent-gateway.example.com
# AGENT_GATEWAY_SERVICE_TOKEN=your_service_token_here
# #######################################
# ########### Messenger Bot #############
# #######################################
+1 -1
View File
@@ -6,7 +6,7 @@ const prComment = async ({ github, context, releaseUrl, artifactsUrl, version, t
const COMMENT_IDENTIFIER = '<!-- DESKTOP-BUILD-COMMENT -->';
/**
* 生成评论内容
* Generate comment body content
*/
const generateCommentBody = async () => {
try {
+2
View File
@@ -136,3 +136,5 @@ bun run type-check
### Code Review
Before reviewing a PR / diff / branch change, read the **review-checklist** skill (`.agents/skills/review-checklist/SKILL.md`) — it lists the recurring mistakes specific to this codebase.
When designing or reviewing user-facing flows (empty/loading/error states, confirmations, async feedback, button hierarchy, lists at scale, pickers), follow the **ux** skill (`.agents/skills/ux/SKILL.md`) — LobeHub's design values (自然 / 意义感 / 确定性) plus per-aspect execution checklists.
+25
View File
@@ -2,6 +2,31 @@
# Changelog
## [Version 2.2.6](https://github.com/lobehub/lobe-chat/compare/v2.2.6-canary.8...v2.2.6)
<sup>Released on **2026-06-17**</sup>
#### ✨ Features
- **agent**: improve connector, document, and fleet workflows.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **agent**: improve connector, document, and fleet workflows, closes [#15936](https://github.com/lobehub/lobe-chat/issues/15936) ([3f82033](https://github.com/lobehub/lobe-chat/commit/3f82033))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.2.1](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr15228.13999...v2.2.1)
<sup>Released on **2026-05-29**</sup>
+37
View File
@@ -1,4 +1,7 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
@@ -77,6 +80,40 @@ describe('lh file - E2E', () => {
});
});
// ── upload (local file) ───────────────────────────────
describe('upload', () => {
it('should upload a local file passed as a positional argument', () => {
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-${Date.now()}.txt`);
fs.writeFileSync(tmpFile, 'hello from lh e2e upload');
try {
const result = runJson<{ id: string }>(`file upload ${tmpFile} --json id`);
expect(result).toHaveProperty('id');
if (result.id) run(`file delete ${result.id} --yes`);
} finally {
fs.rmSync(tmpFile, { force: true });
}
});
it('should upload a local file passed via --file', () => {
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-f-${Date.now()}.txt`);
fs.writeFileSync(tmpFile, 'hello from lh e2e --file upload');
try {
const result = runJson<{ id: string }>(`file upload --file ${tmpFile} --json id`);
expect(result).toHaveProperty('id');
if (result.id) run(`file delete ${result.id} --yes`);
} finally {
fs.rmSync(tmpFile, { force: true });
}
});
it('should error when the local file does not exist', () => {
expect(() => run('file upload -f /no/such/lh-file.txt')).toThrow();
});
});
// ── recent ────────────────────────────────────────────
describe('recent', () => {
+1 -1
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.29" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.32" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.29",
"version": "0.0.32",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -37,6 +37,7 @@
"@lobechat/tool-runtime": "workspace:*",
"@trpc/client": "^11.8.1",
"@types/node": "^24.13.2",
"@types/semver": "^7.7.1",
"@types/ws": "^8.18.1",
"commander": "^13.1.0",
"dayjs": "^1.11.19",
@@ -45,6 +46,7 @@
"fast-glob": "^3.3.3",
"ignore": "^7.0.5",
"picocolors": "^1.1.1",
"semver": "^7.7.3",
"superjson": "^2.2.6",
"tsdown": "^0.21.4",
"typescript": "^6.0.3",
+19
View File
@@ -440,6 +440,25 @@ describe('connect command', () => {
});
});
describe('disconnect (alias for connect stop)', () => {
it('should stop running daemon', async () => {
mockRunningPid = 12345;
const program = createProgram();
await program.parseAsync(['node', 'test', 'disconnect']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
});
it('should warn if no daemon is running', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'disconnect']);
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('No daemon'));
});
});
describe('connect status', () => {
it('should show no daemon running', async () => {
const program = createProgram();
+18 -11
View File
@@ -74,17 +74,7 @@ export function registerConnectCommand(program: Command) {
});
// Subcommands
connectCmd
.command('stop')
.description('Stop the background daemon process')
.action(() => {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
});
connectCmd.command('stop').description('Stop the background daemon process').action(handleStop);
connectCmd
.command('status')
@@ -148,10 +138,27 @@ export function registerConnectCommand(program: Command) {
}
handleDaemonStart({ ...options, daemon: true });
});
// Top-level alias for `connect stop`. Users who run `lh connect` naturally
// reach for `lh disconnect` to undo it; the nested `connect stop` is not
// discoverable enough on its own.
program
.command('disconnect')
.description('Disconnect from the device gateway (alias for `connect stop`)')
.action(handleStop);
}
// --- Internal helpers ---
function handleStop() {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
}
function handleDaemonStart(options: ConnectOptions) {
const existingPid = getRunningDaemonPid();
if (existingPid !== null) {
+117 -3
View File
@@ -1,3 +1,7 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -17,6 +21,9 @@ const { mockTrpcClient } = vi.hoisted(() => ({
removeFiles: { mutate: vi.fn() },
updateFile: { mutate: vi.fn() },
},
upload: {
createS3PreSignedUrl: { mutate: vi.fn() },
},
},
}));
@@ -38,9 +45,11 @@ describe('file command', () => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
for (const method of Object.values(mockTrpcClient.file)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
for (const group of [mockTrpcClient.file, mockTrpcClient.upload]) {
for (const method of Object.values(group)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
}
});
@@ -205,6 +214,111 @@ describe('file command', () => {
expect(mockTrpcClient.file.createFile.mutate).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
});
it('should upload a local file passed as a positional argument', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-${process.pid}.txt`);
fs.writeFileSync(tmpFile, 'hello world');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
mockTrpcClient.file.createFile.mutate.mockResolvedValue({
id: 'f-local',
url: 'files/x.txt',
});
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledWith(
'https://s3/presigned',
expect.objectContaining({ method: 'PUT' }),
);
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({
fileType: 'text/plain',
name: path.basename(tmpFile),
url: expect.stringContaining('.txt'),
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('File created'));
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should upload a local file passed via --file', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-f-${process.pid}.json`);
fs.writeFileSync(tmpFile, '{}');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-json' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', '--file', tmpFile]);
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileType: 'application/json' }),
);
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should skip the S3 upload when the local file hash already exists', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-dedup-${process.pid}.txt`);
fs.writeFileSync(tmpFile, 'dedup me');
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({
isExist: true,
url: 'files/2024-01-01/existing.txt',
});
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-dedup' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
// No pre-sign and no S3 PUT should happen
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
// The record reuses the existing url
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({ url: 'files/2024-01-01/existing.txt' }),
);
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should error when local file does not exist', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', '-f', '/no/such/file.txt']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('File not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should error when no source is provided', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Provide a local file path'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('edit', () => {
+49 -7
View File
@@ -4,6 +4,7 @@ import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
import { uploadLocalFile } from '../utils/uploadLocalFile';
export function registerFileCommand(program: Command) {
const file = program.command('file').description('Manage files');
@@ -113,18 +114,20 @@ export function registerFileCommand(program: Command) {
// ── upload ───────────────────────────────────────────
file
.command('upload <url>')
.description('Upload a file by URL (checks hash first)')
.option('--hash <hash>', 'File hash for deduplication check')
.option('--name <name>', 'File name')
.option('--type <type>', 'File MIME type')
.option('--size <size>', 'File size in bytes')
.command('upload [source]')
.description('Upload a file from a local path or a URL')
.option('-f, --file <path>', 'Local file path to upload')
.option('--hash <hash>', 'File hash for deduplication check (URL mode)')
.option('--name <name>', 'File name (URL mode)')
.option('--type <type>', 'File MIME type (URL mode)')
.option('--size <size>', 'File size in bytes (URL mode)')
.option('--parent-id <id>', 'Parent folder ID')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (
url: string,
source: string | undefined,
options: {
file?: string;
hash?: string;
json?: string | boolean;
name?: string;
@@ -133,8 +136,47 @@ export function registerFileCommand(program: Command) {
type?: string;
},
) => {
const isUrl = (value: string) =>
value.startsWith('http://') || value.startsWith('https://');
// Resolve the local file path: explicit --file, or a positional that is
// not a URL (e.g. `lh file upload ./games_list.txt`).
const localPath = options.file ?? (source && !isUrl(source) ? source : undefined);
const client = await getTrpcClient();
// ── Local file upload ──
if (localPath) {
let result;
try {
result = await uploadLocalFile(client, localPath, { parentId: options.parentId });
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return;
}
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
return;
}
const r = result as any;
console.log(`${pc.green('✓')} File created: ${pc.bold(r.id || '')}`);
if (r.url) console.log(` URL: ${pc.dim(r.url)}`);
return;
}
// ── URL upload ──
if (!source) {
log.error('Provide a local file path, --file <path>, or a URL to upload.');
process.exit(1);
return;
}
const url = source;
// Check hash first if provided
if (options.hash) {
const check = await client.file.checkFileHash.mutate({ hash: options.hash });
+140
View File
@@ -1,3 +1,7 @@
import { rm as fsRm, writeFile as fsWriteFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -6,6 +10,9 @@ import { registerGenerateCommand } from './generate';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
asr: {
transcribe: { mutate: vi.fn() },
},
generation: {
deleteGeneration: { mutate: vi.fn() },
getGenerationStatus: { query: vi.fn() },
@@ -35,6 +42,15 @@ const { writeFileSync: mockWriteFileSync } = vi.hoisted(() => ({
writeFileSync: vi.fn(),
}));
const { uploadLocalFile: mockUploadLocalFile } = vi.hoisted(() => ({
uploadLocalFile: vi.fn(),
}));
vi.mock('../utils/uploadLocalFile', async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, uploadLocalFile: mockUploadLocalFile };
});
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
vi.mock('node:fs', async (importOriginal) => {
@@ -369,6 +385,130 @@ describe('generate command', () => {
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should upload large local audio and transcribe by fileId', async () => {
// Real >3MB temp file so existsSync/statSync (unmocked) see it as large.
const bigPath = path.join(os.tmpdir(), `lh-asr-test-${process.pid}-${Date.now()}.mp3`);
await fsWriteFile(bigPath, Buffer.alloc(4 * 1024 * 1024));
mockUploadLocalFile.mockResolvedValue({ id: 'file_999' });
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'big result' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'asr', bigPath]);
expect(mockUploadLocalFile).toHaveBeenCalledWith(expect.anything(), bigPath);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileId: 'file_999', model: 'whisper-1', provider: 'openai' }),
);
// never inlines bytes for the large file
expect(mockTrpcClient.asr.transcribe.mutate.mock.calls[0][0]).not.toHaveProperty(
'audioBase64',
);
expect(stdoutSpy).toHaveBeenCalledWith('big result');
} finally {
await fsRm(bigPath, { force: true });
}
});
it('should download and transcribe an audio URL', async () => {
const fetchMock = vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers(),
ok: true,
});
vi.stubGlobal('fetch', fetchMock);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'hello world' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/audio/sample.mp3',
]);
expect(fetchMock).toHaveBeenCalledWith('https://example.com/audio/sample.mp3');
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({
audioBase64: Buffer.from('audio-bytes').toString('base64'),
fileName: 'sample.mp3',
model: 'whisper-1',
provider: 'openai',
}),
);
expect(stdoutSpy).toHaveBeenCalledWith('hello world');
expect(exitSpy).not.toHaveBeenCalled();
});
it('should derive an extension and mime type from Content-Type when the URL has none', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers({ 'content-type': 'audio/mpeg; charset=binary' }),
ok: true,
}),
);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'asr', 'https://example.com/download']);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({
fileName: 'download.mp3',
mimeType: 'audio/mpeg',
}),
);
});
it('should prefer the filename from Content-Disposition', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers({
'content-disposition': 'attachment; filename="recording.wav"',
}),
ok: true,
}),
);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/files/abc123?sig=xyz',
]);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileName: 'recording.wav' }),
);
});
it('should exit when audio URL download fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }),
);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/missing.mp3',
]);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to download audio'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('delete', () => {
+167 -39
View File
@@ -1,16 +1,27 @@
import { createReadStream, existsSync } from 'node:fs';
import { existsSync, statSync } from 'node:fs';
import { readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { Command } from 'commander';
import { getAuthInfo } from '../../api/http';
import { getTrpcClient } from '../../api/client';
import { log } from '../../utils/logger';
import { uploadLocalFile } from '../../utils/uploadLocalFile';
// Audio at or below this size is sent inline as base64; anything larger is
// uploaded first and transcribed by `fileId`. Kept in sync with the server-side
// inline cap in `apps/server/src/routers/lambda/asr.ts`.
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
export function registerAsrCommand(parent: Command) {
parent
.command('asr <audio-file>')
.description('Convert speech to text (automatic speech recognition)')
.description(
'Convert speech to text (automatic speech recognition). Accepts a local path or a URL',
)
.option('--model <model>', 'STT model', 'whisper-1')
.option('--provider <provider>', 'AI provider', 'openai')
.option('--language <lang>', 'Language code (e.g. en, zh)')
.option('--json', 'Output raw JSON')
.action(
@@ -20,58 +31,175 @@ export function registerAsrCommand(parent: Command) {
json?: boolean;
language?: string;
model: string;
provider: string;
},
) => {
if (!existsSync(audioFile)) {
const isUrl = audioFile.startsWith('http://') || audioFile.startsWith('https://');
if (!isUrl && !existsSync(audioFile)) {
log.error(`File not found: ${audioFile}`);
process.exit(1);
return;
}
const { serverUrl, headers } = await getAuthInfo();
const sttOptions: Record<string, any> = { model: options.model };
if (options.language) sttOptions.language = options.language;
const formData = new FormData();
const fileBuffer = await readFileAsBlob(audioFile);
formData.append('speech', fileBuffer, path.basename(audioFile));
formData.append('options', JSON.stringify(sttOptions));
// Remove Content-Type for multipart/form-data (let fetch set it with boundary)
const { 'Content-Type': _, ...formHeaders } = headers;
const res = await fetch(`${serverUrl}/webapi/stt/openai`, {
body: formData,
headers: formHeaders,
method: 'POST',
});
if (!res.ok) {
const errText = await res.text();
log.error(`ASR failed: ${res.status} ${errText}`);
// Resolve the input to a local file path (downloading URLs to a temp
// file) so large audio can reuse the shared upload flow.
let localPath: string;
let fileName: string;
let mimeType: string | undefined;
let size: number;
let tempPath: string | undefined;
try {
if (isUrl) {
const downloaded = await fetchAudioFromUrl(audioFile);
fileName = downloaded.name;
mimeType = downloaded.mimeType;
size = downloaded.bytes.byteLength;
tempPath = path.join(os.tmpdir(), `lh-asr-${process.pid}-${Date.now()}-${fileName}`);
await writeFile(tempPath, downloaded.bytes);
localPath = tempPath;
} else {
localPath = audioFile;
fileName = path.basename(audioFile);
size = statSync(audioFile).size;
}
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return;
}
const result = await res.json();
try {
const client = await getTrpcClient();
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
const text = (result as any).text || JSON.stringify(result);
process.stdout.write(text);
process.stdout.write('\n');
let result: { text: string };
if (size > MAX_INLINE_AUDIO_BYTES) {
// Large audio: upload to storage, then transcribe by fileId so the
// bytes never travel inline through tRPC.
process.stderr.write(
`Audio is ${(size / 1024 / 1024).toFixed(1)}MB — uploading before transcription…\n`,
);
const record = (await uploadLocalFile(client, localPath)) as { id: string };
result = await client.asr.transcribe.mutate({
fileId: record.id,
language: options.language,
model: options.model,
provider: options.provider,
});
} else {
const bytes = await readFile(localPath);
result = await client.asr.transcribe.mutate({
audioBase64: Buffer.from(bytes).toString('base64'),
fileName,
language: options.language,
mimeType,
model: options.model,
provider: options.provider,
});
}
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
process.stdout.write(result.text);
process.stdout.write('\n');
}
} catch (error) {
log.error(`ASR failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
} finally {
if (tempPath) {
await rm(tempPath, { force: true }).catch(() => {});
}
}
},
);
}
async function readFileAsBlob(filePath: string): Promise<Blob> {
const chunks: Uint8Array[] = [];
const stream = createReadStream(filePath);
for await (const chunk of stream) {
chunks.push(chunk as Uint8Array);
// Common audio MIME types mapped to a file extension the transcription
// provider can recognize. Keep the extensions within the set OpenAI's
// /audio/transcriptions endpoint accepts.
const AUDIO_MIME_TO_EXT: Record<string, string> = {
'audio/aac': 'aac',
'audio/flac': 'flac',
'audio/m4a': 'm4a',
'audio/mp3': 'mp3',
'audio/mp4': 'm4a',
'audio/mpeg': 'mp3',
'audio/mpga': 'mp3',
'audio/ogg': 'ogg',
'audio/opus': 'ogg',
'audio/wav': 'wav',
'audio/wave': 'wav',
'audio/webm': 'webm',
'audio/x-m4a': 'm4a',
'audio/x-wav': 'wav',
};
async function fetchAudioFromUrl(
url: string,
): Promise<{ bytes: Uint8Array; mimeType?: string; name: string }> {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to download audio: ${res.status} ${res.statusText}`);
}
const bytes = new Uint8Array(await res.arrayBuffer());
// Strip any parameters from the Content-Type (e.g. `audio/mpeg; charset=...`).
const contentType = res.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
const mimeType = contentType?.startsWith('audio/') ? contentType : undefined;
// Prefer the name the server advertises, then the URL path, then a fallback.
const name =
fileNameFromContentDisposition(res.headers.get('content-disposition')) ||
basenameFromUrl(url) ||
'audio';
// Transcription providers infer the audio format from the file extension, so
// make sure the name carries one. Signed URLs and /download endpoints often
// have no extension in the path — in that case borrow it from the
// Content-Type when we recognize it.
const ext = contentType ? AUDIO_MIME_TO_EXT[contentType] : undefined;
const finalName = path.extname(name) || !ext ? name : `${name}.${ext}`;
return { bytes, mimeType, name: finalName };
}
// Extract a file name from a Content-Disposition header, handling both the
// plain `filename="x"` form and the RFC 5987 extended `filename*=UTF-8''x` form.
function fileNameFromContentDisposition(header: string | null): string | undefined {
if (!header) return undefined;
// Extended form takes precedence and may be percent-encoded.
const extended = /filename\*=\s*(?:UTF-8|ISO-8859-1)?''([^;]+)/i.exec(header);
if (extended?.[1]) {
try {
return path.basename(decodeURIComponent(extended[1].trim()));
} catch {
// Malformed encoding — fall through to the plain form.
}
}
const plain = /filename=\s*"?([^";]+)"?/i.exec(header);
const value = plain?.[1]?.trim();
return value ? path.basename(value) : undefined;
}
// Derive the (URL-decoded) last path segment of a URL, if any.
function basenameFromUrl(url: string): string | undefined {
let pathname: string;
try {
pathname = new URL(url).pathname;
} catch {
return undefined;
}
const base = path.basename(pathname);
if (!base) return undefined;
try {
return decodeURIComponent(base);
} catch {
return base;
}
return new Blob(chunks);
}
+47
View File
@@ -649,6 +649,53 @@ describe('hetero exec command', () => {
]);
});
it('finishes with result "error" when a terminal error event is pushed despite a clean exit', async () => {
// CC relays an API/rate-limit error as an in-stream `error` event but still
// exits 0. The finish result must NOT be derived from the exit code alone,
// otherwise the topic/task is wrongly marked completed.
mockSpawnAgent.mockReturnValue(
createFakeHandle({
events: [
{
data: {
error: 'API Error: Server is temporarily limiting requests · Rate limited',
message: 'API Error: Server is temporarily limiting requests · Rate limited',
},
operationId: 'op-err',
stepIndex: 0,
timestamp: 1,
type: 'error',
},
],
exitCode: 0,
}),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--topic',
'topic-1',
'--operation-id',
'op-err',
'--render',
'none',
]);
expect(mockHeteroFinishMutate).toHaveBeenCalledTimes(1);
expect(mockHeteroFinishMutate.mock.calls[0][0]).toMatchObject({
error: {
message: 'API Error: Server is temporarily limiting requests · Rate limited',
type: 'AgentRuntimeError',
},
result: 'error',
});
});
it('resets the per-message text accumulator at message boundaries (no cross-message duplication)', async () => {
// The `replace` snapshot accumulator must not span
// message boundaries. Two assistant messages separated by a
+35 -7
View File
@@ -467,6 +467,11 @@ const exec = async (options: ExecOptions): Promise<void> => {
* sessionId — CC session id from `system.init` (undefined on resume failure)
* ingestError — true when a batch could not be flushed after retries
* resumeNotFound — true when a resume-not-found error was intercepted
* sawTerminalError — true when a terminal `error` event was pushed to the
* ingester (CC can relay an API/rate-limit error this way
* and still exit 0, so the exit code alone is not enough)
* terminalErrorMessage — the message from that terminal `error` event, used
* as the task-level error detail in the finish payload
* stderrContent — accumulated stderr (only when interceptResumeErrors=true)
*/
const runOneAgent = async (
@@ -477,9 +482,11 @@ const exec = async (options: ExecOptions): Promise<void> => {
code: number | null;
ingestError: boolean;
resumeNotFound: boolean;
sawTerminalError: boolean;
sessionId: string | undefined;
signal: NodeJS.Signals | null;
stderrContent: string;
terminalErrorMessage: string | undefined;
}> => {
// One raw-dump file pair per spawn attempt (the resume retry is a second
// attempt). The stdout tee runs inside `spawnAgent` before the adapter.
@@ -549,6 +556,8 @@ const exec = async (options: ExecOptions): Promise<void> => {
// into the ingester. When intercepting resume errors, a matching
// `error` event is withheld from the ingester and flags a retry instead.
let resumeNotFound = false;
let sawTerminalError = false;
let terminalErrorMessage: string | undefined;
const ingestError = false;
try {
for await (const event of handle.events) {
@@ -563,6 +572,16 @@ const exec = async (options: ExecOptions): Promise<void> => {
continue;
}
}
// A terminal `error` event (e.g. an API/rate-limit error relayed by CC)
// must mark the run as failed even when the child exits 0 — track it so
// the finish result is not derived from the exit code alone. Capture the
// message too, so the finish payload can surface it as the task-level
// error detail (CC relays these on stdout, not stderr).
if (event.type === 'error') {
sawTerminalError = true;
const data = event.data as Record<string, unknown> | undefined;
terminalErrorMessage = String(data?.message ?? data?.error ?? '') || undefined;
}
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
serverIngester?.push(event);
}
@@ -608,9 +627,11 @@ const exec = async (options: ExecOptions): Promise<void> => {
code,
ingestError,
resumeNotFound,
sawTerminalError,
sessionId: handle.sessionId,
signal,
stderrContent,
terminalErrorMessage,
};
};
@@ -675,16 +696,23 @@ const exec = async (options: ExecOptions): Promise<void> => {
result = { ...result, ingestError: true };
}
const exitedClean = !result.ingestError && (code === 0 || signal === 'SIGTERM');
// CC relays API/rate-limit errors as an in-stream terminal `error` event but
// still exits 0, so the exit code alone would report `success`. Treat any
// pushed terminal error as a failed run so the topic/task is marked failed.
const exitedClean =
!result.ingestError && !result.sawTerminalError && (code === 0 || signal === 'SIGTERM');
// When the run failed, pass stderr as the error detail so the server can
// surface a useful message instead of the generic "Agent execution failed"
// fallback. Trim to the last 1 KB — the tail is most informative and
// keeps the tRPC payload small.
// When the run failed, pass an error detail so the server surfaces a useful
// message instead of the generic "Agent execution failed" fallback. Prefer
// the in-stream terminal error (CC relays API/rate-limit errors here while
// exiting 0, so stderr is empty); otherwise fall back to the stderr tail.
// Trim to the last 1 KB — the tail is most informative and keeps the tRPC
// payload small.
const stderrTail = result.stderrContent.trim();
const errorDetail = result.terminalErrorMessage || stderrTail;
const finishError =
!exitedClean && stderrTail
? { message: stderrTail.slice(-1024), type: 'AgentRuntimeError' }
!exitedClean && errorDetail
? { message: errorDetail.slice(-1024), type: 'AgentRuntimeError' }
: undefined;
try {
+11 -72
View File
@@ -1,14 +1,12 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { getAuthInfo } from '../api/http';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
import { uploadLocalFile } from '../utils/uploadLocalFile';
function formatFileType(fileType: string): string {
if (!fileType) return '';
@@ -324,81 +322,22 @@ export function registerKbCommand(program: Command) {
.description('Upload a file to a knowledge base')
.option('--parent <parentId>', 'Parent folder ID')
.action(async (knowledgeBaseId: string, filePath: string, options: { parent?: string }) => {
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) {
log.error(`File not found: ${resolved}`);
process.exit(1);
}
const stat = fs.statSync(resolved);
const fileName = path.basename(resolved);
const fileBuffer = fs.readFileSync(resolved);
// Compute SHA-256 hash
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// Detect MIME type from extension
const ext = path.extname(fileName).toLowerCase().slice(1);
const mimeMap: Record<string, string> = {
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
gif: 'image/gif',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
json: 'application/json',
md: 'text/markdown',
mp3: 'audio/mpeg',
mp4: 'video/mp4',
pdf: 'application/pdf',
png: 'image/png',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
svg: 'image/svg+xml',
txt: 'text/plain',
webp: 'image/webp',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
const fileType = mimeMap[ext] || 'application/octet-stream';
const client = await getTrpcClient();
const { serverUrl, headers } = await getAuthInfo();
// 1. Get presigned URL
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
const pathname = `files/${date}/${hash}.${ext}`;
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
// 2. Upload to S3
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
const uploadRes = await fetch(presignedUrl, {
body: fileBuffer,
headers: { 'Content-Type': fileType },
method: 'PUT',
});
if (!uploadRes.ok) {
log.error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
let result;
try {
result = await uploadLocalFile(client, filePath, {
knowledgeBaseId,
parentId: options.parent,
});
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return;
}
// 3. Create file record
const result = await client.file.createFile.mutate({
fileType,
hash,
knowledgeBaseId,
metadata: {
date,
dirname: '',
filename: fileName,
path: pathname,
},
name: fileName,
parentId: options.parent,
size: stat.size,
url: pathname,
});
console.log(
`${pc.green('✓')} Uploaded ${pc.bold(fileName)}${pc.bold((result as any).id)}`,
`${pc.green('✓')} Uploaded ${pc.bold(path.basename(filePath))}${pc.bold((result as any).id)}`,
);
});
}
+31 -1
View File
@@ -1,7 +1,8 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearCredentials } from '../auth/credentials';
import { stopDaemon } from '../daemon/manager';
import { log } from '../utils/logger';
import { registerLogoutCommand } from './logout';
@@ -9,6 +10,10 @@ vi.mock('../auth/credentials', () => ({
clearCredentials: vi.fn(),
}));
vi.mock('../daemon/manager', () => ({
stopDaemon: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
@@ -19,6 +24,11 @@ vi.mock('../utils/logger', () => ({
}));
describe('logout command', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(stopDaemon).mockReturnValue(false);
});
function createProgram() {
const program = new Command();
program.exitOverride();
@@ -44,4 +54,24 @@ describe('logout command', () => {
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Already logged out'));
});
it('should stop the connect daemon before clearing credentials', async () => {
vi.mocked(stopDaemon).mockReturnValue(true);
vi.mocked(clearCredentials).mockReturnValue(true);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Disconnected device daemon'));
});
it('should still attempt daemon teardown when no credentials exist', async () => {
vi.mocked(clearCredentials).mockReturnValue(false);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(stopDaemon).toHaveBeenCalled();
});
});
+9
View File
@@ -1,6 +1,7 @@
import type { Command } from 'commander';
import { clearCredentials } from '../auth/credentials';
import { stopDaemon } from '../daemon/manager';
import { log } from '../utils/logger';
export function registerLogoutCommand(program: Command) {
@@ -8,6 +9,14 @@ export function registerLogoutCommand(program: Command) {
.command('logout')
.description('Log out and remove stored credentials')
.action(() => {
// Tear down the connect daemon first — otherwise it keeps the device
// online on the gateway with the cached token even after credentials are
// gone, leaving the machine remotely driveable past "logout".
const stopped = stopDaemon();
if (stopped) {
log.info('Disconnected device daemon.');
}
const removed = clearCredentials();
if (removed) {
log.info('Logged out. Credentials removed.');
+58
View File
@@ -100,6 +100,19 @@ describe('model command', () => {
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(visibleModels, null, 2));
});
it('should normalize the legacy `stt` type to `asr` when filtering', async () => {
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([
{ displayName: 'Whisper', enabled: true, id: 'whisper-1', type: 'asr' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'list', 'openai', '--type', 'stt']);
expect(mockTrpcClient.aiModel.getAiProviderModelList.query).toHaveBeenCalledWith(
expect.objectContaining({ id: 'openai', type: 'asr' }),
);
});
});
describe('view', () => {
@@ -157,6 +170,28 @@ describe('model command', () => {
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created model'));
});
it('should normalize the legacy `stt` type to `asr`', async () => {
mockTrpcClient.aiModel.createAiModel.mutate.mockResolvedValue('whisper-1');
const program = createProgram();
await program.parseAsync([
'node',
'test',
'model',
'create',
'--id',
'whisper-1',
'--provider',
'openai',
'--type',
'stt',
]);
expect(mockTrpcClient.aiModel.createAiModel.mutate).toHaveBeenCalledWith(
expect.objectContaining({ id: 'whisper-1', providerId: 'openai', type: 'asr' }),
);
});
});
describe('edit', () => {
@@ -184,6 +219,29 @@ describe('model command', () => {
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated model'));
});
it('should normalize the legacy `stt` type to `asr`', async () => {
mockTrpcClient.aiModel.updateAiModel.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'model',
'edit',
'whisper-1',
'--provider',
'openai',
'--type',
'stt',
]);
expect(mockTrpcClient.aiModel.updateAiModel.mutate).toHaveBeenCalledWith({
id: 'whisper-1',
providerId: 'openai',
value: expect.objectContaining({ type: 'asr' }),
});
});
it('should error when no changes specified', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'edit', 'gpt-4', '--provider', 'openai']);
+15 -8
View File
@@ -7,6 +7,11 @@ import { log } from '../utils/logger';
const isVisibleModel = (model: { visible?: boolean }) => model.visible !== false;
// The model type `stt` was renamed to the standard `asr`. Accept the legacy
// alias on CLI input and forward/compare `asr`, so existing scripts and muscle
// memory keep working against the new router schema.
const normalizeModelType = (type: string): string => (type === 'stt' ? 'asr' : type);
export function registerModelCommand(program: Command) {
const model = program.command('model').description('Manage AI models');
@@ -19,7 +24,7 @@ export function registerModelCommand(program: Command) {
.option('--enabled', 'Only show enabled models')
.option(
'--type <type>',
'Filter by model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
'Filter by model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
)
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
@@ -29,18 +34,20 @@ export function registerModelCommand(program: Command) {
) => {
const client = await getTrpcClient();
const typeFilter = options.type ? normalizeModelType(options.type) : undefined;
const input: Record<string, any> = { id: providerId };
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
if (options.enabled) input.enabled = true;
if (options.type) input.type = options.type;
if (typeFilter) input.type = typeFilter;
const result = await client.aiModel.getAiProviderModelList.query(input as any);
let items = (Array.isArray(result) ? result : ((result as any).items ?? [])).filter(
isVisibleModel,
);
if (options.type) {
items = items.filter((m: any) => m.type === options.type);
if (typeFilter) {
items = items.filter((m: any) => m.type === typeFilter);
}
if (options.json !== undefined) {
@@ -106,7 +113,7 @@ export function registerModelCommand(program: Command) {
.option('--display-name <name>', 'Display name')
.option(
'--type <type>',
'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
'chat',
)
.action(
@@ -116,7 +123,7 @@ export function registerModelCommand(program: Command) {
const input: Record<string, any> = {
id: options.id,
providerId: options.provider,
type: options.type || 'chat',
type: normalizeModelType(options.type || 'chat'),
};
if (options.displayName) input.displayName = options.displayName;
@@ -132,7 +139,7 @@ export function registerModelCommand(program: Command) {
.description('Update model info')
.requiredOption('--provider <providerId>', 'Provider ID')
.option('--display-name <name>', 'Display name')
.option('--type <type>', 'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)')
.option('--type <type>', 'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)')
.action(
async (id: string, options: { displayName?: string; provider: string; type?: string }) => {
if (!options.displayName && !options.type) {
@@ -144,7 +151,7 @@ export function registerModelCommand(program: Command) {
const value: Record<string, any> = {};
if (options.displayName) value.displayName = options.displayName;
if (options.type) value.type = options.type;
if (options.type) value.type = normalizeModelType(options.type);
await client.aiModel.updateAiModel.mutate({
id,
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { buildInstallCommand, isNewerVersion } from './update';
describe('isNewerVersion', () => {
it('compares core versions', () => {
expect(isNewerVersion('1.2.3', '1.2.2')).toBe(true);
expect(isNewerVersion('1.2.2', '1.2.3')).toBe(false);
expect(isNewerVersion('1.2.3', '1.2.3')).toBe(false);
expect(isNewerVersion('2.0.0', '1.9.9')).toBe(true);
});
it('tolerates a leading v and missing segments', () => {
expect(isNewerVersion('v1.2.0', '1.2.0')).toBe(false);
expect(isNewerVersion('1.2', '1.2.0')).toBe(false);
expect(isNewerVersion('1.3', '1.2.9')).toBe(true);
});
it('ranks a stable release above a prerelease of the same core', () => {
expect(isNewerVersion('1.2.3', '1.2.3-beta.1')).toBe(true);
expect(isNewerVersion('1.2.3-beta.1', '1.2.3')).toBe(false);
expect(isNewerVersion('1.2.3-beta.2', '1.2.3-beta.1')).toBe(true);
expect(isNewerVersion('1.2.3-beta.1', '1.2.3-beta.1')).toBe(false);
});
it('orders numeric prerelease identifiers numerically, not lexicographically', () => {
// The bug a raw string compare gets wrong: beta.10 must outrank beta.9.
expect(isNewerVersion('1.0.0-beta.10', '1.0.0-beta.9')).toBe(true);
expect(isNewerVersion('1.0.0-beta.9', '1.0.0-beta.10')).toBe(false);
expect(isNewerVersion('1.0.0-beta.2', '1.0.0-beta.10')).toBe(false);
});
it('returns false for an unparseable latest version', () => {
expect(isNewerVersion('not-a-version', '1.0.0')).toBe(false);
});
});
describe('buildInstallCommand', () => {
it('builds the global install command per package manager', () => {
expect(buildInstallCommand('npm', '@lobehub/cli@1.0.0')).toEqual({
args: ['install', '-g', '@lobehub/cli@1.0.0'],
command: 'npm',
});
expect(buildInstallCommand('pnpm', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'pnpm',
});
expect(buildInstallCommand('bun', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'bun',
});
expect(buildInstallCommand('yarn', '@lobehub/cli@1.0.0')).toEqual({
args: ['global', 'add', '@lobehub/cli@1.0.0'],
command: 'yarn',
});
});
});
+179
View File
@@ -0,0 +1,179 @@
import { spawn } from 'node:child_process';
import { realpathSync } from 'node:fs';
import type { Command } from 'commander';
import pc from 'picocolors';
import semver from 'semver';
// Pull package metadata from the shared `src/pkg.ts` module (resolved at the
// bundled entry's depth) rather than a local `require('../../package.json')`,
// which would point outside the package once bundled into dist/index.js.
import { cliPackageName, cliVersion } from '../pkg';
import { log } from '../utils/logger';
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
interface UpdateOptions {
check?: boolean;
packageManager?: PackageManager;
tag?: string;
}
/**
* Detect which package manager installed the CLI so we run the matching global
* upgrade command. We first trust an explicit `npm_config_user_agent` (set when
* invoked through a package-manager script) and otherwise infer from the path of
* the running binary. Falls back to npm.
*/
export function detectPackageManager(): PackageManager {
const ua = process.env.npm_config_user_agent;
if (ua) {
if (ua.startsWith('pnpm')) return 'pnpm';
if (ua.startsWith('yarn')) return 'yarn';
if (ua.startsWith('bun')) return 'bun';
if (ua.startsWith('npm')) return 'npm';
}
try {
const binPath = realpathSync(process.argv[1] ?? '').replaceAll('\\', '/');
if (binPath.includes('/pnpm/')) return 'pnpm';
if (binPath.includes('/.bun/') || binPath.includes('/bun/')) return 'bun';
if (binPath.includes('/yarn/') || binPath.includes('/.yarn/')) return 'yarn';
} catch {
// ignore fall back to npm
}
return 'npm';
}
/** Build the global-install command for the detected package manager. */
export function buildInstallCommand(
pm: PackageManager,
spec: string,
): { args: string[]; command: string } {
switch (pm) {
case 'pnpm': {
return { args: ['add', '-g', spec], command: 'pnpm' };
}
case 'yarn': {
return { args: ['global', 'add', spec], command: 'yarn' };
}
case 'bun': {
return { args: ['add', '-g', spec], command: 'bun' };
}
default: {
return { args: ['install', '-g', spec], command: 'npm' };
}
}
}
/**
* Whether `latest` is a newer version than `current`. Delegates to `semver` so
* prerelease identifiers order correctly (e.g. `1.0.0-beta.10` > `1.0.0-beta.9`,
* which a lexicographic compare gets wrong). Tolerates a leading `v` and missing
* segments via coercion; an unparseable `latest` is treated as "not newer".
*/
export function isNewerVersion(latest: string, current: string): boolean {
const latestParsed = semver.coerce(latest, { includePrerelease: true }) ?? semver.parse(latest);
const currentParsed =
semver.coerce(current, { includePrerelease: true }) ?? semver.parse(current);
if (!latestParsed || !currentParsed) return false;
return semver.gt(latestParsed, currentParsed);
}
async function fetchLatestVersion(name: string, tag: string): Promise<string> {
const url = `https://registry.npmjs.org/${name}/${encodeURIComponent(tag)}`;
const res = await fetch(url, { headers: { accept: 'application/json' } });
if (!res.ok) {
throw new Error(`npm registry returned status ${res.status} for tag "${tag}"`);
}
const data = (await res.json()) as { version?: string };
if (!data.version) {
throw new Error('npm registry response is missing the "version" field');
}
return data.version;
}
function runInstall(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
shell: process.platform === 'win32',
stdio: 'inherit',
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} exited with code ${code ?? 'null'}`));
});
});
}
export function registerUpdateCommand(program: Command) {
program
.command('update')
.description('Update the LobeHub CLI to the latest published version')
.option('--check', 'Only check for a newer version without installing')
.option('--tag <tag>', 'npm dist-tag to update to', 'latest')
.option(
'--package-manager <pm>',
`Force a package manager (${PACKAGE_MANAGERS.join(', ')}) instead of auto-detecting`,
)
.action(async (options: UpdateOptions) => {
if (options.packageManager && !PACKAGE_MANAGERS.includes(options.packageManager)) {
log.error(
`Unsupported package manager "${options.packageManager}". Use one of: ${PACKAGE_MANAGERS.join(', ')}.`,
);
process.exit(1);
return;
}
const current = cliVersion;
const tag = options.tag || 'latest';
log.info(`Current version: ${pc.bold(current)}`);
let latest: string;
try {
latest = await fetchLatestVersion(cliPackageName, tag);
} catch (error) {
log.error(`Unable to check for updates: ${(error as Error).message}`);
process.exit(1);
return;
}
log.info(`Latest version: ${pc.bold(latest)} ${pc.dim(`(${tag})`)}`);
if (!isNewerVersion(latest, current)) {
log.info(pc.green('Already on the latest version.'));
return;
}
if (options.check) {
log.info(
`Update available: ${current}${pc.green(latest)}. Run ${pc.cyan('lh update')} to upgrade.`,
);
return;
}
const pm = options.packageManager || detectPackageManager();
const spec = `${cliPackageName}@${latest}`;
const { args, command } = buildInstallCommand(pm, spec);
log.info(`Upgrading via ${pc.bold(pm)}: ${pc.dim([command, ...args].join(' '))}`);
try {
await runInstall(command, args);
log.info(pc.green(`Successfully updated to ${latest}. Restart any running sessions.`));
} catch (error) {
log.error(`Update failed: ${(error as Error).message}`);
log.error(`You can upgrade manually: ${[command, ...args].join(' ')}`);
process.exit(1);
}
});
}
+82
View File
@@ -19,11 +19,22 @@ vi.mock('node:os', async (importOriginal) => {
};
});
// Mock only `execFileSync` (used by isDaemonProcess to read a process command
// line); keep the real `spawn` so nothing else changes.
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
return { ...actual, execFileSync: vi.fn() };
});
// eslint-disable-next-line import-x/first
import { execFileSync } from 'node:child_process';
// eslint-disable-next-line import-x/first
import {
appendLog,
getLogPath,
getRunningDaemonPid,
isDaemonProcess,
isProcessAlive,
readPid,
readStatus,
@@ -35,9 +46,15 @@ import {
writeStatus,
} from './manager';
// A command line that matches the daemon signature (`connect … --daemon-child`).
const DAEMON_COMMAND = '/usr/local/bin/node /path/to/cli.js connect --daemon-child';
describe('daemon manager', () => {
beforeEach(async () => {
await mkdir(mockDir, { recursive: true });
// Default: any inspected PID looks like our daemon. Tests that need a
// reused / unrelated PID override this per-case.
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
});
afterEach(() => {
@@ -80,6 +97,36 @@ describe('daemon manager', () => {
});
});
describe('isDaemonProcess', () => {
it('should return true when the command line matches the daemon signature', () => {
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
expect(isDaemonProcess(12345)).toBe(true);
expect(execFileSync).toHaveBeenCalledWith(
'ps',
['-ww', '-p', '12345', '-o', 'command='],
expect.any(Object),
);
});
it('should return false for an unrelated process command line', () => {
vi.mocked(execFileSync).mockReturnValue('/usr/bin/vim notes.txt' as any);
expect(isDaemonProcess(12345)).toBe(false);
});
it('should return false when the signature is only partially present', () => {
// `connect` without the internal `--daemon-child` flag is not our daemon.
vi.mocked(execFileSync).mockReturnValue('/usr/bin/node /path/cli connect' as any);
expect(isDaemonProcess(12345)).toBe(false);
});
it('should return false when ps is unavailable / throws', () => {
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('ps: command not found');
});
expect(isDaemonProcess(12345)).toBe(false);
});
});
describe('getRunningDaemonPid', () => {
it('should return null when no PID file', () => {
expect(getRunningDaemonPid()).toBeNull();
@@ -110,6 +157,23 @@ describe('daemon manager', () => {
expect(readStatus()).toBeNull();
});
it('should treat a live but reused (non-daemon) PID as stale and clean up', () => {
// process.pid is alive, but the inspected command line is not our daemon —
// simulates the OS reusing a dead daemon's PID for an unrelated process.
writePid(process.pid);
writeStatus({
connectionStatus: 'connected',
gatewayUrl: 'https://test.com',
pid: process.pid,
startedAt: new Date().toISOString(),
});
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
expect(getRunningDaemonPid()).toBeNull();
expect(readPid()).toBeNull();
expect(readStatus()).toBeNull();
});
});
describe('status file', () => {
@@ -232,5 +296,23 @@ describe('daemon manager', () => {
killSpy.mockRestore();
});
it('should NOT SIGTERM a live PID that is not our daemon', () => {
// Stale daemon.pid whose PID was reused by an unrelated, living process.
writePid(process.pid);
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const result = stopDaemon();
expect(result).toBe(false);
// Only the liveness probe (signal 0) is allowed — never a real SIGTERM.
expect(killSpy).not.toHaveBeenCalledWith(process.pid, 'SIGTERM');
// Stale metadata is cleaned up so we don't keep re-checking it.
expect(readPid()).toBeNull();
killSpy.mockRestore();
});
});
});
+33 -3
View File
@@ -1,4 +1,4 @@
import { spawn } from 'node:child_process';
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -70,6 +70,34 @@ export function isProcessAlive(pid: number): boolean {
}
}
/**
* Verify a live PID actually belongs to a LobeHub connect daemon.
*
* A bare `isProcessAlive` check is not enough: if a daemon dies without cleaning
* up `daemon.pid` (crash, `kill -9`, reboot), the OS can later reuse that PID
* for an unrelated process. Acting on the stale PID would let `lh logout` /
* `connect stop` SIGTERM a stranger. The daemon is always spawned as
* `<node> … connect … --daemon-child`, so we confirm that signature in the
* process command line before trusting the PID.
*
* Best-effort and deliberately conservative: if the command line can't be read
* (e.g. `ps` is unavailable), we return `false` so callers never kill a process
* we can't positively identify.
*/
export function isDaemonProcess(pid: number): boolean {
try {
// `-ww` disables column truncation so the trailing `--daemon-child` flag is
// never cut off; stderr is silenced so a dead PID just yields an empty match.
const command = execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return command.includes('--daemon-child') && command.includes('connect');
} catch {
return false;
}
}
/**
* Get the PID of a running daemon, cleaning up stale PID files.
* Returns null if no daemon is running.
@@ -78,9 +106,11 @@ export function getRunningDaemonPid(): number | null {
const pid = readPid();
if (pid === null) return null;
if (isProcessAlive(pid)) return pid;
// Require both liveness AND identity — a live-but-reused PID is treated as
// stale so we never act on a process that isn't ours.
if (isProcessAlive(pid) && isDaemonProcess(pid)) return pid;
// Stale PID file — process is dead
// Stale PID file — process is dead or the PID now belongs to someone else.
removePid();
removeStatus();
return null;
+16
View File
@@ -0,0 +1,16 @@
import { createRequire } from 'node:module';
/**
* Single source of truth for this package's own metadata.
*
* Must live directly under `src/` (depth 1), the same depth as the bundled
* entry `dist/index.js`, so `../package.json` resolves to `@lobehub/cli`'s own
* package.json both when running from source (`bun src/index.ts`) and from the
* tsdown bundle (`dist/index.js`). A module one directory deeper would resolve
* the path outside the package once everything is bundled into a single file.
*/
const require = createRequire(import.meta.url);
const pkg = require('../package.json') as { name: string; version: string };
export const cliPackageName = pkg.name;
export const cliVersion = pkg.version;
+5 -7
View File
@@ -1,5 +1,3 @@
import { createRequire } from 'node:module';
import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
@@ -33,11 +31,10 @@ import { registerStatusCommand } from './commands/status';
import { registerTaskCommand } from './commands/task';
import { registerThreadCommand } from './commands/thread';
import { registerTopicCommand } from './commands/topic';
import { registerUpdateCommand } from './commands/update';
import { registerUserCommand } from './commands/user';
import { registerVerifyCommand } from './commands/verify';
const require = createRequire(import.meta.url);
const { version } = require('../package.json');
import { cliVersion } from './pkg';
export function createProgram() {
const program = new Command();
@@ -45,7 +42,7 @@ export function createProgram() {
program
.name('lh')
.description('LobeHub CLI - manage and connect to LobeHub services')
.version(version);
.version(cliVersion);
registerLoginCommand(program);
registerLogoutCommand(program);
@@ -80,8 +77,9 @@ export function createProgram() {
registerConfigCommand(program);
registerEvalCommand(program);
registerMigrateCommand(program);
registerUpdateCommand(program);
return program;
}
export { version as cliVersion };
export { cliPackageName, cliVersion } from './pkg';
+125
View File
@@ -0,0 +1,125 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { TrpcClient } from '../api/client';
/**
* Minimal extension → MIME map for files uploaded from the local filesystem.
* Unknown extensions fall back to `application/octet-stream`.
*/
const MIME_MAP: Record<string, string> = {
aac: 'audio/aac',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
flac: 'audio/flac',
gif: 'image/gif',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
json: 'application/json',
m4a: 'audio/mp4',
md: 'text/markdown',
mp3: 'audio/mpeg',
mp4: 'video/mp4',
ogg: 'audio/ogg',
pdf: 'application/pdf',
png: 'image/png',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
svg: 'image/svg+xml',
txt: 'text/plain',
wav: 'audio/wav',
webm: 'audio/webm',
webp: 'image/webp',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
/**
* Detect a MIME type from a file name's extension.
*/
export const detectMimeType = (fileName: string): string => {
const ext = path.extname(fileName).toLowerCase().slice(1);
return MIME_MAP[ext] || 'application/octet-stream';
};
export interface UploadLocalFileOptions {
knowledgeBaseId?: string;
parentId?: string;
}
/**
* Read a file from the local filesystem, upload it to S3 via a pre-signed URL,
* and create the corresponding file record. Shared by `file upload` and
* `kb upload`.
*
* @returns the created file record
*/
export const uploadLocalFile = async (
client: TrpcClient,
filePath: string,
options: UploadLocalFileOptions = {},
) => {
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) {
throw new Error(`File not found: ${resolved}`);
}
const stat = fs.statSync(resolved);
if (!stat.isFile()) {
throw new Error(`Not a file: ${resolved}`);
}
const fileName = path.basename(resolved);
const fileBuffer = fs.readFileSync(resolved);
// Compute SHA-256 hash for deduplication
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const ext = path.extname(fileName).toLowerCase().slice(1);
const fileType = detectMimeType(fileName);
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
// 1. Dedup: if the same bytes are already stored (and the object still
// exists), skip the S3 upload entirely and reuse the existing url.
const existing = (await client.file.checkFileHash.mutate({ hash })) as {
isExist?: boolean;
url?: string;
};
let pathname: string;
if (existing?.isExist && existing.url) {
pathname = existing.url;
} else {
// 2. Get a pre-signed upload URL and PUT the bytes to S3
pathname = ext ? `files/${date}/${hash}.${ext}` : `files/${date}/${hash}`;
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
const uploadRes = await fetch(presignedUrl, {
body: fileBuffer,
headers: { 'Content-Type': fileType },
method: 'PUT',
});
if (!uploadRes.ok) {
throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
}
}
// 3. Create the file record
return await client.file.createFile.mutate({
fileType,
hash,
knowledgeBaseId: options.knowledgeBaseId,
metadata: {
date,
dirname: '',
filename: fileName,
path: pathname,
},
name: fileName,
parentId: options.parentId,
size: stat.size,
url: pathname,
});
};
+2 -2
View File
@@ -127,8 +127,8 @@
],
"overrides": {
"node-gyp": "^12.4.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react": "19.2.7",
"react-dom": "19.2.7",
"vitest": "3.2.6"
}
}
+6
View File
@@ -17,3 +17,9 @@ packages:
- './stubs/business-const'
- './stubs/types'
- '.'
allowBuilds:
electron: set this to true or false
electron-winstaller: set this to true or false
esbuild: set this to true or false
get-windows: set this to true or false
node-mac-permissions: set this to true or false
@@ -15,6 +15,7 @@ import type {
GitWorkingTreeFiles,
GitWorkingTreePatches,
GitWorkingTreeStatus,
GitWorktreeListItem,
} from '@lobechat/electron-client-ipc';
import {
checkoutGitBranch as runCheckoutGitBranch,
@@ -30,6 +31,7 @@ import {
gitInfo as computeGitInfo,
listGitBranches as computeListGitBranches,
listGitRemoteBranches as computeListGitRemoteBranches,
listGitWorktrees as computeListGitWorktrees,
pullGitBranch as runPullGitBranch,
pushGitBranch as runPushGitBranch,
renameGitBranch as runRenameGitBranch,
@@ -83,6 +85,11 @@ export default class GitController extends ControllerModule {
return computeListGitRemoteBranches(dirPath);
}
@IpcMethod()
async listGitWorktrees(dirPath: string): Promise<GitWorktreeListItem[]> {
return computeListGitWorktrees(dirPath);
}
@IpcMethod()
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
return computeGitWorkingTreeStatus(dirPath);
@@ -366,14 +366,14 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
async readFiles({ paths, cwd }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
logger.debug('Starting batch file reading:', { count: paths.length });
const results: LocalReadFileResult[] = [];
for (const filePath of paths) {
logger.debug('Reading single file:', { filePath });
const result = await readLocalFile({ path: filePath });
const result = await readLocalFile({ cwd, path: filePath });
results.push(result);
}
@@ -400,9 +400,9 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
async handleMoveFiles({ items, cwd }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
logger.debug('Starting batch file move:', { itemsCount: items?.length });
return moveLocalFiles({ items });
return moveLocalFiles({ cwd, items });
}
@IpcMethod()
@@ -418,9 +418,9 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
async handleWriteFile({ path: filePath, content, cwd }: WriteLocalFileParams) {
logger.debug(`Writing file ${filePath}`, { contentLength: content?.length });
return writeLocalFile({ content, path: filePath });
return writeLocalFile({ content, cwd, path: filePath });
}
@IpcMethod()
@@ -438,12 +438,14 @@ export default class LocalFileCtr extends ControllerModule {
@IpcMethod()
async getLocalFilePreviewUrl({
accept,
allowExternalFile,
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
try {
const url = await this.app.localFileProtocolManager.createPreviewUrl({
accept,
allowExternalFile,
filePath,
workspaceRoot: workingDirectory,
});
@@ -462,12 +464,14 @@ export default class LocalFileCtr extends ControllerModule {
@IpcMethod()
async getLocalFilePreview({
accept,
allowExternalFile,
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewResult> {
try {
const preview = await this.app.localFileProtocolManager.readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot: workingDirectory,
});
@@ -226,6 +226,7 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
accept: undefined,
allowExternalFile: undefined,
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
@@ -262,6 +263,7 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
accept: 'image',
allowExternalFile: undefined,
filePath: '/workspace/image.png',
workspaceRoot: '/workspace',
});
@@ -270,6 +272,29 @@ describe('LocalFileCtr', () => {
url: 'localfile://file/workspace/image.png?token=abc',
});
});
it('should forward user-approved external preview URL access', async () => {
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
);
const result = await localFileCtr.getLocalFilePreviewUrl({
allowExternalFile: true,
path: '/tmp/worktree-switcher-demo.html',
workingDirectory: '/tmp',
});
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
allowExternalFile: true,
accept: undefined,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(result).toEqual({
success: true,
url: 'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
});
});
});
describe('getLocalFilePreview', () => {
@@ -287,6 +312,7 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
accept: undefined,
allowExternalFile: undefined,
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
@@ -329,6 +355,7 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
accept: 'image',
allowExternalFile: undefined,
filePath: '/workspace/image.png',
workspaceRoot: '/workspace',
});
@@ -341,6 +368,35 @@ describe('LocalFileCtr', () => {
success: true,
});
});
it('should forward user-approved external preview reads', async () => {
mockLocalFileProtocolManager.readPreviewFile.mockResolvedValue({
buffer: Buffer.from('<h1>Demo</h1>'),
contentType: 'text/html',
realPath: '/tmp/worktree-switcher-demo.html',
});
const result = await localFileCtr.getLocalFilePreview({
allowExternalFile: true,
path: '/tmp/worktree-switcher-demo.html',
workingDirectory: '/tmp',
});
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
allowExternalFile: true,
accept: undefined,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(result).toEqual({
preview: {
content: '<h1>Demo</h1>',
contentType: 'text/html',
type: 'text',
},
success: true,
});
});
});
describe('handleWriteFile', () => {
@@ -21,6 +21,7 @@ const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
const logger = createLogger('core:LocalFileProtocolManager');
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
const EXTERNAL_PREVIEW_APPROVAL_TTL_MS = 10 * 60 * 1000;
const normalizeAbsolutePath = (filePath: string): string | null => {
const normalized = path.normalize(filePath);
@@ -59,10 +60,7 @@ type PreviewFileAccept = 'image';
const normalizeContentType = (contentType: string): string =>
contentType.split(';')[0].trim().toLowerCase();
const isAcceptedPreviewContentType = (
contentType: string,
accept?: PreviewFileAccept,
): boolean => {
const isAcceptedPreviewContentType = (contentType: string, accept?: PreviewFileAccept): boolean => {
if (!accept) return true;
const normalizedContentType = normalizeContentType(contentType);
@@ -84,6 +82,8 @@ const isAcceptedPreviewContentType = (
export class LocalFileProtocolManager {
private readonly approvedWorkspaceRoots = new Set<string>();
private readonly externalPreviewApprovals = new Map<string, number>();
private readonly indexedProjectRoots = new Set<string>();
private handlerRegistered = false;
@@ -229,10 +229,12 @@ export class LocalFileProtocolManager {
async createPreviewUrl({
accept,
allowExternalFile,
filePath,
workspaceRoot,
}: {
accept?: PreviewFileAccept;
allowExternalFile?: boolean;
filePath: string;
workspaceRoot: string;
}): Promise<string | null> {
@@ -243,11 +245,12 @@ export class LocalFileProtocolManager {
? (
await this.readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot,
})
)?.realPath
: await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
: await this.resolveApprovedPreviewPath({ allowExternalFile, filePath, workspaceRoot });
if (!realFilePath) return null;
this.cleanupExpiredTokens();
@@ -263,14 +266,21 @@ export class LocalFileProtocolManager {
async readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot,
}: {
accept?: PreviewFileAccept;
allowExternalFile?: boolean;
filePath: string;
workspaceRoot: string;
}): Promise<PreviewFileReadResult | null> {
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
const realFilePath = await this.resolveApprovedPreviewPath({
allowExternalFile,
filePath,
persistExternalApproval: false,
workspaceRoot,
});
if (!realFilePath) return null;
const fileStat = await stat(realFilePath);
@@ -280,6 +290,10 @@ export class LocalFileProtocolManager {
const contentType = resolveLocalFileMimeType(realFilePath, buffer);
if (!isAcceptedPreviewContentType(contentType, accept)) return null;
if (allowExternalFile) {
this.grantExternalPreviewApproval(realFilePath);
}
return {
buffer,
contentType,
@@ -327,10 +341,14 @@ export class LocalFileProtocolManager {
}
private async resolveApprovedPreviewPath({
allowExternalFile,
filePath,
persistExternalApproval = true,
workspaceRoot,
}: {
allowExternalFile?: boolean;
filePath: string;
persistExternalApproval?: boolean;
workspaceRoot: string;
}): Promise<string | null> {
const normalizedFilePath = normalizeAbsolutePath(filePath);
@@ -345,15 +363,44 @@ export class LocalFileProtocolManager {
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
const workspaceRootApproved =
this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) ||
this.indexedProjectRoots.has(normalizedRealWorkspaceRoot);
if (
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
workspaceRootApproved &&
isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)
) {
return null;
return normalizedRealFilePath;
}
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
return normalizedRealFilePath;
if (this.hasExternalPreviewApproval(normalizedRealFilePath)) return normalizedRealFilePath;
if (allowExternalFile) {
return this.approveExternalPreviewFile(normalizedRealFilePath, {
persist: persistExternalApproval,
});
}
return null;
}
private async approveExternalPreviewFile(
realFilePath: string,
{ persist = true }: { persist?: boolean } = {},
): Promise<string | null> {
const fileStat = await stat(realFilePath);
if (!fileStat.isFile()) return null;
if (persist) {
this.grantExternalPreviewApproval(realFilePath);
}
return realFilePath;
}
private grantExternalPreviewApproval(realFilePath: string) {
this.cleanupExpiredExternalPreviewApprovals();
this.externalPreviewApprovals.set(realFilePath, Date.now() + EXTERNAL_PREVIEW_APPROVAL_TTL_MS);
}
private cleanupExpiredTokens() {
@@ -365,6 +412,15 @@ export class LocalFileProtocolManager {
}
}
private cleanupExpiredExternalPreviewApprovals() {
const now = Date.now();
for (const [realPath, expiresAt] of this.externalPreviewApprovals) {
if (expiresAt <= now) {
this.externalPreviewApprovals.delete(realPath);
}
}
}
private hasPreviewToken(token: string): boolean {
const record = this.previewTokens.get(token);
if (!record) return false;
@@ -383,4 +439,16 @@ export class LocalFileProtocolManager {
return record.realPath === realResolvedPath;
}
private hasExternalPreviewApproval(realFilePath: string): boolean {
const expiresAt = this.externalPreviewApprovals.get(realFilePath);
if (!expiresAt) return false;
if (expiresAt <= Date.now()) {
this.externalPreviewApprovals.delete(realFilePath);
return false;
}
return true;
}
}
@@ -263,6 +263,31 @@ describe('LocalFileProtocolManager', () => {
expect(url).toBeNull();
});
it('mints preview URLs for user-approved external files only', async () => {
const manager = new LocalFileProtocolManager();
const url = await manager.createPreviewUrl({
allowExternalFile: true,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
if (!url) throw new Error('Expected external local file preview URL');
expect(url).toContain('token=');
const repeatedUrl = await manager.createPreviewUrl({
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(repeatedUrl).toContain('token=');
const neighborUrl = await manager.createPreviewUrl({
filePath: '/tmp/other.html',
workspaceRoot: '/tmp',
});
expect(neighborUrl).toBeNull();
});
it('can approve a project root derived from an already approved nested scope', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveWorkspaceRoot('/Users/alice/project/packages/app');
@@ -326,6 +351,26 @@ describe('LocalFileProtocolManager', () => {
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/.env');
});
it('does not keep external approval when an image-only external preview rejects text', async () => {
const manager = new LocalFileProtocolManager();
mockReadFile.mockResolvedValue(Buffer.from('SECRET=value'));
const result = await manager.readPreviewFile({
accept: 'image',
allowExternalFile: true,
filePath: '/tmp/secret.txt',
workspaceRoot: '/tmp',
});
expect(result).toBeNull();
const repeatedUrl = await manager.createPreviewUrl({
filePath: '/tmp/secret.txt',
workspaceRoot: '/tmp',
});
expect(repeatedUrl).toBeNull();
});
it('does not read preview payloads outside the approved workspace root', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveIndexedProjectRoot('/Users/alice/project');
+45 -2
View File
@@ -16,6 +16,12 @@ import type { App } from '../App';
// Create logger
const logger = createLogger('core:Tray');
// Debounce window for distinguishing a single-click from the leading edge of
// a double-click. Electron delivers two `click` events before `double-click`,
// so we defer the single-click action until this window passes — the
// `double-click` handler clears it if it arrives in time.
const CLICK_DEBOUNCE_MS = 250;
export interface TrayOptions {
/**
* Tray icon path (relative to resource directory)
@@ -54,6 +60,12 @@ export class Tray {
*/
private _contextMenu?: ElectronMenu;
/**
* Pending single-click timer. Cleared by the double-click handler so a
* double-click never accidentally fires startSession before showMainWindow.
*/
private _clickTimer?: NodeJS.Timeout;
/**
* Identifier
*/
@@ -118,10 +130,25 @@ export class Tray {
// Set default context menu
this.setContextMenu();
// Left-click: open Quick Composer.
// Left-click: deferred so a follow-up `double-click` can pre-empt it.
this._tray.on('click', () => {
logger.debug(`[${this.identifier}] Tray clicked`);
this.onClick();
if (this._clickTimer) clearTimeout(this._clickTimer);
this._clickTimer = setTimeout(() => {
this._clickTimer = undefined;
this.onClick();
}, CLICK_DEBOUNCE_MS);
});
// Double-click (macOS / Windows): cancel the pending single-click and
// surface the main window instead.
this._tray.on('double-click', () => {
logger.debug(`[${this.identifier}] Tray double-clicked`);
if (this._clickTimer) {
clearTimeout(this._clickTimer);
this._clickTimer = undefined;
}
this.onDoubleClick();
});
// Right-click: pop the stored context menu manually so left-click stays
@@ -189,6 +216,18 @@ export class Tray {
}
}
/**
* Handle tray double-click event surfaces the main window.
*/
onDoubleClick() {
logger.debug(`[${this.identifier}] Tray double-click → showMainWindow`);
try {
this.app.browserManager.showMainWindow();
} catch (error) {
logger.error(`[${this.identifier}] Failed to show main window:`, error);
}
}
/**
* Replace the tray context menu with a pre-built Electron Menu instance.
* Stored in-house and popped up manually on right-click to preserve
@@ -259,6 +298,10 @@ export class Tray {
*/
destroy() {
logger.debug(`Destroying tray instance: ${this.identifier}`);
if (this._clickTimer) {
clearTimeout(this._clickTimer);
this._clickTimer = undefined;
}
if (this._tray) {
this._tray.destroy();
this._tray = undefined;
@@ -189,7 +189,7 @@ describe('Tray', () => {
expect(mockElectronTray.setContextMenu).not.toHaveBeenCalled();
});
it('should register both click and right-click listeners', () => {
it('should register click, double-click and right-click listeners', () => {
tray = new Tray(
{
iconPath: 'tray.png',
@@ -200,6 +200,7 @@ describe('Tray', () => {
const events = mockElectronTray.on.mock.calls.map((c: any[]) => c[0]);
expect(events).toContain('click');
expect(events).toContain('double-click');
expect(events).toContain('right-click');
});
@@ -346,6 +347,96 @@ describe('Tray', () => {
});
});
describe('onDoubleClick', () => {
beforeEach(() => {
tray = new Tray(
{
iconPath: 'tray.png',
identifier: 'test-tray',
},
mockApp,
);
});
it('should show the main window', () => {
tray.onDoubleClick();
expect(mockApp.browserManager.showMainWindow).toHaveBeenCalled();
});
it('should not start the capture session', () => {
tray.onDoubleClick();
expect(mockApp.screenCaptureManager.startSession).not.toHaveBeenCalled();
});
it('should not throw when showMainWindow throws', () => {
vi.mocked(mockApp.browserManager.showMainWindow).mockImplementationOnce(() => {
throw new Error('window failed');
});
expect(() => tray.onDoubleClick()).not.toThrow();
});
});
describe('click vs double-click handling', () => {
let clickHandler: (() => void) | undefined;
let doubleClickHandler: (() => void) | undefined;
beforeEach(() => {
vi.useFakeTimers();
tray = new Tray(
{
iconPath: 'tray.png',
identifier: 'test-tray',
},
mockApp,
);
clickHandler = mockElectronTray.on.mock.calls.find((c: any[]) => c[0] === 'click')?.[1];
doubleClickHandler = mockElectronTray.on.mock.calls.find(
(c: any[]) => c[0] === 'double-click',
)?.[1];
});
afterEach(() => {
vi.useRealTimers();
});
it('should debounce single click before calling startSession', () => {
expect(clickHandler).toBeDefined();
clickHandler?.();
expect(mockApp.screenCaptureManager.startSession).not.toHaveBeenCalled();
vi.advanceTimersByTime(250);
expect(mockApp.screenCaptureManager.startSession).toHaveBeenCalledTimes(1);
});
it('should cancel the pending single click when double-click fires', () => {
expect(clickHandler).toBeDefined();
expect(doubleClickHandler).toBeDefined();
clickHandler?.();
clickHandler?.();
doubleClickHandler?.();
vi.advanceTimersByTime(1000);
expect(mockApp.screenCaptureManager.startSession).not.toHaveBeenCalled();
expect(mockApp.browserManager.showMainWindow).toHaveBeenCalledTimes(1);
});
it('should only fire startSession once per single-click burst', () => {
clickHandler?.();
clickHandler?.();
vi.advanceTimersByTime(250);
expect(mockApp.screenCaptureManager.startSession).toHaveBeenCalledTimes(1);
});
});
describe('updateIcon', () => {
beforeEach(() => {
tray = new Tray(
+5
View File
@@ -3,6 +3,11 @@ import './pre-app-init';
import fixPath from 'fix-path';
import { App } from './core/App';
import { installProcessErrorHandlers } from './process-error-handlers';
// Guard the main process against transient network blips (Wi-Fi/VPN switch,
// system sleep) emitted by Electron's net stack as uncaught exceptions.
installProcessErrorHandlers();
const app = new App();
@@ -1,5 +1,5 @@
// apps/desktop/src/main/menus/impl/BaseMenuPlatform.ts
import type { MenuItemConstructorOptions } from 'electron';
import type { BaseWindow, MenuItemConstructorOptions } from 'electron';
import { BrowserWindow } from 'electron';
import type { App } from '@/core/App';
@@ -34,6 +34,26 @@ export abstract class BaseMenuPlatform {
];
}
protected closeFocusedTabOrWindow(targetWindow?: BaseWindow | null): void {
const focused =
targetWindow && 'webContents' in targetWindow
? (targetWindow as BrowserWindow)
: BrowserWindow.getFocusedWindow();
if (!focused) return;
if (focused.webContents.isDevToolsOpened()) {
focused.webContents.closeDevTools();
return;
}
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
}
private buildZoomMenuItemOption(
action: ZoomAction,
label: string,
@@ -1,4 +1,4 @@
import { app, dialog, Menu, shell } from 'electron';
import { app, BrowserWindow, dialog, Menu, shell } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
@@ -7,6 +7,9 @@ import { LinuxMenu } from './linux';
// Mock Electron modules
vi.mock('electron', () => ({
BrowserWindow: class BrowserWindow {
static getFocusedWindow = vi.fn();
},
Menu: {
buildFromTemplate: vi.fn((template) => ({ template })),
setApplicationMenu: vi.fn(),
@@ -339,6 +342,100 @@ describe('LinuxMenu', () => {
expect(closeItem.role).toBeUndefined();
});
it('should close open DevTools before delegating CmdOrCtrl+W to renderer window logic', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => true),
},
};
closeItem.click(undefined, focusedWindow);
expect(focusedWindow.webContents.closeDevTools).toHaveBeenCalled();
expect(focusedWindow.close).not.toHaveBeenCalled();
expect(mockApp.browserManager.getMainWindow).not.toHaveBeenCalled();
});
it('should broadcast tab close when CmdOrCtrl+W targets the main window', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const mainBrowserWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => false),
},
};
const broadcast = vi.fn();
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue({
broadcast,
browserWindow: mainBrowserWindow,
} as any);
closeItem.click(undefined, mainBrowserWindow);
expect(broadcast).toHaveBeenCalledWith('closeCurrentTabOrWindow');
expect(mainBrowserWindow.close).not.toHaveBeenCalled();
});
it('should close non-main windows when CmdOrCtrl+W has no DevTools panel to close', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const mainBrowserWindow = {
webContents: {
isDevToolsOpened: vi.fn(() => false),
},
};
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => false),
},
};
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue({
broadcast: vi.fn(),
browserWindow: mainBrowserWindow,
} as any);
closeItem.click(undefined, focusedWindow);
expect(focusedWindow.close).toHaveBeenCalled();
});
it('should use the focused window when Electron does not pass a menu target window', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => true),
},
};
vi.mocked(BrowserWindow.getFocusedWindow).mockReturnValue(focusedWindow as any);
closeItem.click();
expect(focusedWindow.webContents.closeDevTools).toHaveBeenCalled();
});
it('should use role for minimize (accelerator handled by Electron)', () => {
linuxMenu.buildAndSetAppMenu();
+2 -11
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, clipboard, dialog, Menu, shell } from 'electron';
import { app, clipboard, dialog, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -122,16 +122,7 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
{ type: 'separator' },
{
accelerator: 'CmdOrCtrl+W',
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
label: t('window.close'),
},
{ label: t('window.minimize'), role: 'minimize' },
+2 -11
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { app, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -164,16 +164,7 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
{ type: 'separator' },
{
accelerator: 'CmdOrCtrl+W',
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
label: t('window.close'),
},
],
+2 -11
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { app, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -185,16 +185,7 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
{ label: t('window.minimize'), role: 'minimize' },
{
accelerator: 'CmdOrCtrl+W',
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
label: t('window.close'),
},
],
@@ -0,0 +1,77 @@
import { createLogger } from '@/utils/logger';
const logger = createLogger('main:process-error-handlers');
/**
* Transient Chromium network errors emitted by Electron's `net` stack
* (`SimpleURLLoaderWrapper`). These happen during normal operation switching
* Wi-Fi / VPN, the machine sleeping, the network interface dropping and are
* NOT application bugs. Electron emits them as an `error` event on the internal
* loader; when nothing is listening they bubble up as an `uncaughtException`
* and pop the "A JavaScript error occurred in the main process" dialog, even
* though the request layer already handles the failure via promise rejection.
*
* We swallow these specific cases so transient connectivity blips never crash
* the main process. Everything else is re-thrown to preserve normal crash
* visibility.
*
* @see https://github.com/electron/electron/issues/24948
*/
const TRANSIENT_NET_ERROR_CODES = new Set([
'ERR_NETWORK_CHANGED',
'ERR_NETWORK_IO_SUSPENDED',
'ERR_INTERNET_DISCONNECTED',
'ERR_NETWORK_ACCESS_DENIED',
'ERR_CONNECTION_RESET',
'ERR_CONNECTION_ABORTED',
'ERR_CONNECTION_CLOSED',
'ERR_NAME_NOT_RESOLVED',
'ERR_TIMED_OUT',
]);
const isTransientNetError = (error: unknown): boolean => {
if (!error) return false;
const message = error instanceof Error ? error.message : String(error);
// Electron net errors are formatted as `net::ERR_XXX`.
const match = message.match(/net::(ERR_[A-Z_]+)/);
if (match && TRANSIENT_NET_ERROR_CODES.has(match[1])) return true;
// Belt-and-suspenders: these only ever originate from the net loader.
const stack = error instanceof Error ? (error.stack ?? '') : '';
return /net::ERR_/.test(message) && stack.includes('SimpleURLLoaderWrapper');
};
/**
* Install global guards for the Electron main process. Must be called as early
* as possible (before the rest of the app boots) so it catches errors from any
* module's top-level / async work.
*/
export const installProcessErrorHandlers = () => {
process.on('uncaughtException', (error) => {
if (isTransientNetError(error)) {
logger.warn('Ignoring transient network error in main process:', error.message);
return;
}
// Re-throw so genuine bugs still surface as a crash instead of being
// silently swallowed by this handler.
logger.error('Uncaught exception in main process:', error);
throw error;
});
process.on('unhandledRejection', (reason) => {
if (isTransientNetError(reason)) {
logger.warn(
'Ignoring transient network rejection in main process:',
reason instanceof Error ? reason.message : String(reason),
);
return;
}
logger.error('Unhandled rejection in main process:', reason);
});
logger.info('Process error handlers installed');
};
@@ -15,13 +15,21 @@ const mocks = vi.hoisted(() => ({
),
}));
const mockGlobalConfigDependencies = (enableBusinessFeatures: boolean) => {
interface MockGlobalConfigOptions {
agentGatewayUrl?: string;
enableAgentGateway?: boolean;
}
const mockGlobalConfigDependencies = (
enableBusinessFeatures: boolean,
options: MockGlobalConfigOptions = {},
) => {
vi.doMock('@lobechat/business-const', () => ({
ENABLE_BUSINESS_FEATURES: enableBusinessFeatures,
}));
vi.doMock('@/config/klavis', () => ({
klavisEnv: {},
vi.doMock('@/config/composio', () => ({
composioEnv: {},
}));
vi.doMock('@/const/version', () => ({
@@ -29,7 +37,12 @@ const mockGlobalConfigDependencies = (enableBusinessFeatures: boolean) => {
}));
vi.doMock('@/envs/app', () => ({
appEnv: {},
appEnv: {
...(options.agentGatewayUrl ? { AGENT_GATEWAY_URL: options.agentGatewayUrl } : {}),
...(options.enableAgentGateway === undefined
? {}
: { ENABLE_AGENT_GATEWAY: options.enableAgentGateway }),
},
getAppConfig: vi.fn(() => ({
DEFAULT_AGENT_CONFIG: '',
})),
@@ -113,6 +126,18 @@ const loadCapturedProviderConfig = async (enableBusinessFeatures: boolean) => {
>;
};
const loadServerConfig = async (
enableBusinessFeatures: boolean,
options?: MockGlobalConfigOptions,
) => {
vi.resetModules();
mocks.genServerAiProvidersConfig.mockClear();
mockGlobalConfigDependencies(enableBusinessFeatures, options);
const { getServerGlobalConfig } = await import('./index');
return getServerGlobalConfig();
};
describe('getServerGlobalConfig', () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -139,4 +164,36 @@ describe('getServerGlobalConfig', () => {
expect(providerConfig[ModelProvider.OpenAI]).toBeUndefined();
expect(providerConfig[ModelProvider.DeepSeek].enabled).toBe(true);
});
it('should enable gateway mode for business builds', async () => {
await expect(loadServerConfig(true)).resolves.toMatchObject({
enableGatewayMode: true,
});
});
it('should enable gateway mode for self-hosted builds only when explicitly enabled with a gateway url', async () => {
await expect(
loadServerConfig(false, {
agentGatewayUrl: 'https://gateway.test.com',
enableAgentGateway: true,
}),
).resolves.toMatchObject({
agentGatewayUrl: 'https://gateway.test.com',
enableGatewayMode: true,
});
await expect(
loadServerConfig(false, {
agentGatewayUrl: 'https://gateway.test.com',
enableAgentGateway: false,
}),
).resolves.toMatchObject({
agentGatewayUrl: 'https://gateway.test.com',
enableGatewayMode: false,
});
await expect(loadServerConfig(false, { enableAgentGateway: true })).resolves.toMatchObject({
enableGatewayMode: false,
});
});
});
+4 -2
View File
@@ -1,7 +1,7 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { ModelProvider } from 'model-bank';
import { klavisEnv } from '@/config/klavis';
import { composioEnv } from '@/config/composio';
import { isDesktop } from '@/const/version';
import { appEnv, getAppConfig } from '@/envs/app';
import { authEnv } from '@/envs/auth';
@@ -104,7 +104,9 @@ export const getServerGlobalConfig = async () => {
disableEmailPassword: authEnv.AUTH_DISABLE_EMAIL_PASSWORD,
enableBusinessFeatures: ENABLE_BUSINESS_FEATURES,
enableEmailVerification: authEnv.AUTH_EMAIL_VERIFICATION,
enableKlavis: !!klavisEnv.KLAVIS_API_KEY,
enableComposio: !!composioEnv.COMPOSIO_API_KEY,
enableGatewayMode:
ENABLE_BUSINESS_FEATURES || (!!appEnv.ENABLE_AGENT_GATEWAY && !!appEnv.AGENT_GATEWAY_URL),
enableLobehubSkill: !!(appEnv.MARKET_TRUSTED_CLIENT_SECRET && appEnv.MARKET_TRUSTED_CLIENT_ID),
enableMagicLink: authEnv.AUTH_ENABLE_MAGIC_LINK,
enableMarketTrustedClient: !!(
@@ -14,14 +14,14 @@ import {
} from '@lobechat/agent-runtime';
import { LobeActivatorIdentifier } from '@lobechat/builtin-tool-activator';
import {
type ComposioServiceSummary,
type CredSummary,
generateComposioServicesList,
generateCredsList,
generateKlavisServicesList,
type KlavisServiceSummary,
} from '@lobechat/builtin-tool-creds';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { BRANDING_PROVIDER } from '@lobechat/business-const';
import { KLAVIS_SERVER_TYPES } from '@lobechat/const';
import { COMPOSIO_APP_TYPES } from '@lobechat/const';
import {
type AgentContextDocument,
type AgentGroupConfig,
@@ -38,7 +38,12 @@ import {
ToolResolver,
} from '@lobechat/context-engine';
import { parse } from '@lobechat/conversation-flow';
import { consumeStreamUntilDone } from '@lobechat/model-runtime';
import {
applyModelExtendParams,
type ChatStreamPayload,
consumeStreamUntilDone,
type ModelExtendParams,
} from '@lobechat/model-runtime';
import {
context as otelContext,
SpanKind,
@@ -67,8 +72,9 @@ import {
} from '@lobechat/types';
import { sanitizeToolCallArguments, serializePartsForStorage } from '@lobechat/utils';
import debug from 'debug';
import { type ExtendParamsType, ModelProvider } from 'model-bank';
import { klavisEnv } from '@/config/klavis';
import { composioEnv } from '@/config/composio';
import { type MessageModel, MessageModel as MessageModelClass } from '@/database/models/message';
import { TopicModel } from '@/database/models/topic';
import { UserModel } from '@/database/models/user';
@@ -80,6 +86,10 @@ import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/type
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import type { HookDispatcher } from '@/server/services/agentRuntime/hooks/HookDispatcher';
import type {
ExecGroupMemberParams,
ExecGroupMemberResult,
} from '@/server/services/agentRuntime/types';
import {
type DeviceAccessReason,
isDeviceToolIdentifier,
@@ -89,6 +99,7 @@ import { FileService } from '@/server/services/file';
import { MessageService } from '@/server/services/message';
import { OnboardingService } from '@/server/services/onboarding';
import {
type ServerAgentMemberRunner,
type ServerSubAgentRunner,
type ToolExecutionResultResponse,
type ToolExecutionService,
@@ -405,6 +416,147 @@ const buildServerVirtualSubAgentRunner = (
};
};
/**
* Build the per-tool "call agent member" runner for the group orchestration
* server tool (`lobe-group-management`). Mirrors {@link buildServerVirtualSubAgentRunner}
* but for group members: it owns the group tool message (the parked tool call)
* and the per-member anchors that drive the K=N member barrier.
*
* For each `agentMember.run(...)` it:
* 1. creates the group tool placeholder (`tool_call_id` = the group-management
* call id) stamped with the barrier target + finish disposition;
* 2. for a single member uses that placeholder as the member anchor; for
* multiple members creates one child anchor per member under it;
* 3. forks each member via `ctx.execGroupMember` (in-group or isolated);
* 4. backfills anchors for members that failed to start so the barrier can
* still complete, and tears everything down when none started.
*
* Returns `undefined` when group-member execution is unavailable (no
* `execGroupMember` callback, or missing agent/topic/group context).
*/
const buildServerAgentMemberRunner = (
ctx: RuntimeExecutorContext,
state: AgentState,
chatToolPayload: ChatToolPayload,
parentMessageId: string,
): ServerAgentMemberRunner | undefined => {
const execGroupMember = ctx.execGroupMember;
if (!execGroupMember) return undefined;
const agentId = state.metadata?.agentId;
const topicId = ctx.topicId ?? state.metadata?.topicId;
const groupId = state.metadata?.groupId ?? undefined;
if (!agentId || !topicId || !groupId) return undefined;
return {
run: async ({ members, mode, onComplete, disableTools, timeout }) => {
const expectedMembers = members.length;
if (expectedMembers === 0) return { started: false, startedCount: 0 };
// 1. Group tool placeholder — the parked tool call the supervisor op waits
// on. Stamped with the barrier target + finish disposition so the resume
// path (and verify watchdog) resolve resume-vs-finish on their own.
const groupTool = await ctx.messageModel.create({
agentId,
content: '',
parentId: parentMessageId,
plugin: chatToolPayload as any,
pluginState: { expectedMembers, onComplete, status: 'pending' },
role: 'tool',
threadId: state.metadata?.threadId,
tool_call_id: chatToolPayload.id,
topicId,
});
// 2. Per-member anchors. A single member collapses onto the group tool
// message; multiple members each get a child anchor under it.
const anchorIds: string[] = [];
if (expectedMembers === 1) {
anchorIds.push(groupTool.id);
} else {
for (let i = 0; i < expectedMembers; i += 1) {
const memberToolCallId = `${chatToolPayload.id}::m${i}`;
const anchor = await ctx.messageModel.create({
agentId,
content: '',
parentId: groupTool.id,
plugin: { ...(chatToolPayload as any), id: memberToolCallId },
pluginState: { status: 'pending' },
role: 'tool',
threadId: state.metadata?.threadId,
tool_call_id: memberToolCallId,
topicId,
});
anchorIds.push(anchor.id);
}
}
// 3. Fork members.
let startedCount = 0;
await Promise.all(
members.map(async (member, i) => {
const anchorMessageId = anchorIds[i];
try {
const result = await execGroupMember({
agentId: member.agentId,
anchorMessageId,
disableTools,
expectedMembers,
groupId,
groupToolMessageId: groupTool.id,
instruction: member.instruction,
mode,
onComplete,
parentOperationId: ctx.operationId,
timeout,
topicId,
});
if (result?.started) {
startedCount += 1;
return;
}
} catch (error) {
log(
'buildServerAgentMemberRunner: member %s failed to start: %O',
member.agentId,
error,
);
}
// Member failed to start — its completion bridge will never fire, so
// backfill the anchor as errored to keep the K=N barrier reachable.
try {
await ctx.messageModel.updateToolMessage(anchorMessageId, {
content: `Agent member "${member.agentId}" failed to start.`,
pluginState: { status: 'error' },
});
} catch (error) {
log(
'buildServerAgentMemberRunner: failed to mark anchor %s as errored: %O',
anchorMessageId,
error,
);
}
}),
);
// None started — no bridge will ever fire, so tear down the placeholders
// and let the caller surface an inline tool error instead of parking.
if (startedCount === 0) {
for (const id of new Set([...anchorIds, groupTool.id])) {
try {
await ctx.messageModel.deleteMessage(id);
} catch (error) {
log('buildServerAgentMemberRunner: cleanup failed for %s: %O', id, error);
}
}
return { started: false, startedCount: 0 };
}
return { started: true, startedCount };
},
};
};
const shouldRetryLLM = (kind: LLMErrorKind, attempt: number, maxRetries: number) =>
kind === 'retry' && attempt <= maxRetries;
@@ -522,6 +674,12 @@ export interface RuntimeExecutorContext {
botPlatformContext?: BotPlatformContext;
discordContext?: any;
evalContext?: EvalContext;
/**
* Callback to fork a group member ("call agent member") under a
* `lobe-group-management` tool call. Injected by AiAgentService; powers the
* per-tool `agentMember` runner (in-group + isolated members, K=N barrier).
*/
execGroupMember?: (params: ExecGroupMemberParams) => Promise<ExecGroupMemberResult>;
/**
* Callback to run a legacy agent invocation server-side.
* Injected by AiAgentService so exec_sub_agent / exec_sub_agents executors
@@ -721,6 +879,7 @@ export const createRuntimeExecutors = (
type ContentPart = { text: string; type: 'text' } | { image: string; type: 'image' };
let shouldReplayAssistantReasoning = false;
let preserveThinkingForPayload: boolean | undefined;
let resolvedExtendParams: ModelExtendParams | undefined;
// Process messages through serverMessagesEngine to inject system role, knowledge, etc.
// Rebuild params from agentConfig at execution time (capabilities built dynamically)
@@ -736,19 +895,39 @@ export const createRuntimeExecutors = (
: undefined;
const preserveThinkingRequested = preserveThinkingConfigured === true;
const readExtendParams = (
card: (typeof builtinModels)[number] | undefined,
): string[] | undefined =>
card &&
'settings' in card &&
card.settings &&
typeof card.settings === 'object' &&
'extendParams' in card.settings
? (card.settings as { extendParams?: string[] }).extendParams
: undefined;
const modelCard = builtinModels.find(
(item) =>
item.providerId === provider &&
(item.id === model || item.config?.deploymentName === model),
);
const modelExtendParams =
modelCard &&
'settings' in modelCard &&
modelCard.settings &&
typeof modelCard.settings === 'object' &&
'extendParams' in modelCard.settings
? (modelCard.settings as { extendParams?: string[] }).extendParams
: undefined;
const canonicalModelCard = builtinModels.find(
(item) => item.id === model || item.config?.deploymentName === model,
);
const modelKnowledgeCutoff =
modelCard?.knowledgeCutoff ??
(provider === ModelProvider.LobeHub ? canonicalModelCard?.knowledgeCutoff : undefined);
let modelExtendParams = readExtendParams(modelCard);
// Aggregation providers (e.g. `lobehub`) may serve a model without copying
// its origin `settings.extendParams`. Fall back to the canonical model card
// (matched by id across any provider) so reasoning/thinking params like
// `thinkingLevel` still reach the model. Mirrors the client-side
// `transformToAiModelList` re-namespacing behavior.
if (!modelExtendParams || modelExtendParams.length === 0) {
modelExtendParams = readExtendParams(canonicalModelCard);
}
const modelSupportsPreserveThinkingFromCard =
Array.isArray(modelExtendParams) && modelExtendParams.includes('preserveThinking');
@@ -763,6 +942,19 @@ export const createRuntimeExecutors = (
modelSupportsPreserveThinking && typeof preserveThinkingConfigured === 'boolean'
? preserveThinkingConfigured
: undefined;
// Resolve model extend params (thinkingLevel, reasoning effort, urlContext, …)
// from the agent chat config so the server-side agent runtime forwards the same
// runtime params the client chat service does. Without this, e.g. Gemini 3 Pro's
// `thinkingLevel` never reaches the request and thought summaries come back empty.
if (agentConfig.chatConfig) {
resolvedExtendParams = applyModelExtendParams({
chatConfig: agentConfig.chatConfig,
extendParams: modelExtendParams as ExtendParamsType[] | undefined,
model,
});
}
const messagesForContext = shouldReplayAssistantReasoning
? (llmPayload.messages as UIChatMessage[])
: stripAssistantReasoningForReplay(llmPayload.messages as UIChatMessage[]);
@@ -999,39 +1191,38 @@ export const createRuntimeExecutors = (
}
}
// {{KLAVIS_SERVICES_LIST}} — used by lobe-creds system role (Klavis integrations section).
// Mirrors client-side: klavisStoreSelectors.getServers() filtered by connection status.
let klavisServicesListStr = '';
if (ctx.serverDB && ctx.userId && !!klavisEnv.KLAVIS_API_KEY) {
// {{COMPOSIO_SERVICES_LIST}} — used by lobe-creds system role (Composio integrations section).
let composioServicesListStr = '';
if (ctx.serverDB && ctx.userId && !!composioEnv.COMPOSIO_API_KEY) {
try {
const { PluginModel } = await import('@/database/models/plugin');
const pluginModel = new PluginModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
const allPlugins = await pluginModel.query();
const validKlavisIds = new Set(KLAVIS_SERVER_TYPES.map((t) => t.identifier));
const validComposioIds = new Set(COMPOSIO_APP_TYPES.map((t) => t.identifier));
const connectedIds = new Set(
allPlugins
.filter(
(p) =>
validKlavisIds.has(p.identifier) &&
(p.customParams as any)?.klavis?.isAuthenticated === true,
validComposioIds.has(p.identifier) &&
(p.customParams as any)?.composio?.status === 'ACTIVE',
)
.map((p) => p.identifier),
);
const connected: KlavisServiceSummary[] = KLAVIS_SERVER_TYPES.filter((t) =>
const connected: ComposioServiceSummary[] = COMPOSIO_APP_TYPES.filter((t) =>
connectedIds.has(t.identifier),
).map((t) => ({ identifier: t.identifier, name: t.label }));
const available: KlavisServiceSummary[] = KLAVIS_SERVER_TYPES.filter(
const available: ComposioServiceSummary[] = COMPOSIO_APP_TYPES.filter(
(t) => !connectedIds.has(t.identifier),
).map((t) => ({ identifier: t.identifier, name: t.label }));
klavisServicesListStr = generateKlavisServicesList(connected, available);
composioServicesListStr = generateComposioServicesList(connected, available);
log(
'Fetched Klavis services for {{KLAVIS_SERVICES_LIST}}: connected=%d, available=%d',
'Fetched Composio services for {{COMPOSIO_SERVICES_LIST}}: connected=%d, available=%d',
connected.length,
available.length,
);
} catch (error) {
log(
'Failed to fetch Klavis services for {{KLAVIS_SERVICES_LIST}} substitution: %O',
'Failed to fetch Composio services for {{COMPOSIO_SERVICES_LIST}} substitution: %O',
error,
);
}
@@ -1055,12 +1246,18 @@ export const createRuntimeExecutors = (
sandbox_enabled: sandboxEnabled,
sandbox_uploaded_files: sandboxUploadedFiles,
CREDS_LIST: credsListStr,
KLAVIS_SERVICES_LIST: klavisServicesListStr,
COMPOSIO_SERVICES_LIST: composioServicesListStr,
// Memory tool variables
memory_effort: memoryEffort,
},
userTimezone: ctx.userTimezone,
capabilities: {
isCanUseAudio: (m: string, p: string) => {
const info =
builtinModels.find((item) => item.id === m && item.providerId === p) ??
builtinModels.find((item) => item.id === m);
return info?.abilities?.audio ?? false;
},
isCanUseFC: (m: string, p: string) => {
const info = builtinModels.find((item) => item.id === m && item.providerId === p);
return info?.abilities?.functionCall ?? true;
@@ -1106,6 +1303,7 @@ export const createRuntimeExecutors = (
},
messages: messagesForContext,
model,
modelKnowledgeCutoff,
provider,
systemRole: agentConfig.systemRole ?? undefined,
toolDiscoveryConfig,
@@ -1205,6 +1403,9 @@ export const createRuntimeExecutors = (
model,
stream,
tools,
// ModelExtendParams keeps provider-specific effort/thinking values as loose
// strings (e.g. hy3's 'no_think'); the runtime payload narrows them, so cast.
...(resolvedExtendParams as Partial<ChatStreamPayload>),
...(typeof preserveThinkingForPayload === 'boolean' && {
preserveThinking: preserveThinkingForPayload,
}),
@@ -2446,7 +2647,7 @@ 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)
// lobehubSkill / composio tools correctly (LLM responses don't carry source)
if (toolSource && !chatToolPayload.source) {
chatToolPayload.source = toolSource;
}
@@ -2463,7 +2664,14 @@ export const createRuntimeExecutors = (
toolExecutionService.executeTool(chatToolPayload, {
activeDeviceId: state.metadata?.activeDeviceId,
agentId: state.metadata?.agentId,
agentMember: buildServerAgentMemberRunner(
ctx,
state,
chatToolPayload,
payload.parentMessageId,
),
documentId: state.metadata?.documentId,
editingAgentId: state.metadata?.editingAgentId,
execSubAgent: ctx.execSubAgent,
executionTimeoutMs: timeoutMs,
groupId: state.metadata?.groupId,
@@ -2496,6 +2704,10 @@ export const createRuntimeExecutors = (
toolResultMaxLength,
topicId: ctx.topicId,
userId: ctx.userId,
// Device-bound cwd folded into deviceSystemInfo at operation
// creation; resume-safe via computeDeviceContext (recovers it
// from the prior tool message's pluginState.metadata).
workingDirectory: state.metadata?.deviceSystemInfo?.workingDirectory,
workspaceId: state.metadata?.workspaceId ?? ctx.workspaceId,
}),
{
@@ -3026,7 +3238,7 @@ 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)
// lobehubSkill / composio tools correctly (LLM responses don't carry source)
const batchToolSource =
state.operationToolSet?.sourceMap?.[chatToolPayload.identifier] ??
state.toolSourceMap?.[chatToolPayload.identifier];
@@ -3045,6 +3257,12 @@ export const createRuntimeExecutors = (
toolExecutionService.executeTool(chatToolPayload, {
activeDeviceId: state.metadata?.activeDeviceId,
agentId: state.metadata?.agentId,
agentMember: buildServerAgentMemberRunner(
ctx,
state,
chatToolPayload,
payload.parentMessageId,
),
documentId: state.metadata?.documentId,
execSubAgent: ctx.execSubAgent,
executionTimeoutMs: timeoutMs,
@@ -14,6 +14,7 @@ const mockBuiltinModels = vi.hoisted(() => [
{
abilities: { functionCall: true, video: false, vision: true },
id: 'gpt-4',
knowledgeCutoff: '2024-06',
providerId: 'openai',
},
{
@@ -58,6 +59,9 @@ vi.mock('@/server/services/message', () => ({
// @lobechat/model-runtime resolves to @cloud/business-model-runtime which has
// cloud-specific dependencies that are unavailable in the test environment
vi.mock('@lobechat/model-runtime', () => ({
// The executor resolves extend params via this helper; an empty result keeps
// the runtime payload unchanged, matching this suite's pre-existing behavior.
applyModelExtendParams: vi.fn(() => ({})),
consumeStreamUntilDone: vi.fn().mockResolvedValue(undefined),
// `llmErrorClassification.ts` reads these at module-load time; an empty
// spec map is fine here because this suite never exercises the runtime
@@ -74,13 +78,16 @@ vi.mock('@/business/client/model-bank/loadModels', () => ({
// model-bank is a TypeScript source file that cannot be dynamically imported in vitest
vi.mock('model-bank', () => ({
LOBE_DEFAULT_MODEL_LIST: mockBuiltinModels,
ModelProvider: {
LobeHub: 'lobehub',
},
}));
// klavisEnv uses @t3-oss/env-nextjs which throws in jsdom (treats it as client context)
vi.mock('@/config/klavis', () => ({
getKlavisConfig: vi.fn(),
getServerKlavisApiKey: vi.fn().mockReturnValue(undefined),
klavisEnv: { KLAVIS_API_KEY: undefined },
// composioEnv uses @t3-oss/env-nextjs which throws in jsdom (treats it as client context)
vi.mock('@/config/composio', () => ({
getComposioConfig: vi.fn(),
getServerComposioApiKey: vi.fn().mockReturnValue(undefined),
composioEnv: { COMPOSIO_API_KEY: undefined },
}));
// fileEnv uses @t3-oss/env-core; stub the only field the runtime reads so the
@@ -125,6 +132,7 @@ describe('RuntimeExecutors', () => {
mockMessageModel = {
create: vi.fn().mockResolvedValue({ id: 'msg-123' }),
deleteMessage: vi.fn().mockResolvedValue({ success: true }),
// call_llm does a parent existence preflight; return a truthy row by
// default so existing tests don't have to stub it.
findById: vi.fn().mockResolvedValue({ id: 'msg-existing' }),
@@ -1571,6 +1579,87 @@ describe('RuntimeExecutors', () => {
);
});
it('should pass model knowledge cutoff into serverMessagesEngine', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
const instruction = {
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'openai',
},
type: 'call_llm' as const,
};
await executors.call_llm!(instruction, state);
expect(engineSpy).toHaveBeenCalledWith(
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
);
});
it('should resolve LobeHub routed model knowledge cutoff by model id fallback', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
await executors.call_llm!(
{
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'lobehub',
},
type: 'call_llm' as const,
},
state,
);
expect(engineSpy).toHaveBeenCalledWith(
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
);
});
it('should omit model knowledge cutoff for unknown non-LobeHub providers', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
await executors.call_llm!(
{
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'custom-openai',
},
type: 'call_llm' as const,
},
state,
);
expect(engineSpy.mock.calls[0][0]).toHaveProperty('modelKnowledgeCutoff', undefined);
});
it('should keep current turn when agent historyCount is 0', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
@@ -4850,10 +4939,9 @@ describe('RuntimeExecutors', () => {
...overrides,
});
it('call_tool sets stop:true in tool_result payload when tool returns execSubAgent state', async () => {
// Simulate agentManagement.callAgent returning execSubAgent state
it('call_tool preserves stop:true for legacy execSubAgent state', async () => {
mockToolExecutionService.executeTool.mockResolvedValue({
content: '🚀 Triggered async task to call agent "target-agent"',
content: 'Legacy async task result',
executionTime: 10,
state: {
parentMessageId: 'tool-msg-id',
@@ -4894,13 +4982,112 @@ describe('RuntimeExecutors', () => {
expect((result.nextContext?.payload as any).stop).toBe(true);
});
it('exec_sub_agent executor creates task message and calls execSubAgent callback', async () => {
const mockExecSubAgentTask = vi
it('call_tool lets server callAgent run as a deferred tool via the subAgent runner', async () => {
const mockExecVirtualSubAgent = vi
.fn()
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
const ctxWithCallback = {
...ctx,
execSubAgent: mockExecSubAgentTask,
execVirtualSubAgent: mockExecVirtualSubAgent,
topicId: 'topic-123',
};
mockMessageModel.create.mockResolvedValueOnce({ id: 'tool-msg-id' });
mockToolExecutionService.executeTool.mockImplementation(
async (_payload: any, context: any) => {
const subAgent = await context.subAgent.run({
agentId: 'target-agent-id',
description: 'Call agent target-agent',
instruction: 'Do something useful',
timeout: 1_800_000,
});
return {
content: '',
deferred: true,
executionTime: 10,
state: {
status: 'pending',
subOperationId: subAgent.subOperationId,
targetAgentId: 'target-agent-id',
threadId: subAgent.threadId,
},
success: subAgent.started,
};
},
);
const executors = createRuntimeExecutors(ctxWithCallback);
const state = createMockState();
const instruction = {
payload: {
parentMessageId: 'assistant-msg-id',
toolCalling: {
apiName: 'callAgent',
arguments: JSON.stringify({
agentId: 'target-agent-id',
instruction: 'Do something useful',
runAsTask: true,
}),
id: 'tool-call-1',
identifier: 'lobe-agent-management',
type: 'default' as const,
},
},
type: 'call_tool' as const,
};
const result = await executors.call_tool!(instruction, state);
expect(mockMessageModel.create).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'parent-agent-id',
plugin: expect.objectContaining({
apiName: 'callAgent',
identifier: 'lobe-agent-management',
}),
pluginState: { status: 'pending' },
parentId: 'assistant-msg-id',
role: 'tool',
tool_call_id: 'tool-call-1',
topicId: 'topic-123',
}),
);
expect(mockExecVirtualSubAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'target-agent-id',
instruction: 'Do something useful',
parentMessageId: 'tool-msg-id',
parentOperationId: 'op-123',
title: 'Call agent target-agent',
topicId: 'topic-123',
}),
);
expect(result.newState.status).toBe('waiting_for_async_tool');
expect(result.newState.pendingToolsCalling).toEqual([
expect.objectContaining({
apiName: 'callAgent',
id: 'tool-call-1',
identifier: 'lobe-agent-management',
}),
]);
expect(result.events).toEqual([
expect.objectContaining({
canResume: true,
reason: 'async_tool',
type: 'interrupted',
}),
]);
expect(result.nextContext).toBeUndefined();
});
it('exec_sub_agent executor creates task message and calls execSubAgent callback', async () => {
const mockExecSubAgent = vi
.fn()
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
const ctxWithCallback = {
...ctx,
execSubAgent: mockExecSubAgent,
topicId: 'topic-123',
};
@@ -4926,6 +5113,9 @@ describe('RuntimeExecutors', () => {
expect(mockMessageModel.create).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'parent-agent-id',
metadata: expect.objectContaining({
targetAgentId: 'target-agent-id',
}),
role: 'task',
parentId: 'tool-msg-id',
topicId: 'topic-123',
@@ -4933,7 +5123,7 @@ describe('RuntimeExecutors', () => {
);
// execSubAgent callback fired with targetAgentId
expect(mockExecSubAgentTask).toHaveBeenCalledWith(
expect(mockExecSubAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'target-agent-id',
instruction: 'Do something useful',
@@ -4947,10 +5137,10 @@ describe('RuntimeExecutors', () => {
});
it('exec_sub_agent blocks nested dispatch when current state is already a sub-agent', async () => {
const mockExecSubAgentTask = vi.fn();
const mockExecSubAgent = vi.fn();
const ctxWithCallback = {
...ctx,
execSubAgentTask: mockExecSubAgentTask,
execSubAgent: mockExecSubAgent,
topicId: 'topic-123',
};
@@ -4983,7 +5173,7 @@ describe('RuntimeExecutors', () => {
success: false,
});
expect(mockMessageModel.create).not.toHaveBeenCalled();
expect(mockExecSubAgentTask).not.toHaveBeenCalled();
expect(mockExecSubAgent).not.toHaveBeenCalled();
});
it('exec_sub_agent gracefully skips dispatch when execSubAgent not injected', async () => {
@@ -1,3 +1,4 @@
import { AgentRuntimeErrorType } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { formatErrorEventData } from '../formatErrorEventData';
@@ -62,6 +63,75 @@ describe('formatErrorEventData', () => {
});
describe('business-typed errors (must not be overridden)', () => {
it('preserves traceable runtime payload body for gateway error events', () => {
const out = formatErrorEventData(
{
error: { message: 'Upstream failed', traceId: 'trace-123' },
errorType: AgentRuntimeErrorType.ProviderBizError,
provider: 'openai',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Upstream failed',
provider: 'openai',
traceId: 'trace-123',
},
error: 'Upstream failed',
errorType: AgentRuntimeErrorType.ProviderBizError,
phase: 'llm_execution',
});
});
it('uses the normalized runtime type for gateway error events', () => {
const out = formatErrorEventData(
{
error: { message: 'Payment required', status: 402, traceId: 'trace-402' },
errorType: AgentRuntimeErrorType.ProviderBizError,
provider: 'lobehub',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Payment required',
provider: 'lobehub',
status: 402,
traceId: 'trace-402',
},
error: 'Payment required',
errorType: AgentRuntimeErrorType.InsufficientQuota,
phase: 'llm_execution',
});
});
it('uses the normalized runtime message when the payload message is a placeholder', () => {
const out = formatErrorEventData(
{
error: { message: 'Payment required', status: 402, traceId: 'trace-402' },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'error',
provider: 'lobehub',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Payment required',
provider: 'lobehub',
status: 402,
traceId: 'trace-402',
},
error: 'Payment required',
errorType: AgentRuntimeErrorType.InsufficientQuota,
phase: 'llm_execution',
});
});
it('preserves ConversationParentMissing errorType and message even when .cause has PG info', () => {
// Mirrors createConversationParentMissingError from messagePersistErrors.ts:
// the user-facing errorType lives on the error object directly, and the
@@ -1,5 +1,11 @@
import { pickNonEmptyString, toRecord } from '@lobechat/utils/object';
import { formatErrorForState } from './formatErrorForState';
import { formatPgError, pgErrorType, unwrapPgError } from './pgError';
const isErrorType = (value: unknown): value is string | number =>
typeof value === 'string' || typeof value === 'number';
/**
* Normalize an arbitrary thrown value into the shape the runtime stream-event
* protocol expects. Extracts a human-readable `error` string and a best-effort
@@ -23,55 +29,38 @@ import { formatPgError, pgErrorType, unwrapPgError } from './pgError';
* DB failures by SQLSTATE.
*/
export const formatErrorEventData = (error: unknown, phase: string) => {
let errorMessage = 'Unknown error';
let errorType: string | undefined;
// True when `errorType` came from a business-typed field on the error
// payload (step 1 above). Driver class names assigned via `error.name`
// do NOT set this flag, so raw `PostgresError` / `DatabaseError` instances
// still fall through to the PG unwrap step.
let hasBusinessErrorType = false;
if (error && typeof error === 'object') {
const payload = error as { error?: unknown; errorType?: unknown; message?: unknown };
if (typeof payload.errorType === 'string') {
errorType = payload.errorType;
hasBusinessErrorType = true;
}
if (typeof payload.message === 'string' && payload.message.length > 0) {
errorMessage = payload.message;
} else if (typeof payload.error === 'string' && payload.error.length > 0) {
errorMessage = payload.error;
} else if (
payload.error &&
typeof payload.error === 'object' &&
'message' in payload.error &&
typeof payload.error.message === 'string'
) {
errorMessage = payload.error.message;
} else if (error instanceof Error && error.message.length > 0) {
errorMessage = error.message;
} else if (errorType) {
errorMessage = errorType;
}
} else if (error instanceof Error && error.message.length > 0) {
errorMessage = error.message;
errorType = error.name;
} else if (typeof error === 'string' && error.length > 0) {
errorMessage = error;
}
const payload = toRecord(error);
const rawPayloadErrorType = payload?.errorType ?? payload?.type;
const payloadErrorType = isErrorType(rawPayloadErrorType) ? rawPayloadErrorType : undefined;
const structuredError =
error instanceof Error || payloadErrorType === undefined
? undefined
: formatErrorForState(payload);
const body = structuredError?.body;
const hasPayloadErrorType = payloadErrorType !== undefined;
let errorType = hasPayloadErrorType
? String(structuredError?.type ?? payloadErrorType)
: undefined;
const payloadError = payload?.error;
let errorMessage =
pickNonEmptyString(structuredError?.message) ??
pickNonEmptyString(payload?.message) ??
pickNonEmptyString(payloadError) ??
pickNonEmptyString(toRecord(payloadError)?.message) ??
(error instanceof Error ? pickNonEmptyString(error.message) : pickNonEmptyString(error)) ??
errorType ??
'Unknown error';
if (!errorType && error instanceof Error && error.name) {
errorType = error.name;
}
// Enrichment: run PG unwrap whenever no *business-typed* errorType was
// Enrichment: run PG unwrap whenever no payload errorType was
// declared. This covers both Drizzle-wrapped errors (PG info under .cause)
// AND raw top-level driver errors like `PostgresError` / `DatabaseError`
// which carry a specific `name` but are still real PG errors deserving
// `pg_<sqlstate>` classification on the dashboard.
if (!hasBusinessErrorType) {
if (!hasPayloadErrorType) {
const pg = unwrapPgError(error);
if (pg) {
errorMessage = formatPgError(pg);
@@ -80,6 +69,7 @@ export const formatErrorEventData = (error: unknown, phase: string) => {
}
return {
...(body === undefined ? {} : { body }),
error: errorMessage,
errorType,
phase,
@@ -16,7 +16,35 @@ describe('formatErrorForState', () => {
expect(result.type).toBe(AgentRuntimeErrorType.InvalidProviderAPIKey);
expect(result.message).toBe('Invalid API key');
expect(result.body).toEqual({ detail: 'Unauthorized' });
expect(result.body).toEqual({
detail: 'Unauthorized',
message: 'Invalid API key',
provider: 'openai',
});
});
it('preserves top-level context from ChatCompletionErrorPayload', () => {
const budget = { required: 12 };
const result = formatErrorForState({
budget,
error: { message: 'Budget exceeded' },
errorType: ChatErrorType.FreePlanLimit,
provider: 'lobehub',
});
expect(result).toMatchObject({
attribution: 'user',
body: {
budget,
message: 'Budget exceeded',
provider: 'lobehub',
},
category: 'quota',
httpStatus: 402,
message: 'Budget exceeded',
type: ChatErrorType.FreePlanLimit,
});
});
it('wraps standard Error as InternalServerError', () => {
@@ -180,6 +208,43 @@ describe('formatErrorForState', () => {
expect(result.category).toBe('quota');
});
it('keeps payload.error available when _responseBody is present', () => {
const result = formatErrorForState({
_responseBody: { provider: 'lobehub' },
error: { status: 402 },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'opaque upstream message',
});
expect(result).toMatchObject({
body: {
error: { status: 402 },
message: 'opaque upstream message',
provider: 'lobehub',
},
category: 'quota',
type: AgentRuntimeErrorType.InsufficientQuota,
});
});
it('merges payload status into an existing _responseBody error object', () => {
const result = formatErrorForState({
_responseBody: { error: { message: 'Payment required' }, provider: 'lobehub' },
error: { status: 402 },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'opaque upstream message',
});
expect(result).toMatchObject({
body: {
error: { message: 'Payment required', status: 402 },
provider: 'lobehub',
},
category: 'quota',
type: AgentRuntimeErrorType.InsufficientQuota,
});
});
it('keeps a genuine residual as ProviderBizError (E8002)', () => {
const result = formatErrorForState({
errorType: AgentRuntimeErrorType.ProviderBizError,
@@ -1,5 +1,6 @@
import { getErrorCodeSpec, refineErrorCode } from '@lobechat/model-runtime';
import { AgentRuntimeErrorType, ChatErrorType, type ChatMessageError } from '@lobechat/types';
import { isRecord } from '@lobechat/utils';
/** Pull a usable HTTP status out of the nested upstream error object. */
const extractHttpStatus = (body: unknown): number | undefined => {
@@ -19,6 +20,80 @@ const extractProvider = (body: unknown): string | undefined => {
return typeof p === 'string' ? p : undefined;
};
const extractMessage = (value: unknown): string | undefined => {
if (!isRecord(value)) return undefined;
const message = value.message;
if (typeof message === 'string' && message) return message;
const nestedError = value.error;
if (isRecord(nestedError)) {
const nestedMessage = nestedError.message;
if (typeof nestedMessage === 'string' && nestedMessage) return nestedMessage;
}
};
interface ChatCompletionErrorPayloadLike {
_responseBody?: unknown;
budget?: unknown;
error?: unknown;
errorType: ChatMessageError['type'];
message?: string;
provider?: unknown;
}
const mergePayloadError = (
sourceBody: Record<string, unknown>,
payload: ChatCompletionErrorPayloadLike,
): unknown | undefined => {
if (payload._responseBody === undefined || payload.error === undefined) return undefined;
if (!('error' in sourceBody)) return payload.error;
if (isRecord(sourceBody.error) && isRecord(payload.error)) {
return { ...payload.error, ...sourceBody.error };
}
};
const buildPayloadBody = (
payload: ChatCompletionErrorPayloadLike,
originalError: unknown,
message: string,
): unknown => {
// Runtime payloads often keep UI context (for example quota hints) next to
// `error`, while `error` itself only carries the display message. Merge both
// layers so normalizing `{ errorType, error }` does not drop the fields the
// chat error renderer needs later.
const sourceBody = payload._responseBody ?? payload.error ?? originalError;
const context: Record<string, unknown> = {};
if (payload.budget !== undefined) context.budget = payload.budget;
if (typeof payload.provider === 'string') context.provider = payload.provider;
if (isRecord(sourceBody)) {
const payloadError = mergePayloadError(sourceBody, payload);
return {
...sourceBody,
// `_responseBody` is the display-facing body, but gateway/model-runtime
// still carries status/provider details in `error` for some failures:
// `{ _responseBody: { error: { message } }, error: { status: 402 } }`.
...(payloadError === undefined ? {} : { error: payloadError }),
...(payload.budget !== undefined && !('budget' in sourceBody)
? { budget: payload.budget }
: {}),
...(typeof payload.provider === 'string' && !('provider' in sourceBody)
? { provider: payload.provider }
: {}),
...('message' in sourceBody ? {} : { message }),
};
}
return {
...context,
...(sourceBody === undefined ? {} : { error: sourceBody }),
message,
};
};
/**
* Merge classification metadata from `ERROR_CODE_SPECS` onto a normalized
* `ChatMessageError`. Codes that aren't in the spec table (fallbacks like
@@ -79,14 +154,16 @@ const enrichWithSpec = (formatted: ChatMessageError): ChatMessageError => {
*/
export const formatErrorForState = (error: unknown): ChatMessageError => {
if (error && typeof error === 'object' && 'errorType' in error) {
const payload = error as {
error?: unknown;
errorType: ChatMessageError['type'];
message?: string;
};
const payload = error as ChatCompletionErrorPayloadLike;
const message =
(payload.message && payload.message !== 'error' ? payload.message : undefined) ??
extractMessage(payload._responseBody) ??
extractMessage(payload.error) ??
String(payload.errorType);
return enrichWithSpec({
body: payload.error || error,
message: payload.message || String(payload.errorType),
body: buildPayloadBody(payload, error, message),
message,
type: payload.errorType,
});
}
@@ -659,6 +659,59 @@ describe('createServerAgentToolsEngine', () => {
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should disable RemoteDevice when a device is explicitly bound (locked to the selection)', () => {
// A user-selected (bound) device locks the run to that device — the
// activate-device tool is never offered, so the model cannot switch.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
canUseDevice: true,
deviceContext: {
autoActivated: true,
boundDeviceId: 'device-001',
deviceOnline: true,
gatewayConfigured: true,
},
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [RemoteDeviceManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should disable RemoteDevice when the bound device is OFFLINE — no silent hop to another machine', () => {
// The bound device going offline makes the plan device-unrouted, so
// `autoActivated` is false. Without the `boundDeviceId` gate the tool
// would resurface and let the model activate a *different* online device.
// The explicit selection must keep the run locked instead.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
canUseDevice: true,
deviceContext: {
boundDeviceId: 'device-001',
deviceOnline: true,
gatewayConfigured: true,
},
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [RemoteDeviceManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should enable RemoteDevice in bot conversations when caller is trusted (canUseDevice=true)', () => {
// The `!isBotConversation` clause was dropped in — the
// confused-deputy concern that motivated it is now handled at a
@@ -28,7 +28,11 @@ import { ToolsEngine } from '@lobechat/context-engine';
import { type RuntimeEnvMode, type RuntimePlatform } from '@lobechat/types';
import debug from 'debug';
import { executionTargetToRuntimeMode, resolveExecutionTarget } from '@/helpers/executionTarget';
import {
executionTargetToRuntimeMode,
resolveExecutionTarget,
resolveToolMode,
} from '@/helpers/executionTarget';
import {
buildAllowedBuiltinTools,
DEVICE_TOOL_IDENTIFIERS,
@@ -86,7 +90,7 @@ export const createServerToolsEngine = (
// Combine all manifests, then drop anything whose identifier the caller
// has explicitly forbidden for this turn. The post-merge filter closes
// the second half of the wall: an installed plugin or a
// Skill/Klavis manifest claiming `lobe-remote-device` would otherwise
// Skill/Composio manifest claiming `lobe-remote-device` would otherwise
// slip through `buildAllowedBuiltinTools` (which only touches the
// builtin source).
const combinedManifests = [...pluginManifests, ...builtinManifests, ...additionalManifests];
@@ -157,7 +161,7 @@ export const createServerAgentToolsEngine = (
const executionTarget =
executionPlan?.target ??
resolveExecutionTarget(agentConfig.agencyConfig, {
isDesktop: platform === 'desktop',
clientExecutionAvailable: platform === 'desktop',
});
const runtimeMode: RuntimeEnvMode = executionTargetToRuntimeMode(executionTarget);
// Device tools (local-system, remote-device proxy) only exist for
@@ -170,9 +174,7 @@ export const createServerAgentToolsEngine = (
const isSearchEnabled = searchMode !== 'off';
// Tool mode: explicit `toolMode` wins; otherwise derive from `enableAgentMode`
// (undefined = agent). `custom` = toolset is exactly the agent's plugins.
const toolMode: 'agent' | 'chat' | 'custom' =
agentConfig.chatConfig?.toolMode ??
(agentConfig.chatConfig?.enableAgentMode === false ? 'chat' : 'agent');
const toolMode = resolveToolMode(agentConfig.chatConfig ?? undefined);
const isChatMode = toolMode === 'chat';
const isCustomMode = toolMode === 'custom';
@@ -231,12 +233,20 @@ export const createServerAgentToolsEngine = (
// Only auto-enable in bot conversations; otherwise let user's plugin selection take effect
...(isBotConversation && { [MessageManifest.identifier]: true }),
// Remote-device proxy: shown only for device-capable targets when the
// server has a proxy but no specific device is auto-activated yet (user
// must pick). External bot senders never reach it: the plan degrades
// denied targets to `none` (→ not deviceCapable) and the physical
// manifest walls drop it for `canUseDevice=false` turns.
// server has a proxy, no specific device is auto-activated yet, AND the
// user has NOT explicitly selected a device. Once a device is explicitly
// selected (`boundDeviceId`), the run is locked to it: we never expose the
// activate-device tool, so the model can never switch to another machine —
// not even when the selected device is offline (the run stays unrouted
// until that device comes back, rather than silently hopping elsewhere).
// External bot senders never reach it: the plan degrades denied targets to
// `none` (→ not deviceCapable) and the physical manifest walls drop it for
// `canUseDevice=false` turns.
[RemoteDeviceManifest.identifier]:
deviceCapable && hasDeviceProxy && !deviceContext?.autoActivated,
deviceCapable &&
hasDeviceProxy &&
!deviceContext?.autoActivated &&
!deviceContext?.boundDeviceId,
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
[WebBrowsingManifest.identifier]: isSearchEnabled,
};
@@ -256,7 +266,7 @@ export const createServerAgentToolsEngine = (
: isChatMode
? chatModeAllowedToolIds
: defaultToolIds,
// Post-merge wall: a plugin or Skill/Klavis manifest claiming a
// Post-merge wall: a plugin or Skill/Composio manifest claiming a
// device identifier survives `buildAllowedBuiltinTools` (which only
// filters the builtin source). Excluding the identifiers here drops
// them from the combined `manifestSchemas` so the activator cannot
@@ -22,7 +22,7 @@ export interface ServerAgentToolsContext {
* Configuration options for createServerToolsEngine
*/
export interface ServerAgentToolsEngineConfig {
/** Additional manifests to include (e.g., Klavis tools) */
/** Additional manifests to include (e.g., Composio tools) */
additionalManifests?: LobeToolManifest[];
/**
* Override the list of builtin tools fed into the engine's
@@ -39,7 +39,7 @@ export interface ServerAgentToolsEngineConfig {
/**
* Identifiers to drop from `manifestSchemas` after combining plugin,
* builtin, and additional manifests. Filtering builtins alone is not
* enough: an installed plugin or a Skill/Klavis manifest can declare
* enough: an installed plugin or a Skill/Composio manifest can declare
* `identifier: 'lobe-remote-device'` and slip past `buildAllowedBuiltinTools`.
* This is the final post-merge wall referenced in .
*/
@@ -70,6 +70,25 @@ describe('serverMessagesEngine', () => {
expect(result[0].content).toBe(systemRole + '\n\n' + getCurrentDateContent());
});
it('should inject model knowledge cutoff when provided', async () => {
const messages = createBasicMessages();
const result = await serverMessagesEngine({
messages,
model: 'gpt-4',
modelKnowledgeCutoff: '2024-06',
provider: 'openai',
systemRole: 'You are a helpful assistant',
});
expect(result[0].role).toBe('system');
expect(result[0].content).toBe(
'You are a helpful assistant\n\n' +
getCurrentDateContent() +
'\n\nModel knowledge cutoff: 2024-06',
);
});
it('should handle empty messages', async () => {
const result = await serverMessagesEngine({
messages: [],
@@ -51,6 +51,7 @@ const createServerVariableGenerators = (params: {
export const serverMessagesEngine = async ({
messages = [],
model,
modelKnowledgeCutoff,
provider,
systemRole,
inputTemplate,
@@ -83,6 +84,7 @@ export const serverMessagesEngine = async ({
const engine = new MessagesEngine({
// Capability injection
capabilities: {
isCanUseAudio: capabilities?.isCanUseAudio,
isCanUseFC: capabilities?.isCanUseFC,
isCanUseVideo: capabilities?.isCanUseVideo,
isCanUseVision: capabilities?.isCanUseVision,
@@ -120,6 +122,7 @@ export const serverMessagesEngine = async ({
// Model info
model,
modelKnowledgeCutoff,
provider,
systemRole,
@@ -23,6 +23,8 @@ import type { RuntimeInitialContext, UIChatMessage } from '@lobechat/types';
* Model capability checker functions for server-side
*/
export interface ServerModelCapabilities {
/** Check if audio input is supported */
isCanUseAudio?: (model: string, provider: string) => boolean;
/** Check if function calling is supported */
isCanUseFC?: (model: string, provider: string) => boolean;
/** Check if video is supported */
@@ -130,6 +132,8 @@ export interface ServerMessagesEngineParams {
/** Model ID */
model: string;
/** Model knowledge cutoff date, e.g. `2024-06`. Omit when unknown. */
modelKnowledgeCutoff?: string;
/** Page content context (optional, for document editing) */
pageContentContext?: PageContentContext;
+6 -8
View File
@@ -20,8 +20,8 @@ import { GenerationModel } from '@/database/models/generation';
import { asyncAuthedProcedure, asyncRouter as router } from '@/libs/trpc/async';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { VideoGenerationService } from '@/server/services/generation/video';
import { buildVideoGenerationFilePayload } from '@/server/services/generation/videoFile';
import { FileSource } from '@/types/files';
import { sanitizeFileName } from '@/utils/sanitizeFileName';
const log = debug('lobe-video:async');
@@ -196,13 +196,11 @@ export const videoRouter = router({
url: processResult.videoKey,
width: processResult.width,
},
{
fileHash: processResult.fileHash,
fileType: processResult.mimeType,
name: `${sanitizeFileName(batch?.prompt ?? '', generationId)}.mp4`,
size: processResult.fileSize,
url: processResult.videoKey,
},
buildVideoGenerationFilePayload({
generationId,
processResult,
prompt: batch?.prompt,
}),
FileSource.VideoGeneration,
);
@@ -393,6 +393,7 @@ describe('agentRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentRouter.createCaller(wsCtx());
@@ -410,6 +411,7 @@ describe('agentRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentRouter.createCaller(wsCtx());
@@ -0,0 +1,146 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type * as AgentDocumentModels from '@/database/models/agentDocuments';
import { createCallerFactory } from '@/libs/trpc/lambda';
import { createContextInner } from '@/libs/trpc/lambda/context';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import { agentDocumentRouter } from '../agentDocument';
const mocks = vi.hoisted(() => ({
associate: vi.fn(),
createTopic: vi.fn(),
findByAgentAndDocumentTrigger: vi.fn(),
findRowByDocumentId: vi.fn(),
getServerDB: vi.fn(),
}));
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: mocks.getServerDB,
}));
vi.mock('@/database/models/agentDocuments', async (importOriginal) => {
const actual = await importOriginal<typeof AgentDocumentModels>();
return {
...actual,
AgentDocumentModel: vi.fn(),
};
});
vi.mock('@/database/models/topic', () => ({
TopicModel: vi.fn().mockImplementation(() => ({
create: mocks.createTopic,
findByAgentAndDocumentTrigger: mocks.findByAgentAndDocumentTrigger,
})),
}));
vi.mock('@/database/models/topicDocument', () => ({
TopicDocumentModel: vi.fn().mockImplementation(() => ({
associate: mocks.associate,
})),
}));
vi.mock('@/server/services/agentDocuments', () => ({
AgentDocumentsService: vi.fn(),
}));
vi.mock('@/server/services/agentDocumentVfs', () => ({
AgentDocumentVfsService: vi.fn(),
}));
vi.mock('@/server/services/agentDocuments/toolOutcome', () => ({
emitAgentDocumentToolOutcomeSafely: vi.fn(),
}));
const createCaller = createCallerFactory(agentDocumentRouter);
describe('agentDocumentRouter.getOrCreateChatTopic', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getServerDB.mockResolvedValue({ kind: 'server-db' });
vi.mocked(AgentDocumentsService).mockImplementation(
() =>
({ findRowByDocumentId: mocks.findRowByDocumentId }) as unknown as AgentDocumentsService,
);
});
it('returns the existing topic when a doc-anchored row is already linked', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue({ id: 'topic-existing' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
const result = await caller.getOrCreateChatTopic({
agentId: 'agent-1',
documentId: 'docs_abc',
});
expect(result).toEqual({ topicId: 'topic-existing' });
expect(mocks.findByAgentAndDocumentTrigger).toHaveBeenCalledWith({
agentId: 'agent-1',
documentId: 'docs_abc',
trigger: 'document',
});
expect(mocks.createTopic).not.toHaveBeenCalled();
expect(mocks.associate).not.toHaveBeenCalled();
});
it('creates a new doc-anchored topic and associates it when none exists', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue({
filename: 'spec.md',
id: 'agent-document-1',
title: 'Spec',
});
mocks.createTopic.mockResolvedValue({ id: 'topic-new' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
const result = await caller.getOrCreateChatTopic({
agentId: 'agent-1',
documentId: 'docs_abc',
});
expect(result).toEqual({ topicId: 'topic-new' });
expect(mocks.createTopic).toHaveBeenCalledWith({
agentId: 'agent-1',
title: 'Spec',
trigger: 'document',
});
expect(mocks.associate).toHaveBeenCalledWith({
documentId: 'docs_abc',
topicId: 'topic-new',
});
});
it('falls back to the filename when the document has no title', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue({
filename: 'fallback.md',
id: 'agent-document-1',
title: undefined,
});
mocks.createTopic.mockResolvedValue({ id: 'topic-new' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
await caller.getOrCreateChatTopic({ agentId: 'agent-1', documentId: 'docs_abc' });
expect(mocks.createTopic).toHaveBeenCalledWith({
agentId: 'agent-1',
title: 'fallback.md',
trigger: 'document',
});
});
it('throws NOT_FOUND when the document is missing or not owned by the agent', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue(undefined);
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
await expect(
caller.getOrCreateChatTopic({ agentId: 'agent-1', documentId: 'docs_missing' }),
).rejects.toThrow(/Document not found/);
expect(mocks.createTopic).not.toHaveBeenCalled();
expect(mocks.associate).not.toHaveBeenCalled();
});
});
@@ -500,6 +500,7 @@ describe('agentGroupRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentGroupRouter.createCaller(wsCtx());
@@ -517,6 +518,7 @@ describe('agentGroupRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentGroupRouter.createCaller(wsCtx());
@@ -29,10 +29,12 @@ describe('aiModelRouter', () => {
it('should create ai model', async () => {
const mockCreate = vi.fn().mockResolvedValue({ id: 'model-1' });
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
@@ -44,12 +46,68 @@ describe('aiModelRouter', () => {
});
expect(result).toBe('model-1');
expect(mockFindByIdAndProvider).toHaveBeenCalledWith('test-model', 'test-provider');
expect(mockCreate).toHaveBeenCalledWith({
id: 'test-model',
providerId: 'test-provider',
});
});
it('should reject duplicate ai model before creating', async () => {
const mockCreate = vi.fn();
const mockFindByIdAndProvider = vi.fn().mockResolvedValue({ id: 'test-model' });
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
const caller = aiModelRouter.createCaller(mockCtx);
await expect(
caller.createAiModel({
id: 'test-model',
providerId: 'test-provider',
}),
).rejects.toMatchObject({
code: 'CONFLICT',
message: 'Model "test-model" already exists',
});
expect(mockCreate).not.toHaveBeenCalled();
});
it('should convert duplicate insert races to conflict errors', async () => {
const duplicateError = Object.assign(new Error('failed query'), {
cause: Object.assign(new Error('duplicate key'), {
code: '23505',
constraint: 'ai_models_id_provider_id_user_id_pk',
}),
});
const mockCreate = vi.fn().mockRejectedValue(duplicateError);
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
const caller = aiModelRouter.createCaller(mockCtx);
await expect(
caller.createAiModel({
id: 'test-model',
providerId: 'test-provider',
}),
).rejects.toMatchObject({
code: 'CONFLICT',
message: 'Model "test-model" already exists',
});
});
it('should get ai model by id', async () => {
const mockModel = {
id: 'model-1',
@@ -0,0 +1,118 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { asrRouter } from '../asr';
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: vi.fn(() => ({})),
}));
const transcribeMock = vi.fn();
vi.mock('@/server/modules/ModelRuntime', () => ({
initModelRuntimeFromDB: vi.fn(async () => ({ transcribe: transcribeMock })),
}));
const findByIdMock = vi.fn();
vi.mock('@/database/models/file', () => ({
FileModel: vi.fn(() => ({ findById: findByIdMock })),
}));
const getFileByteArrayMock = vi.fn();
vi.mock('@/server/services/file', () => ({
FileService: vi.fn(() => ({ getFileByteArray: getFileByteArrayMock })),
}));
const caller = asrRouter.createCaller({ jwtPayload: { userId: 'u1' }, userId: 'u1' } as any);
beforeEach(() => {
transcribeMock.mockResolvedValue({ text: 'hello world' });
});
afterEach(() => {
vi.clearAllMocks();
});
describe('asrRouter.transcribe', () => {
it('transcribes inline base64 audio', async () => {
const res = await caller.transcribe({
audioBase64: Buffer.from('audio-bytes').toString('base64'),
fileName: 'clip.mp3',
model: 'whisper-1',
provider: 'openai',
});
expect(res).toEqual({ text: 'hello world' });
expect(findByIdMock).not.toHaveBeenCalled();
const payload = transcribeMock.mock.calls[0][0];
expect(payload.file).toBeInstanceOf(File);
expect(payload.fileName).toBe('clip.mp3');
expect(await payload.file.text()).toBe('audio-bytes');
});
it('resolves a fileId by downloading the bytes from storage', async () => {
findByIdMock.mockResolvedValue({
fileType: 'audio/mp4',
name: 'meeting.m4a',
url: 's3-key/meeting.m4a',
});
getFileByteArrayMock.mockResolvedValue(new Uint8Array(Buffer.from('from-s3')));
const res = await caller.transcribe({ fileId: 'file_123', model: 'whisper-1' });
expect(res).toEqual({ text: 'hello world' });
expect(findByIdMock).toHaveBeenCalledWith('file_123');
expect(getFileByteArrayMock).toHaveBeenCalledWith('s3-key/meeting.m4a');
const payload = transcribeMock.mock.calls[0][0];
expect(payload.fileName).toBe('meeting.m4a');
expect(payload.file.type).toBe('audio/mp4');
expect(await payload.file.text()).toBe('from-s3');
});
it('rejects when neither fileId nor audioBase64 is provided', async () => {
await expect(caller.transcribe({ model: 'whisper-1' } as any)).rejects.toThrow();
});
it('rejects oversized inline base64 and guides to fileId', async () => {
// > 3MB decoded → base64 string exceeds the cap
const tooBig = 'A'.repeat(5 * 1024 * 1024);
await expect(caller.transcribe({ audioBase64: tooBig, model: 'whisper-1' })).rejects.toThrow(
/fileId/i,
);
expect(transcribeMock).not.toHaveBeenCalled();
});
it('rejects when both fileId and audioBase64 are provided', async () => {
await expect(
caller.transcribe({
audioBase64: Buffer.from('x').toString('base64'),
fileId: 'file_123',
model: 'whisper-1',
} as any),
).rejects.toThrow();
});
it('throws NOT_FOUND when the fileId does not exist', async () => {
findByIdMock.mockResolvedValue(undefined);
await expect(caller.transcribe({ fileId: 'missing', model: 'whisper-1' })).rejects.toThrow(
/not found/i,
);
expect(getFileByteArrayMock).not.toHaveBeenCalled();
});
it('throws NOT_FOUND when the stored object is gone (NoSuchKey)', async () => {
findByIdMock.mockResolvedValue({
fileType: 'audio/mp4',
name: 'gone.m4a',
url: 's3-key/gone.m4a',
});
getFileByteArrayMock.mockRejectedValue({ Code: 'NoSuchKey' });
await expect(caller.transcribe({ fileId: 'file_x', model: 'whisper-1' })).rejects.toThrow(
/no longer available/i,
);
});
});
@@ -0,0 +1,70 @@
import { TRPCError } from '@trpc/server';
import { describe, expect, it, vi } from 'vitest';
import type { DeviceModel } from '@/database/models/device';
import { assertWorkspaceRootApproved } from '../deviceWorkspaceGuard';
const mockModel = (row: { defaultCwd?: string | null; workingDirs?: { path: string }[] } | null) =>
({
findByDeviceId: vi.fn().mockResolvedValue(row),
}) as unknown as DeviceModel;
describe('assertWorkspaceRootApproved', () => {
it('allows a root that exactly matches a bound workingDir', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).resolves.toBeUndefined();
});
it('allows a root nested inside a bound workingDir', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj/packages/app'),
).resolves.toBeUndefined();
});
it('allows a root matching defaultCwd when no workingDirs match', async () => {
const model = mockModel({ defaultCwd: '/Users/me/default', workingDirs: [] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/default'),
).resolves.toBeUndefined();
});
it('rejects a root that escapes the approved roots (filesystem root)', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(assertWorkspaceRootApproved(model, 'dev-1', '/')).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
it('rejects a sibling directory that shares a path prefix but is not contained', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj-evil'),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('rejects when the device has no approved roots at all', async () => {
const model = mockModel({ workingDirs: [] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('rejects when the device row is missing', async () => {
const model = mockModel(null);
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).rejects.toBeInstanceOf(TRPCError);
});
it('rejects an empty workspace root with BAD_REQUEST before hitting the DB', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(assertWorkspaceRootApproved(model, 'dev-1', '')).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(model.findByDeviceId).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,289 @@
// @vitest-environment node
/**
* Integration test for the server `lobe-agent-management.callAgent` deferred
* execution flow.
*
* Verifies the full lifecycle end-to-end on the in-memory runtime:
* 1. Parent op LLM emits a `lobe-agent-management____callAgent` tool call.
* 2. The real server executor parks the parent, creates a pending tool
* placeholder, and forks the target agent as a child op.
* 3. The child op completes.
* 4. The completion bridge backfills the placeholder and resumes the parent.
* 5. The parent reaches `done`.
*/
import { type LobeChatDatabase } from '@lobechat/database';
import { agentOperations, agents, messagePlugins, messages } from '@lobechat/database/schemas';
import { getTestDB } from '@lobechat/database/test-utils';
import { and, eq } from 'drizzle-orm';
import OpenAI from 'openai';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { inMemoryAgentStateManager } from '@/server/modules/AgentRuntime/InMemoryAgentStateManager';
import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager';
import { aiAgentRouter } from '../../../aiAgent';
import { cleanupTestUser, createTestUser } from '../setup';
import { createMockResponsesStream, waitForOperationComplete } from './helpers';
process.env.OPENAI_API_KEY = 'sk-test-fake-api-key-for-testing';
let testDB: LobeChatDatabase;
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: vi.fn(() => testDB),
}));
vi.mock('@/server/services/file', () => ({
FileService: vi.fn().mockImplementation(() => ({
getFullFileUrl: vi.fn().mockImplementation((path: string) => (path ? `/files${path}` : null)),
})),
}));
let mockResponsesCreate: any;
let serverDB: LobeChatDatabase;
let userId: string;
let parentAgentId: string;
let targetAgentId: string;
const TARGET_ANSWER = 'The target agent completed the delegated callAgent work.';
const PARENT_FINAL = 'I received the target agent result and the delegated work is complete.';
const createTestContext = () => ({ jwtPayload: { userId }, userId });
const createCallAgentResponse = () => {
const responseId = `resp_call_agent_${Date.now()}`;
const msgItemId = `msg_call_agent_${Date.now()}`;
const callId = 'call_agent_1';
const fnCall = {
arguments: JSON.stringify({
agentId: targetAgentId,
instruction: 'Handle the delegated backend integration task.',
runAsTask: true,
taskTitle: 'Delegated backend integration task',
timeout: 30_000,
}),
call_id: callId,
name: 'lobe-agent-management____callAgent',
type: 'function_call',
};
return createMockResponsesStream([
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [],
status: 'in_progress',
},
type: 'response.created',
},
{
item: {
content: [],
id: msgItemId,
role: 'assistant',
status: 'in_progress',
type: 'message',
},
output_index: 0,
type: 'response.output_item.added',
},
{
content_index: 0,
delta: 'I will delegate this to the target agent.',
item_id: msgItemId,
output_index: 0,
type: 'response.output_text.delta',
},
{ item: fnCall, output_index: 1, type: 'response.output_item.added' },
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [
{
content: [{ text: 'I will delegate this to the target agent.', type: 'output_text' }],
id: msgItemId,
role: 'assistant',
status: 'completed',
type: 'message',
},
fnCall,
],
status: 'completed',
usage: { input_tokens: 30, output_tokens: 20, total_tokens: 50 },
},
type: 'response.completed',
},
]);
};
const createFinalTextResponse = (content: string) => {
const responseId = `resp_final_${Date.now()}_${content.length}`;
const msgItemId = `msg_final_${Date.now()}_${content.length}`;
return createMockResponsesStream([
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [],
status: 'in_progress',
},
type: 'response.created',
},
{
content_index: 0,
delta: content,
item_id: msgItemId,
output_index: 0,
type: 'response.output_text.delta',
},
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [
{
content: [{ text: content, type: 'output_text' }],
id: msgItemId,
role: 'assistant',
status: 'completed',
type: 'message',
},
],
status: 'completed',
usage: { input_tokens: 40, output_tokens: 20, total_tokens: 60 },
},
type: 'response.completed',
},
]);
};
beforeEach(async () => {
serverDB = await getTestDB();
testDB = serverDB;
userId = await createTestUser(serverDB);
const insertedAgents = await serverDB
.insert(agents)
.values([
{
chatConfig: {},
model: 'gpt-5-pro',
plugins: ['lobe-agent-management'],
provider: 'openai',
systemRole: 'You are a supervisor that delegates work to other agents.',
title: 'callAgent Supervisor',
userId,
},
{
chatConfig: {},
model: 'gpt-5-pro',
plugins: [],
provider: 'openai',
systemRole: 'You are the target agent. Return a concise result.',
title: 'callAgent Target',
userId,
},
])
.returning();
parentAgentId = insertedAgents[0].id;
targetAgentId = insertedAgents[1].id;
// `create` is overloaded (streaming / non-streaming); its precise spy type
// isn't assignable to the generic MockInstance fallback, so widen via unknown.
mockResponsesCreate = vi.spyOn(
OpenAI.Responses.prototype,
'create',
) as unknown as typeof mockResponsesCreate;
});
afterEach(async () => {
await cleanupTestUser(serverDB, userId);
vi.clearAllMocks();
vi.restoreAllMocks();
inMemoryAgentStateManager.clear();
inMemoryStreamEventManager.clear();
});
describe('Server callAgent deferred execution', () => {
it('parks the parent, runs the target agent, backfills the tool message and resumes', async () => {
let callCount = 0;
mockResponsesCreate.mockImplementation(() => {
callCount++;
if (callCount === 1) return Promise.resolve(createCallAgentResponse() as any);
if (callCount === 2) return Promise.resolve(createFinalTextResponse(TARGET_ANSWER) as any);
return Promise.resolve(createFinalTextResponse(PARENT_FINAL) as any);
});
const caller = aiAgentRouter.createCaller(createTestContext());
const createResult = await caller.execAgent({
agentId: parentAgentId,
prompt: 'Delegate this work to the target agent and report back.',
userInterventionConfig: { approvalMode: 'headless' },
});
expect(createResult.success).toBe(true);
const finalState = await waitForOperationComplete(
inMemoryAgentStateManager,
createResult.operationId,
{ maxWaitTime: 20_000 },
);
expect(finalState.status).toBe('done');
expect(finalState.pendingToolsCalling ?? []).toHaveLength(0);
expect(mockResponsesCreate).toHaveBeenCalledTimes(3);
const childOps = await serverDB
.select()
.from(agentOperations)
.where(eq(agentOperations.parentOperationId, createResult.operationId));
expect(childOps).toHaveLength(1);
expect(childOps[0]).toMatchObject({
agentId: targetAgentId,
status: 'done',
});
const toolMessages = await serverDB
.select({
content: messages.content,
role: messages.role,
state: messagePlugins.state,
identifier: messagePlugins.identifier,
apiName: messagePlugins.apiName,
toolCallId: messagePlugins.toolCallId,
})
.from(messages)
.innerJoin(messagePlugins, eq(messagePlugins.id, messages.id))
.where(
and(
eq(messages.userId, userId),
eq(messagePlugins.identifier, 'lobe-agent-management'),
eq(messagePlugins.apiName, 'callAgent'),
),
);
expect(toolMessages).toHaveLength(1);
expect(toolMessages[0]).toMatchObject({
apiName: 'callAgent',
content: TARGET_ANSWER,
identifier: 'lobe-agent-management',
role: 'tool',
toolCallId: 'call_agent_1',
});
expect(toolMessages[0].state).toMatchObject({
status: 'completed',
threadId: childOps[0].threadId,
});
}, 30_000);
});
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { topicRouter } from '../../topic';
import { cleanupTestUser, createTestContext, createTestUser } from './setup';
import { cleanupTestUser, createTestAgent, createTestContext, createTestUser } from './setup';
// We need to mock getServerDB to return our test database instance
let testDB: LobeChatDatabase;
@@ -332,31 +332,79 @@ describe('Topic Router Integration Tests', () => {
});
});
// BM25 search requires pg_search extension (ParadeDB), not available in integration test DB
// BM25 search requires pg_search extension (ParadeDB), not available in the
// default integration test DB (PGlite). Run with TEST_SERVER_DB=1 +
// DATABASE_TEST_URL pointing at a ParadeDB instance to exercise these.
describe.skip('searchTopics', () => {
it('should search topics using agentId', async () => {
const caller = topicRouter.createCaller(createTestContext(userId));
// Create test topics
await caller.createTopic({
title: 'TypeScript Discussion',
sessionId: testSessionId,
});
// Topics are agent-native: stored with agentId directly.
await serverDB.insert(topics).values([
{ agentId: testAgentId, title: 'TypeScript Discussion', userId },
{ agentId: testAgentId, title: 'JavaScript Basics', userId },
]);
await caller.createTopic({
title: 'JavaScript Basics',
sessionId: testSessionId,
});
// Search using agentId
const result = await caller.searchTopics({
keywords: 'TypeScript',
agentId: testAgentId,
keywords: 'TypeScript',
});
expect(result.length).toBeGreaterThan(0);
expect(result[0].title).toContain('TypeScript');
});
// Regression for the "No topics match these filters" bug: topics created by
// the new agent system carry `agentId` directly with a NULL `sessionId`.
// The old search resolved agentId -> sessionId and filtered by the
// container only, so these rows were never matched even though the topics
// list (which filters by agentId) showed them.
it('should find agentId-scoped topics that have no sessionId', async () => {
const caller = topicRouter.createCaller(createTestContext(userId));
// Insert a topic the way the agent runtime does: agentId set, sessionId null.
await serverDB.insert(topics).values({
agentId: testAgentId,
sessionId: null,
title: 'rinabrown84@gmail.com',
userId,
});
const result = await caller.searchTopics({
agentId: testAgentId,
keywords: 'rinabrown84@gmail.com',
});
expect(result.length).toBeGreaterThan(0);
expect(result[0].title).toBe('rinabrown84@gmail.com');
});
// The agent scope mirrors the topics list exactly (agentId only). A row that
// shares this agent's resolved session but is owned by a DIFFERENT agent
// must not leak in — the bug the constrained-session-fallback review flagged.
it('should not leak another agent topic that shares the session mapping', async () => {
const caller = topicRouter.createCaller(createTestContext(userId));
const otherAgentId = await createTestAgent(serverDB, userId);
await serverDB.insert(topics).values([
{ agentId: testAgentId, title: 'mine rinabrown84@gmail.com', userId },
// Same session, different agent — used to leak via the session fallback.
{
agentId: otherAgentId,
sessionId: testSessionId,
title: 'theirs rinabrown84@gmail.com',
userId,
},
]);
const result = await caller.searchTopics({
agentId: testAgentId,
keywords: 'rinabrown84@gmail.com',
});
expect(result.map((t) => t.title)).toEqual(['mine rinabrown84@gmail.com']);
});
});
describe('updateTopic', () => {
@@ -719,7 +767,7 @@ describe('Topic Router Integration Tests', () => {
sessionId: testSessionId,
});
const allTopics = await caller.getAllTopics();
const allTopics = await caller.queryTopics();
expect(allTopics).toHaveLength(2);
});
@@ -12,6 +12,7 @@ const mockFindById = vi.fn();
const mockCountTopicsForMemoryExtractor = vi.fn();
const mockDeleteAll = vi.fn();
const mockDeletePersona = vi.fn();
const { mockTriggerProcessUsers } = vi.hoisted(() => ({
mockTriggerProcessUsers: vi.fn(),
}));
@@ -43,6 +44,12 @@ vi.mock('@/database/models/userMemory', () => ({
UserMemoryPreferenceModel: vi.fn(() => ({})),
}));
vi.mock('@/database/models/userMemory/persona', () => ({
UserPersonaModel: vi.fn(() => ({
deletePersona: mockDeletePersona,
})),
}));
vi.mock('@/envs/app', () => ({
appEnv: {
APP_URL: 'https://example.com',
@@ -301,11 +308,13 @@ describe('userMemoryRouter.deleteAll', () => {
it('purges all user memories through the aggregate model', async () => {
mockDeleteAll.mockResolvedValue(undefined);
mockDeletePersona.mockResolvedValue(undefined);
const caller = createCaller();
const result = await caller.deleteAll();
expect(mockDeleteAll).toHaveBeenCalledOnce();
expect(mockDeletePersona).toHaveBeenCalledOnce();
expect(result).toEqual({ success: true });
});
});
@@ -41,6 +41,7 @@ export const updateDocumentInputSchema = z.object({
editorData: z.string().optional(),
fileType: z.string().optional(),
id: z.string(),
lockOwnerId: z.string().optional(),
metadata: z.record(z.any()).optional(),
parentId: z.string().nullable().optional(),
restoreFromHistoryId: z.string().optional(),
@@ -51,6 +52,7 @@ export const updateDocumentInputSchema = z.object({
export const saveDocumentHistoryInputSchema = z.object({
documentId: z.string(),
editorData: z.string(),
lockOwnerId: z.string().optional(),
saveSource: documentHistorySaveSourceSchema,
});
@@ -98,6 +100,8 @@ export interface UpdateDocumentOutput {
export interface SaveDocumentHistoryInput {
documentId: string;
editorData: string;
/** Edit-session id proving the client still holds the workspace page lease. */
lockOwnerId?: string;
saveSource: DocumentHistorySaveSource;
}
@@ -130,6 +134,7 @@ export interface UpdateDocumentInput {
editorData?: string;
fileType?: string;
id: string;
lockOwnerId?: string;
metadata?: Record<string, any>;
parentId?: string | null;
restoreFromHistoryId?: string;
@@ -8,6 +8,7 @@ import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { TopicTrigger } from '@/const/topic';
import { AgentDocumentModel } from '@/database/models/agentDocuments';
import { TopicModel } from '@/database/models/topic';
import { TopicDocumentModel } from '@/database/models/topicDocument';
@@ -254,6 +255,56 @@ export const agentDocumentRouter = router({
return ctx.agentDocumentService.getDocument(input.agentId, input.filename);
}),
/**
* Return the chat topic that anchors the doc-scoped conversation for this
* `(documentId, agentId)` pair, creating it idempotently on the first call.
*
* Topics are marked with `trigger='document'` so they stay out of the main
* sidebar history (`MAIN_SIDEBAR_EXCLUDE_TRIGGERS` already excludes them).
* The mapping is persisted through `topic_documents`, so subsequent calls
* resolve the same topic id.
*/
getOrCreateChatTopic: agentDocumentProcedure
.input(
z.object({
agentId: z.string(),
documentId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.topicModel.findByAgentAndDocumentTrigger({
agentId: input.agentId,
documentId: input.documentId,
trigger: TopicTrigger.Document,
});
if (existing) return { topicId: existing.id };
const document = await ctx.agentDocumentService.findRowByDocumentId(
input.agentId,
input.documentId,
);
if (!document) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Document not found for agentId=${input.agentId}`,
});
}
const title = document.title || document.filename || 'Document chat';
const topic = await ctx.topicModel.create({
agentId: input.agentId,
title,
trigger: TopicTrigger.Document,
});
await ctx.topicDocumentModel.associate({
documentId: input.documentId,
topicId: topic.id,
});
return { topicId: topic.id };
}),
/**
* Create or update a document
*/
@@ -372,12 +423,16 @@ export const agentDocumentRouter = router({
.input(
z.object({
agentId: z.string(),
// Reveal the auto-created `.tool-results` archive. Off by default so
// user-facing lists stay clean; the agent document-listing tool opts in.
includeArchivedToolResults: z.boolean().optional().default(false),
scope: z.enum(['agent', 'currentTopic']).optional().default('agent'),
sourceType: z.enum(['all', 'file', 'web']).optional().default('all'),
topicId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { includeArchivedToolResults } = input;
if (input.scope === 'currentTopic') {
if (!input.topicId) throw new Error('topicId is required to list current topic documents');
@@ -385,10 +440,13 @@ export const agentDocumentRouter = router({
input.agentId,
input.topicId,
input.sourceType,
{ includeArchivedToolResults },
);
}
return ctx.agentDocumentService.listDocuments(input.agentId, input.sourceType);
return ctx.agentDocumentService.listDocuments(input.agentId, input.sourceType, {
includeArchivedToolResults,
});
}),
/**
@@ -139,6 +139,8 @@ const ExecAgentSchema = z
.object({
defaultTaskAssigneeAgentId: z.string().optional(),
documentId: z.string().optional().nullable(),
/** The agent being edited when scope is 'agent_builder' (not the builder builtin itself). */
editingAgentId: z.string().optional(),
groupId: z.string().optional().nullable(),
initialTopicMetadata: z
.object({
+36 -2
View File
@@ -1,3 +1,4 @@
import { TRPCError } from '@trpc/server';
import { type AiProviderModelListItem } from 'model-bank';
import {
AiModelTypeSchema,
@@ -18,6 +19,30 @@ import { getServerGlobalConfig } from '@/server/globalConfig';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { type ProviderConfig } from '@/types/user/settings';
const AI_MODEL_UNIQUE_CONSTRAINT = 'ai_models_id_provider_id_user_id_pk';
const getPostgresErrorField = (error: unknown, field: 'code' | 'constraint') => {
let current = error;
while (current && typeof current === 'object') {
const value = (current as Record<string, unknown>)[field];
if (typeof value === 'string') return value;
current = (current as { cause?: unknown }).cause;
}
};
const isDuplicateAiModelError = (error: unknown) =>
getPostgresErrorField(error, 'code') === '23505' &&
getPostgresErrorField(error, 'constraint') === AI_MODEL_UNIQUE_CONSTRAINT;
const throwDuplicateAiModelError = (id: string): never => {
throw new TRPCError({
code: 'CONFLICT',
message: `Model "${id}" already exists`,
});
};
const aiModelProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
const wsId = ctx.workspaceId ?? undefined;
@@ -82,9 +107,18 @@ export const aiModelRouter = router({
.use(withScopedPermission('ai_model:create'))
.input(CreateAiModelSchema)
.mutation(async ({ input, ctx }) => {
const data = await ctx.aiModelModel.create(input);
const existingModel = await ctx.aiModelModel.findByIdAndProvider(input.id, input.providerId);
if (existingModel) throwDuplicateAiModelError(input.id);
return data?.id;
try {
const data = await ctx.aiModelModel.create(input);
return data?.id;
} catch (error) {
if (isDuplicateAiModelError(error)) throwDuplicateAiModelError(input.id);
throw error;
}
}),
getAiModelById: aiModelProcedure
+161
View File
@@ -0,0 +1,161 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { FileModel } from '@/database/models/file';
import type { LobeChatDatabase } from '@/database/type';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { FileService } from '@/server/services/file';
const asrProcedure = wsCompatProcedure.use(serverDatabase);
// Inline base64 is only for short clips. The whole request must fit inside the
// platform body limit (≈4.5MB on serverless deploys) and base64 inflates bytes
// by ~4/3, so cap the decoded audio well under that — anything larger should be
// uploaded and passed as `fileId`.
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
// base64 length ≈ ceil(bytes / 3) * 4; validating the string length lets us
// reject oversized payloads before allocating/decoding them.
const MAX_INLINE_AUDIO_BASE64_CHARS = Math.ceil(MAX_INLINE_AUDIO_BYTES / 3) * 4;
interface ResolvedAudio {
bytes: Uint8Array;
fileName: string;
mimeType?: string;
}
export const asrRouter = router({
/**
* Automatic Speech Recognition (speech-to-text).
*
* Accepts the audio either as an already-uploaded `fileId` (preferred the
* server streams the bytes from storage, nothing large travels over tRPC) or
* inline as base64 for short clips (capped at `MAX_INLINE_AUDIO_BYTES`;
* larger payloads are rejected with guidance to upload and pass `fileId`).
*
* Note on base64: tRPC here uses an `httpLink` + superjson (JSON only), which
* has no binary representation for a `Buffer`/`Uint8Array` a raw buffer would
* serialize to a per-byte JSON object, far worse than base64. So inline bytes
* stay base64; use `fileId` to avoid inlining entirely.
*
* Transcription is a single request/response (not streamed), so a mutation is
* the right shape.
*/
transcribe: asrProcedure
.input(
z
.object({
/** Base64-encoded audio bytes (short clips only). Mutually exclusive with `fileId`. */
audioBase64: z
.string()
.min(1)
.max(MAX_INLINE_AUDIO_BASE64_CHARS, {
message: `Inline audio is limited to ${MAX_INLINE_AUDIO_BYTES / 1024 / 1024}MB. Upload the file and pass \`fileId\` instead.`,
})
.optional(),
/** Already-uploaded audio file id. Mutually exclusive with `audioBase64`. */
fileId: z.string().min(1).optional(),
/** Original file name (base64 path); its extension helps format detection. */
fileName: z.string().optional(),
/** ISO-639-1 language code (e.g. `en`, `zh`). */
language: z.string().optional(),
/** Audio mime type (base64 path, e.g. `audio/mp4`). */
mimeType: z.string().optional(),
model: z.string().min(1),
/** Optional text to guide the model's style. */
prompt: z.string().optional(),
provider: z.string().default('openai'),
responseFormat: z.enum(['json', 'srt', 'text', 'verbose_json', 'vtt']).optional(),
})
.refine((d) => Boolean(d.fileId) !== Boolean(d.audioBase64), {
message: 'Provide exactly one of `fileId` or `audioBase64`.',
}),
)
.mutation(async ({ ctx, input }): Promise<{ text: string }> => {
const workspaceId = ctx.workspaceId ?? undefined;
const { bytes, fileName, mimeType } = await resolveAudio(ctx, input, workspaceId);
// Resolve the user's provider config (key + baseURL) from the database,
// falling back to server env keys, exactly like chat/embeddings do.
const runtime = await initModelRuntimeFromDB(
ctx.serverDB,
ctx.userId,
input.provider,
workspaceId,
);
// `Uint8Array` is a valid BlobPart at runtime; the cast sidesteps the
// `Uint8Array<ArrayBufferLike>` vs BlobPart generic mismatch in lib.dom.
const file = new File([bytes as BlobPart], fileName, {
type: mimeType || 'application/octet-stream',
});
const result = await runtime.transcribe(
{
file,
fileName,
language: input.language,
model: input.model,
prompt: input.prompt,
responseFormat: input.responseFormat,
},
{ user: ctx.userId },
);
if (!result) {
throw new TRPCError({
code: 'NOT_IMPLEMENTED',
message: `Provider "${input.provider}" does not support ASR.`,
});
}
return result;
}),
});
/**
* Turn the request into raw audio bytes + metadata, from either a stored file
* (downloaded from S3, ownership enforced by the userId-scoped FileModel) or the
* inline base64 payload.
*/
async function resolveAudio(
ctx: { serverDB: LobeChatDatabase; userId: string },
input: { audioBase64?: string; fileId?: string; fileName?: string; mimeType?: string },
workspaceId?: string,
): Promise<ResolvedAudio> {
if (input.fileId) {
const fileModel = new FileModel(ctx.serverDB, ctx.userId, workspaceId);
const fileItem = await fileModel.findById(input.fileId);
if (!fileItem) {
throw new TRPCError({ code: 'NOT_FOUND', message: `File "${input.fileId}" not found.` });
}
const fileService = new FileService(ctx.serverDB, ctx.userId, workspaceId);
let bytes: Uint8Array;
try {
bytes = await fileService.getFileByteArray(fileItem.url);
} catch (error) {
if ((error as { Code?: string }).Code === 'NoSuchKey') {
throw new TRPCError({
code: 'NOT_FOUND',
message: `File "${input.fileId}" is no longer available in storage.`,
});
}
throw error;
}
return { bytes, fileName: fileItem.name, mimeType: fileItem.fileType };
}
return {
bytes: new Uint8Array(Buffer.from(input.audioBase64!, 'base64')),
fileName: input.fileName || 'audio',
mimeType: input.mimeType,
};
}
export type AsrRouter = typeof asrRouter;
+256
View File
@@ -0,0 +1,256 @@
import { type ToolManifest } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { getServerComposioAuthConfigId } from '@/config/composio';
import { PluginModel } from '@/database/models/plugin';
import { getComposioClient } from '@/libs/composio';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
const composioProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const client = getComposioClient();
const pluginModel = new PluginModel(opts.ctx.serverDB, opts.ctx.userId);
return opts.next({
ctx: { ...opts.ctx, composioClient: client, pluginModel },
});
});
export const composioRouter = router({
createConnection: composioProcedure
.input(
z.object({
appSlug: z.string(),
identifier: z.string(),
label: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const { appSlug, identifier, label } = input;
const { userId } = ctx;
const callbackUrl = `${process.env.APP_URL || process.env.NEXTAUTH_URL || ''}/api/composio/oauth/callback`;
// Prefer a pre-configured auth config (e.g. a custom/white-label config
// created in the Composio dashboard), pinned per toolkit via env. Falls
// back to discovering an existing config for this toolkit, and finally to
// auto-creating a Composio-managed one.
let authConfigId = getServerComposioAuthConfigId(identifier);
if (!authConfigId) {
const authConfigs = await (ctx.composioClient.authConfigs as any).list();
let authConfig = authConfigs?.items?.find(
(c: any) => c.toolkit?.slug?.toLowerCase() === appSlug.toLowerCase(),
);
if (!authConfig) {
authConfig = await (ctx.composioClient.authConfigs as any).create(appSlug, {
name: appSlug,
type: 'use_composio_managed_auth',
});
}
authConfigId = authConfig.id;
}
if (!authConfigId) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Failed to resolve a Composio auth config for "${appSlug}".`,
});
}
// Composio-managed OAuth auth configs no longer support `initiate`; use
// `link` (POST /api/v3/connected_accounts/link) to get the redirect URL.
const connReq = await (ctx.composioClient.connectedAccounts as any).link(
userId,
authConfigId,
{ callbackUrl },
);
let rawTools: any[] = [];
try {
const toolsResp = await (ctx.composioClient.tools as any).getRawComposioTools({
toolkits: [appSlug],
});
rawTools = toolsResp?.items || toolsResp || [];
} catch {
// tools may not be available before auth
}
const manifest: ToolManifest = {
api: Array.isArray(rawTools)
? rawTools.map((tool: any) => ({
description: tool.description || '',
name: tool.slug || tool.name || '',
parameters: tool.inputParameters ||
tool.inputSchema || {
properties: {},
type: 'object',
},
}))
: [],
identifier,
meta: {
avatar: '🔌',
description: `Composio: ${label}`,
title: label,
},
type: 'default',
};
await ctx.pluginModel.create({
customParams: {
composio: {
appSlug,
authConfigId,
connectedAccountId: connReq.id,
redirectUrl: connReq.redirectUrl,
status: 'PENDING',
},
},
identifier,
manifest,
source: 'composio',
type: 'plugin',
});
return {
authConfigId,
connectedAccountId: connReq.id,
identifier,
redirectUrl: connReq.redirectUrl,
};
}),
deleteConnection: composioProcedure
.input(
z.object({
connectedAccountId: z.string(),
identifier: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
await (ctx.composioClient.connectedAccounts as any).delete(input.connectedAccountId);
} catch (error) {
console.warn('[Composio] Failed to delete remote connection:', error);
}
await ctx.pluginModel.delete(input.identifier);
return { success: true };
}),
getComposioPlugins: composioProcedure.query(async ({ ctx }) => {
const allPlugins = await ctx.pluginModel.query();
return allPlugins.filter((plugin) => plugin.customParams?.composio);
}),
getConnection: composioProcedure
.input(
z.object({
connectedAccountId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
try {
const account = await (ctx.composioClient.connectedAccounts as any).get(
input.connectedAccountId,
);
return {
appSlug: account?.toolkit?.slug || '',
connectedAccountId: input.connectedAccountId,
error: undefined as 'AUTH_ERROR' | undefined,
status: (account?.status || 'PENDING') as string,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const isAuthError = errorMessage.includes('401') || errorMessage.includes('Unauthorized');
if (isAuthError) {
return {
appSlug: '',
connectedAccountId: input.connectedAccountId,
error: 'AUTH_ERROR' as const,
status: 'FAILED',
};
}
throw error;
}
}),
removeComposioPlugin: composioProcedure
.input(z.object({ identifier: z.string() }))
.mutation(async ({ input, ctx }) => {
await ctx.pluginModel.delete(input.identifier);
return { success: true };
}),
updateComposioPlugin: composioProcedure
.input(
z.object({
appSlug: z.string(),
authConfigId: z.string(),
connectedAccountId: z.string(),
identifier: z.string(),
label: z.string(),
redirectUrl: z.string().optional(),
status: z.string(),
tools: z.array(
z.object({
description: z.string().optional(),
inputSchema: z.any().optional(),
name: z.string(),
}),
),
}),
)
.mutation(async ({ input, ctx }) => {
const {
identifier,
label,
appSlug,
authConfigId,
connectedAccountId,
tools,
status,
redirectUrl,
} = input;
const existingPlugin = await ctx.pluginModel.findById(identifier);
const manifest: ToolManifest = {
api: tools.map((tool) => ({
description: tool.description || '',
name: tool.name,
parameters: tool.inputSchema || { properties: {}, type: 'object' },
})),
identifier,
meta: existingPlugin?.manifest?.meta || {
avatar: '🔌',
description: `Composio: ${label}`,
title: label,
},
type: 'default',
};
const customParams = {
composio: { appSlug, authConfigId, connectedAccountId, redirectUrl, status },
};
if (existingPlugin) {
await ctx.pluginModel.update(identifier, { customParams, manifest });
} else {
await ctx.pluginModel.create({
customParams,
identifier,
manifest,
source: 'composio',
type: 'plugin',
});
}
return { savedCount: tools.length };
}),
});
export type ComposioRouter = typeof composioRouter;
+34 -2
View File
@@ -115,6 +115,33 @@ export const connectorRouter = router({
return toolsByConnector;
}),
/**
* Return the connector record with decrypted user-set credentials so the
* edit form can pre-fill accurately. Only the connector owner can call this
* (enforced by connectorProcedure ownership check).
*
* Machine-managed secrets are intentionally excluded:
* - OAuth access/refresh tokens (type 'oauth2') stripped, returned as null
* - oidcConfig.clientSecret (DCR-registered secret) stripped
* User-set credentials (bearer token, custom headers) are returned as-is so
* the edit form can display them.
*/
getForEdit: connectorProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const connector = await ctx.connectorModel.findById(input.id);
if (!connector)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Connector not found' });
const { oidcConfig, credentials, ...rest } = connector;
const safeOidcConfig = oidcConfig ? { ...oidcConfig, clientSecret: undefined } : oidcConfig;
// OAuth tokens are machine-managed — don't return them; the UI only needs
// to know an OAuth flow is configured (reflected via oidcConfig presence).
const safeCredentials = credentials?.type === 'oauth2' ? null : credentials;
return { ...rest, credentials: safeCredentials, oidcConfig: safeOidcConfig };
}),
/**
* The exact redirect URI the server will send to the OAuth/DCR endpoints.
* The Add modal must display THIS value (not a client-derived origin) so the
@@ -268,9 +295,14 @@ export const connectorRouter = router({
await ctx.connectorModel.update(input.id, {
...patch,
// undefined → leave untouched; null → clear; object → encrypt the JSON string.
// When credentials are cleared, also drop the cached expiry timestamp so
// token-refresh logic doesn't act on a stale value for the new server.
...(credentials === undefined
? {}
: { credentials: credentials ? JSON.stringify(credentials) : null }),
: {
credentials: credentials ? JSON.stringify(credentials) : null,
...(credentials === null ? { tokenExpiresAt: null } : {}),
}),
} as any);
}),
@@ -358,7 +390,7 @@ export const connectorRouter = router({
}),
/**
* Sync tools from a client-provided list (for Lobehub OAuth skills, Klavis, etc.
* Sync tools from a client-provided list (for Lobehub OAuth skills, Composio, etc.
* that already have their tool list available on the client side).
* Idempotent safe to call whenever the detail panel opens.
*/
+100 -7
View File
@@ -8,6 +8,7 @@ import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { deviceGateway } from '@/server/services/deviceGateway';
import { preserveWorkspaceCache } from './deviceWorkingDirs';
import { assertWorkspaceRootApproved } from './deviceWorkspaceGuard';
// Derive the zod enum from the canonical config so new platforms are
// automatically covered without touching this file.
@@ -29,6 +30,23 @@ const deviceProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
});
});
const workspaceFileInput = z.object({
deviceId: z.string(),
workingDirectory: z.string(),
});
/**
* `deviceProcedure` that additionally requires `workingDirectory` to be an
* approved workspace root for the device. Builds the guard into the procedure
* so every file-mutating route inherits it and can never forget the check
* see {@link assertWorkspaceRootApproved} for why the check is necessary.
*/
const workspaceFileProcedure = deviceProcedure.input(workspaceFileInput).use(async (opts) => {
const { deviceId, workingDirectory } = workspaceFileInput.parse(await opts.getRawInput());
await assertWorkspaceRootApproved(opts.ctx.deviceModel, deviceId, workingDirectory);
return opts.next();
});
export const deviceRouter = router({
/**
* Probe whether a specific agent platform (openclaw / hermes) is available
@@ -119,6 +137,22 @@ export const deviceRouter = router({
return result ?? null;
}),
/**
* List the git worktrees attached to the same repository as a directory on a
* remote device, via the device's `listGitWorktrees` RPC. Lets the web/remote
* worktree picker mirror the local desktop's, populated over IPC.
*/
listGitWorktrees: deviceProcedure
.input(z.object({ deviceId: z.string(), path: z.string() }))
.query(async ({ ctx, input }) => {
const result = await deviceGateway.listGitWorktrees({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
});
return result ?? [];
}),
/**
* List the local branches of a directory on a remote device, via the device's
* `listGitBranches` RPC. Lets the web/remote branch switcher populate the same
@@ -318,24 +352,22 @@ export const deviceRouter = router({
* Read-only local file preview for a file on a remote device. The web client
* receives render data, not a `localfile://` URL; saving remains unsupported.
*/
getLocalFilePreview: deviceProcedure
getLocalFilePreview: workspaceFileProcedure
.input(
z.object({
accept: z.enum(['image']).optional(),
deviceId: z.string(),
path: z.string(),
workingDirectory: z.string(),
}),
)
.query(async ({ ctx, input }) =>
deviceGateway.getLocalFilePreview({
.query(async ({ ctx, input }) => {
return deviceGateway.getLocalFilePreview({
accept: input.accept,
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workingDirectory: input.workingDirectory,
}),
),
});
}),
/**
* Project skills (`.agents/skills` / `.claude/skills`) for a directory on a
@@ -368,6 +400,67 @@ export const deviceRouter = router({
}),
),
/**
* Move files/folders within a directory on a remote device, via the device's
* `moveLocalFiles` RPC. Powers the Files tree's drag-to-move in device mode.
*/
moveProjectFiles: workspaceFileProcedure
.input(
z.object({
items: z.array(z.object({ newPath: z.string(), oldPath: z.string() })),
}),
)
.mutation(async ({ ctx, input }) => {
return deviceGateway.moveProjectFiles({
deviceId: input.deviceId,
items: input.items,
userId: ctx.userId,
workingDirectory: input.workingDirectory,
});
}),
/**
* Rename a single file/folder in a directory on a remote device, via the
* device's `renameLocalFile` RPC.
*/
renameProjectFile: workspaceFileProcedure
.input(
z.object({
newName: z.string(),
path: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
return deviceGateway.renameProjectFile({
deviceId: input.deviceId,
newName: input.newName,
path: input.path,
userId: ctx.userId,
workingDirectory: input.workingDirectory,
});
}),
/**
* Save edited content back to a file on a remote device, via the device's
* `writeLocalFile` RPC. Powers remote save in the LocalFile editor.
*/
writeProjectFile: workspaceFileProcedure
.input(
z.object({
content: z.string(),
path: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
return deviceGateway.writeProjectFile({
content: input.content,
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workingDirectory: input.workingDirectory,
});
}),
/**
* Check whether a path exists on a remote device and is a directory, via the
* device's `statPath` RPC. Lets a web client validate a manually-entered
@@ -0,0 +1,52 @@
import { TRPCError } from '@trpc/server';
import type { DeviceModel } from '@/database/models/device';
import { isPathWithinRoot } from '@/server/services/deviceGateway';
/**
* Validate that a client-supplied workspace root is actually one the user has
* bound to this device.
*
* The file routes (move / rename / write / preview) receive `workingDirectory`
* from the same untrusted browser session that supplies the file paths. The
* gateway's `assertPathsWithinWorkspace` only proves the paths sit *inside that
* directory* it never proves the directory itself is legitimate. So a caller
* could set `workingDirectory` to `/` (or `C:\`), pass that containment check
* trivially, and reach any path on the device.
*
* To close that hole we re-derive the approved roots from the *server-owned*
* device row the `workingDirs` recent list and `defaultCwd`, both written only
* via `device.updateDevice` / the run path, never trusted from this request
* and require the requested root to equal or nest inside one of them before any
* RPC is forwarded. The picker upserts every chosen directory into `workingDirs`
* (see `useCommitWorkingDirectory`) and run start upserts the bound cwd, so a
* legitimately-selected workspace is always present here.
*/
export const assertWorkspaceRootApproved = async (
deviceModel: DeviceModel,
deviceId: string,
workingDirectory: string,
): Promise<void> => {
if (!workingDirectory) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'A workspace root is required for file operations',
});
}
const device = await deviceModel.findByDeviceId(deviceId);
const approvedRoots = [
...(device?.workingDirs ?? []).map((dir) => dir.path),
...(device?.defaultCwd ? [device.defaultCwd] : []),
].filter((root): root is string => Boolean(root));
const approved = approvedRoots.some((root) => isPathWithinRoot(root, workingDirectory));
if (!approved) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Working directory is not an approved workspace for this device',
});
}
};
+11 -6
View File
@@ -183,6 +183,7 @@ export const documentRouter = router({
input.documentId,
editorData,
input.saveSource,
input.lockOwnerId,
);
}),
@@ -255,23 +256,27 @@ export const documentRouter = router({
acquireDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string() }))
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
return ctx.documentService.acquireDocumentLock(input.id);
return input.ownerId
? ctx.documentService.acquireDocumentLockWithOwner(input.id, input.ownerId)
: ctx.documentService.acquireDocumentLock(input.id);
}),
getDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string() }))
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
.query(async ({ ctx, input }) => {
return ctx.documentService.getDocumentLock(input.id);
return ctx.documentService.getDocumentLock(input.id, input.ownerId);
}),
releaseDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string() }))
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
await ctx.documentService.releaseDocumentLock(input.id);
if (input.ownerId)
await ctx.documentService.releaseDocumentLockWithOwner(input.id, input.ownerId);
else await ctx.documentService.releaseDocumentLock(input.id);
}),
updateDocument: documentProcedure
+5 -2
View File
@@ -32,11 +32,13 @@ import { aiChatRouter } from './aiChat';
import { aiModelRouter } from './aiModel';
import { aiProviderRouter } from './aiProvider';
import { apiKeyRouter } from './apiKey';
import { asrRouter } from './asr';
import { botMessageRouter } from './botMessage';
import { briefRouter } from './brief';
import { changelogRouter } from './changelog';
import { chunkRouter } from './chunk';
import { comfyuiRouter } from './comfyui';
import { composioRouter } from './composio';
import { configRouter } from './config';
import { connectorRouter } from './connector';
import { deviceRouter } from './device';
@@ -50,7 +52,6 @@ import { generationTopicRouter } from './generationTopic';
import { homeRouter } from './home';
import { imageRouter } from './image';
import { importerRouter } from './importer';
import { klavisRouter } from './klavis';
import { knowledgeRouter } from './knowledge';
import { knowledgeBaseRouter } from './knowledgeBase';
import { llmGenerationTracingRouter } from './llmGenerationTracing';
@@ -98,6 +99,7 @@ export const lambdaRouter = router({
aiModel: aiModelRouter,
aiProvider: aiProviderRouter,
apiKey: apiKeyRouter,
asr: asrRouter,
chunk: chunkRouter,
comfyui: comfyuiRouter,
config: configRouter,
@@ -115,7 +117,8 @@ export const lambdaRouter = router({
home: homeRouter,
image: imageRouter,
importer: importerRouter,
klavis: klavisRouter,
composio: composioRouter,
knowledge: knowledgeRouter,
knowledgeBase: knowledgeBaseRouter,
llmGenerationTracing: llmGenerationTracingRouter,
-284
View File
@@ -1,284 +0,0 @@
import { type ToolManifest } from '@lobechat/types';
import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { PluginModel } from '@/database/models/plugin';
import { getKlavisClient } from '@/libs/klavis';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
/**
* Klavis procedure with API key validation and database access
*/
const klavisProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const client = getKlavisClient();
const wsId = opts.ctx.workspaceId ?? undefined;
const pluginModel = new PluginModel(opts.ctx.serverDB, opts.ctx.userId, wsId);
return opts.next({
ctx: { ...opts.ctx, klavisClient: client, pluginModel },
});
});
export const klavisRouter = router({
/**
* Create a single MCP server instance and save to database
* Returns: { serverUrl, instanceId, oauthUrl?, identifier, serverName }
*/
createServerInstance: klavisProcedure
.use(withScopedPermission('agent:update'))
.input(
z.object({
/** Identifier for storage (e.g., 'google-calendar') */
identifier: z.string(),
/** Server name for Klavis API (e.g., 'Google Calendar') */
serverName: z.string(),
userId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const { serverName, userId, identifier } = input;
// Create a single server instance
const response = await ctx.klavisClient.mcpServer.createServerInstance({
serverName: serverName as any,
userId,
});
const { serverUrl, instanceId, oauthUrl } = response;
// Get the tool list for this server
const toolsResponse = await ctx.klavisClient.mcpServer.getTools(serverName as any);
const tools = toolsResponse.tools || [];
// Save to database using the provided identifier (format: lowercase, spaces replaced with hyphens)
const manifest: ToolManifest = {
api: tools.map((tool: any) => ({
description: tool.description || '',
name: tool.name,
parameters: tool.inputSchema || { properties: {}, type: 'object' },
})),
identifier,
meta: {
avatar: '🔌',
description: `LobeHub Mcp Server: ${serverName}`,
title: serverName,
},
type: 'default',
};
// Save to database with oauthUrl and isAuthenticated status
const isAuthenticated = !oauthUrl; // If there's no oauthUrl, authentication is not required or already authenticated
await ctx.pluginModel.create({
customParams: {
klavis: {
instanceId,
isAuthenticated,
oauthUrl,
serverName,
serverUrl,
},
},
identifier,
manifest,
source: 'klavis',
type: 'plugin',
});
return {
identifier,
instanceId,
isAuthenticated,
oauthUrl,
serverName,
serverUrl,
};
}),
/**
* Delete a server instance
*/
deleteServerInstance: klavisProcedure
.use(withScopedPermission('agent:update'))
.input(
z.object({
/** Identifier for storage (e.g., 'google-calendar') */
identifier: z.string(),
instanceId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
// Call Klavis API to delete server instance
await ctx.klavisClient.mcpServer.deleteServerInstance(input.instanceId);
// Delete from database (using identifier)
await ctx.pluginModel.delete(input.identifier);
return { success: true };
}),
/**
* Get Klavis plugins from database
*/
getKlavisPlugins: klavisProcedure.query(async ({ ctx }) => {
const allPlugins = await ctx.pluginModel.query();
// Filter plugins that have klavis customParams
return allPlugins.filter((plugin) => plugin.customParams?.klavis);
}),
/**
* Get server instance status from Klavis API
* Returns error object instead of throwing on auth errors (useful for polling)
*/
getServerInstance: klavisProcedure
.input(
z.object({
instanceId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
try {
const response = await ctx.klavisClient.mcpServer.getServerInstance(input.instanceId);
return {
authNeeded: response.authNeeded,
error: undefined,
externalUserId: response.externalUserId,
instanceId: response.instanceId,
isAuthenticated: response.isAuthenticated,
oauthUrl: response.oauthUrl,
platform: response.platform,
serverName: response.serverName,
};
} catch (error) {
// Check if this is an authentication error
const errorMessage = error instanceof Error ? error.message : String(error);
const isAuthError =
errorMessage.includes('Invalid API key or instance ID') ||
errorMessage.includes('Status code: 401');
// For auth errors, return error object instead of throwing
// This prevents 500 errors in logs during polling
if (isAuthError) {
return {
authNeeded: true,
error: 'AUTH_ERROR',
externalUserId: undefined,
instanceId: input.instanceId,
isAuthenticated: false,
oauthUrl: undefined,
platform: undefined,
serverName: undefined,
};
}
// For other errors, still throw
throw error;
}
}),
getUserIntergrations: klavisProcedure
.input(
z.object({
userId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
const response = await ctx.klavisClient.user.getUserIntegrations(input.userId);
return {
integrations: response.integrations,
};
}),
/**
* Remove Klavis plugin from database by identifier
*/
removeKlavisPlugin: klavisProcedure
.use(withScopedPermission('agent:update'))
.input(
z.object({
/** Identifier for storage (e.g., 'google-calendar') */
identifier: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
await ctx.pluginModel.delete(input.identifier);
return { success: true };
}),
/**
* Update Klavis plugin with tools and auth status in database
*/
updateKlavisPlugin: klavisProcedure
.use(withScopedPermission('agent:update'))
.input(
z.object({
/** Identifier for storage (e.g., 'google-calendar') */
identifier: z.string(),
instanceId: z.string(),
isAuthenticated: z.boolean(),
oauthUrl: z.string().optional(),
/** Server name for Klavis API (e.g., 'Google Calendar') */
serverName: z.string(),
serverUrl: z.string(),
tools: z.array(
z.object({
description: z.string().optional(),
inputSchema: z.any().optional(),
name: z.string(),
}),
),
}),
)
.mutation(async ({ input, ctx }) => {
const { identifier, serverName, serverUrl, instanceId, tools, isAuthenticated, oauthUrl } =
input;
// Get existing plugin (using identifier)
const existingPlugin = await ctx.pluginModel.findById(identifier);
// Build manifest containing all tools
const manifest: ToolManifest = {
api: tools.map((tool) => ({
description: tool.description || '',
name: tool.name,
parameters: tool.inputSchema || { properties: {}, type: 'object' },
})),
identifier,
meta: existingPlugin?.manifest?.meta || {
avatar: '🔌',
description: `LobeHub Mcp Server: ${serverName}`,
title: serverName,
},
type: 'default',
};
const customParams = {
klavis: {
instanceId,
isAuthenticated,
oauthUrl,
serverName,
serverUrl,
},
};
// Update or create plugin
if (existingPlugin) {
await ctx.pluginModel.update(identifier, { customParams, manifest });
} else {
await ctx.pluginModel.create({
customParams,
identifier,
manifest,
source: 'klavis',
type: 'plugin',
});
}
return { savedCount: tools.length };
}),
});
export type KlavisRouter = typeof klavisRouter;
+12 -41
View File
@@ -1,6 +1,5 @@
import { DEFAULT_INBOX_AVATAR, INBOX_SESSION_ID } from '@lobechat/const';
import { TRPCError } from '@trpc/server';
import { and, desc, eq, ne, or } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
@@ -12,6 +11,7 @@ import {
isMessengerPlatformEnabled,
type MessengerPlatform,
} from '@/config/messenger';
import { AgentModel } from '@/database/models/agent';
import {
MessengerAccountLinkConflictError,
MessengerAccountLinkModel,
@@ -23,7 +23,6 @@ import { RbacModel } from '@/database/models/rbac';
import { WorkspaceModel } from '@/database/models/workspace';
import { agents, users } from '@/database/schemas';
import type { LobeChatDatabase } from '@/database/type';
import { buildWorkspaceWhere } from '@/database/utils/workspace';
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { getServerFeatureFlagsStateFromRuntimeConfig } from '@/server/featureFlags';
@@ -122,6 +121,12 @@ const messengerProcedure = authedProcedure.use(serverDatabase).use(async (opts)
// userId), and per-agent authorization happens in-handler via
// `resolveAuthorizedAgentScope`.
messengerLinkModel: new MessengerAccountLinkModel(ctx.serverDB, ctx.userId),
// The bindable-agents scope is request-driven — the cascading scope
// picker passes the workspace via input, not the ambient header — so
// expose a workspace-parameterized AgentModel factory rather than a
// single pre-scoped instance.
getAgentModel: (workspaceId?: string | null) =>
new AgentModel(ctx.serverDB, ctx.userId, workspaceId ?? undefined),
},
});
});
@@ -454,44 +459,10 @@ export const messengerRouter = router({
}
}
const rows = await serverDB
.select({
avatar: agents.avatar,
backgroundColor: agents.backgroundColor,
id: agents.id,
slug: agents.slug,
title: agents.title,
})
.from(agents)
.where(
and(
buildWorkspaceWhere({ userId, workspaceId: workspaceId ?? undefined }, agents),
or(ne(agents.virtual, true), eq(agents.slug, INBOX_SESSION_ID)),
),
)
.orderBy(desc(agents.updatedAt));
const mapped = rows
.filter((row) => row.id)
.map((row) => ({
avatar: row.avatar || (row.slug === INBOX_SESSION_ID ? DEFAULT_INBOX_AVATAR : null),
backgroundColor: row.backgroundColor,
id: row.id,
slug: row.slug,
title: row.title || (row.slug === INBOX_SESSION_ID ? 'LobeAI' : null),
}));
// Pin the inbox/LobeAI agent to the top regardless of updatedAt — it's
// the implicit "default" agent and should always be the first option.
const inboxIdx = mapped.findIndex((row) => row.slug === INBOX_SESSION_ID);
if (inboxIdx > 0) {
const [inbox] = mapped.splice(inboxIdx, 1);
mapped.unshift(inbox);
}
return mapped.map(({ slug, ...rest }) => ({
...rest,
isInbox: slug === INBOX_SESSION_ID,
}));
// Inbox meta fallback, the virtual-or-inbox filter, inbox pinning, and the
// `isInbox` flag all live in the model. Blank non-inbox titles stay null
// here so the web picker can apply its own i18n default.
return ctx.getAgentModel(workspaceId).listMessengerBindableAgents();
}),
/**
@@ -53,7 +53,7 @@ export const oauthDeviceFlowRouter = router({
);
if (!providerDetail?.keyVaults) {
return { isAuthenticated: false };
return { status: 'PENDING' };
}
const keyVaults = providerDetail.keyVaults as Record<string, any>;
@@ -63,12 +63,12 @@ export const oauthDeviceFlowRouter = router({
return {
avatarUrl: keyVaults.githubAvatarUrl as string | undefined,
expiresAt: keyVaults.oauthTokenExpiresAt || keyVaults.bearerTokenExpiresAt,
isAuthenticated: true,
status: 'ACTIVE',
username: keyVaults.githubUsername as string | undefined,
};
}
return { isAuthenticated: false };
return { status: 'PENDING' };
}),
/**
@@ -1,21 +1,19 @@
import { KNOWN_TASK_TEMPLATE_IDS } from '@lobechat/const';
import { TASK_TEMPLATE_RECOMMEND_MAX_COUNT } from '@lobechat/const';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { ENABLED_SKILL_SOURCES, TaskTemplateService } from '@/server/services/taskTemplate';
import { TaskTemplateService } from '@/server/services/taskTemplate';
const listDailyRecommendSchema = z.object({
count: z.number().int().min(1).optional(),
count: z.number().int().min(1).max(TASK_TEMPLATE_RECOMMEND_MAX_COUNT).optional(),
interestKeys: z.array(z.string().max(64)).max(32),
locale: z.string().max(32).optional(),
refreshSeed: z.string().min(1).max(32).optional(),
});
const templateIdSchema = z.object({
templateId: z
.string()
.max(64)
.refine((id) => KNOWN_TASK_TEMPLATE_IDS.has(id), { message: 'Unknown task template id' }),
templateId: z.number().int().positive(),
});
export const taskTemplateRouter = router({
@@ -28,7 +26,7 @@ export const taskTemplateRouter = router({
const service = new TaskTemplateService(ctx.userId);
const data = await service.listDailyRecommend(input.interestKeys, {
count: input.count,
enabledSkillSources: ENABLED_SKILL_SOURCES,
locale: input.locale,
refreshSeed: input.refreshSeed,
});
return { data, success: true };
+36 -50
View File
@@ -1,22 +1,25 @@
import {
chatTopicStatusSchema,
type RecentTopic,
type RecentTopicGroup,
type RecentTopicGroupMember,
} from '@lobechat/types';
import { cleanObject } from '@lobechat/utils';
import { eq, inArray } from 'drizzle-orm';
import { inArray } from 'drizzle-orm';
import { after } from 'next/server';
import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { AgentModel } from '@/database/models/agent';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { ChatGroupModel } from '@/database/models/chatGroup';
import { MessageModel } from '@/database/models/message';
import { TopicModel } from '@/database/models/topic';
import { TopicShareModel } from '@/database/models/topicShare';
import { AgentMigrationRepo } from '@/database/repositories/agentMigration';
import { TopicImporterRepo } from '@/database/repositories/topicImporter';
import { agents, chatGroups, chatGroupsAgents } from '@/database/schemas';
import { chatGroups } from '@/database/schemas';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { type BatchTaskResult } from '@/types/service';
@@ -35,7 +38,9 @@ const topicProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
return opts.next({
ctx: {
agentMigrationRepo: new AgentMigrationRepo(ctx.serverDB, ctx.userId, wsId),
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
agentOperationModel: new AgentOperationModel(ctx.serverDB, ctx.userId, wsId),
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
topicImporterRepo: new TopicImporterRepo(ctx.serverDB, ctx.userId, wsId),
topicModel: new TopicModel(ctx.serverDB, ctx.userId, wsId),
topicShareModel: new TopicShareModel(ctx.serverDB, ctx.userId, wsId),
@@ -251,9 +256,18 @@ export const topicRouter = router({
return ctx.topicShareModel.create(input.topicId, input.visibility);
}),
getAllTopics: topicProcedure.query(async ({ ctx }) => {
return ctx.topicModel.queryAll();
}),
queryTopics: topicProcedure
.input(
z
.object({
pageSize: z.number().max(500).optional(),
statuses: z.array(z.string()).optional(),
})
.optional(),
)
.query(async ({ input, ctx }) => {
return ctx.topicModel.queryTopics({ pageSize: input?.pageSize, statuses: input?.statuses });
}),
getShareInfo: topicProcedure
.input(z.object({ topicId: z.string() }))
@@ -436,22 +450,14 @@ export const topicRouter = router({
// Collect all agentIds to fetch agent info
const allAgentIds = [...new Set(topicAgentIdMap.values())];
// Batch query agent info
// Batch query agent info (already normalized for the inbox agent)
const agentInfoMap = new Map<
string,
{ avatar: string | null; backgroundColor: string | null; id: string; title: string | null }
>();
if (allAgentIds.length > 0) {
const agentInfos = await ctx.serverDB
.select({
avatar: agents.avatar,
backgroundColor: agents.backgroundColor,
id: agents.id,
title: agents.title,
})
.from(agents)
.where(inArray(agents.id, allAgentIds));
const agentInfos = await ctx.agentModel.getAgentAvatarsByIds(allAgentIds);
for (const agent of agentInfos) {
agentInfoMap.set(agent.id, agent);
@@ -472,28 +478,9 @@ export const topicRouter = router({
.from(chatGroups)
.where(inArray(chatGroups.id, allGroupIds));
// Query group member agents (get avatar info)
const groupMembersRaw = await ctx.serverDB
.select({
agentAvatar: agents.avatar,
agentBackgroundColor: agents.backgroundColor,
chatGroupId: chatGroupsAgents.chatGroupId,
order: chatGroupsAgents.order,
})
.from(chatGroupsAgents)
.leftJoin(agents, eq(chatGroupsAgents.agentId, agents.id))
.where(inArray(chatGroupsAgents.chatGroupId, allGroupIds));
// Group members by chatGroupId
const groupMembersMap = new Map<string, RecentTopicGroupMember[]>();
for (const member of groupMembersRaw) {
const members = groupMembersMap.get(member.chatGroupId) || [];
members.push({
avatar: member.agentAvatar,
backgroundColor: member.agentBackgroundColor,
});
groupMembersMap.set(member.chatGroupId, members);
}
// Query group member avatars (already normalized for the inbox agent)
const groupMembersMap: Map<string, RecentTopicGroupMember[]> =
await ctx.chatGroupModel.getMemberAvatarsByGroupIds(allGroupIds);
// Build group info map
for (const group of chatGroupInfos) {
@@ -582,7 +569,17 @@ export const topicRouter = router({
ctx.workspaceId ?? undefined,
);
return ctx.topicModel.queryByKeyword(input.keywords, resolved.sessionId);
// Scope the search exactly like the topics list (`query`): by agentId
// directly (the new agent system stamps every topic with an agentId).
// Passing only the resolved sessionId used to miss every agentId-scoped
// topic — the cause of "no topics match" in the per-agent Topics search.
// `containerId` is only the fallback for legacy callers that pass no
// agentId/groupId.
return ctx.topicModel.queryByKeyword(input.keywords, {
agentId: input.agentId,
containerId: resolved.sessionId,
groupId: input.groupId,
});
}),
/**
@@ -618,18 +615,7 @@ export const topicRouter = router({
})
.optional(),
sessionId: z.string().optional(),
status: z
.enum([
'active',
'running',
'paused',
'waitingForHuman',
'failed',
'completed',
'archived',
])
.nullable()
.optional(),
status: chatTopicStatusSchema.nullable().optional(),
title: z.string().optional(),
}),
}),
@@ -104,6 +104,7 @@ export const userMemoryRouter = router({
deleteAll: userMemoryWriteProcedure.mutation(async ({ ctx }) => {
await ctx.userMemoryModel.deleteAll();
await ctx.personaModel.deletePersona();
return { success: true };
}),
+2
View File
@@ -13,6 +13,7 @@ import { aiProviderRouter } from '../lambda/aiProvider';
import { briefRouter } from '../lambda/brief';
import { chunkRouter } from '../lambda/chunk';
import { configRouter } from '../lambda/config';
import { deviceRouter } from '../lambda/device';
import { documentRouter } from '../lambda/document';
import { fileRouter } from '../lambda/file';
import { homeRouter } from '../lambda/home';
@@ -36,6 +37,7 @@ export const mobileRouter = router({
aiProvider: aiProviderRouter,
chunk: chunkRouter,
config: configRouter,
device: deviceRouter,
document: documentRouter,
file: fileRouter,
healthcheck: publicProcedure.query(() => "i'm live!"),
+3 -14
View File
@@ -3,8 +3,7 @@ import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { TopicModel } from '@/database/models/topic';
import { getServerDB } from '@/database/server';
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { type BatchTaskResult } from '@/types/service';
@@ -95,12 +94,7 @@ export const topicRouter = router({
return data.id;
}),
getAllTopics: topicProcedure.query(async ({ ctx }) => {
return ctx.topicModel.queryAll();
}),
// TODO: this procedure should be used with authedProcedure
getTopics: publicProcedure
getTopics: topicProcedure
.input(
z.object({
containerId: z.string().nullable().optional(),
@@ -109,12 +103,7 @@ export const topicRouter = router({
}),
)
.query(async ({ input, ctx }) => {
if (!ctx.userId) return [];
const serverDB = await getServerDB();
const topicModel = new TopicModel(serverDB, ctx.userId, ctx.workspaceId ?? undefined);
return topicModel.query(input);
return ctx.topicModel.query(input);
}),
hasTopics: topicProcedure.query(async ({ ctx }) => {
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
isMarketConnectionsAuthError,
isMarketConnectionsTimeoutError,
listMarketConnectionsWithTimeout,
listOptionalMarketConnectionsWithTimeout,
MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
} from './marketConnections';
@@ -31,4 +33,33 @@ describe('marketConnections helpers', () => {
expect(isMarketConnectionsTimeoutError(new DOMException('Aborted', 'AbortError'))).toBe(true);
expect(isMarketConnectionsTimeoutError(new Error('market failed'))).toBe(false);
});
it('detects Market auth failures', () => {
expect(
isMarketConnectionsAuthError({
errorBody: { error: 'unauthorized', error_description: 'Missing bearer token' },
status: 401,
}),
).toBe(true);
expect(isMarketConnectionsAuthError(new Error('Network error'))).toBe(false);
});
it('returns empty connections for optional auth failures', async () => {
const listConnections = vi.fn().mockRejectedValue({
errorBody: { error: 'unauthorized', error_description: 'Missing bearer token' },
status: 401,
});
await expect(listOptionalMarketConnectionsWithTimeout({ listConnections })).resolves.toEqual({
connections: [],
success: true,
});
});
it('rethrows non-auth failures for optional connections', async () => {
const error = new Error('Market API unavailable');
const listConnections = vi.fn().mockRejectedValue(error);
await expect(listOptionalMarketConnectionsWithTimeout({ listConnections })).rejects.toBe(error);
});
});
@@ -4,6 +4,47 @@ export const MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS = 10_000;
type MarketConnectClient = Pick<MarketSDK['connect'], 'listConnections'>;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const getStringField = (value: unknown, key: string) => {
if (!isRecord(value)) return;
const field = value[key];
return typeof field === 'string' ? field : undefined;
};
const includesAuthError = (value?: string) => {
const normalized = value?.toLowerCase();
if (!normalized) return false;
return (
normalized === 'unauthorized' ||
normalized === 'invalid_token' ||
normalized === 'token_expired' ||
normalized.includes('missing bearer token') ||
normalized.includes('unauthorized') ||
normalized.includes('invalid_token') ||
normalized.includes('token expired')
);
};
export const isMarketConnectionsAuthError = (error: unknown): boolean => {
if (!isRecord(error)) return false;
const status = error.status;
const errorBody = error.errorBody;
return (
status === 401 ||
includesAuthError(getStringField(error, 'name')) ||
includesAuthError(getStringField(error, 'message')) ||
includesAuthError(getStringField(errorBody, 'error')) ||
includesAuthError(getStringField(errorBody, 'error_description'))
);
};
export const isMarketConnectionsTimeoutError = (error: unknown): boolean =>
error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
@@ -15,3 +56,18 @@ export const listMarketConnectionsWithTimeout = async (
signal: AbortSignal.timeout(timeoutMs),
});
};
export const listOptionalMarketConnectionsWithTimeout = async (
marketConnect: MarketConnectClient,
timeoutMs = MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
): Promise<ListConnectionsResponse> => {
try {
return await listMarketConnectionsWithTimeout(marketConnect, timeoutMs);
} catch (error) {
if (isMarketConnectionsAuthError(error)) {
return { connections: [], success: true };
}
throw error;
}
};

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