Compare commits

...

88 Commits

Author SHA1 Message Date
Arvin Xu 67d314b089 🐛 fix: seed message:list cache even when replaceMessages store-set is a no-op
Optimistic flows (optimisticUpdateMessageContent / optimisticDeleteMessage[s])
dispatch the mutation into dbMessagesMap first, then call
replaceMessages(server). When the server echo equals the already-applied
optimistic state, the isEqual early-return skipped the store-set AND the
write-through, leaving message:list at the pre-mutation snapshot — a later
remount could hydrate stale content / deleted rows.

Move the write-through ahead of the equality early-return so the cache is
seeded even on a store no-op. Streaming / fetch-sync guards stay inside
#writeThroughMessageCache, so per-token thrash is still avoided.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:53:41 +08:00
Arvin Xu 22eddbc474 ️ perf: write message mutations through to the message:list SWR cache
Message mutations only touched the in-memory store, so the message:list
SWR/IndexedDB cache stayed stale until a network refetch. Because the
Conversation store is recreated on every topic/session switch and
re-hydrates from that cache, the stale cache is what forced a refetch on
every switch.

replaceMessages now seeds the message:list cache for the exact bucket via
mutate(matcher, messages, { revalidate: false }). Skips the
useFetchMessages onData sync path (SWR already holds it) and skips while
the context is streaming to avoid per-token IndexedDB thrash; the
agent_runtime_end snapshot still writes through since it clears the
running flag first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:48:48 +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
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
697 changed files with 23285 additions and 10109 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
@@ -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 ;;
+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 |
+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
+283
View File
@@ -0,0 +1,283 @@
---
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)_
- [ ] **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)_
---
## 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.
- [ ] 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.
**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 #############
# #######################################
+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.
+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 {
@@ -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);
@@ -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(
@@ -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 } 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,36 @@ 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;
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) {
const canonicalCard = builtinModels.find(
(item) => item.id === model || item.config?.deploymentName === model,
);
modelExtendParams = readExtendParams(canonicalCard);
}
const modelSupportsPreserveThinkingFromCard =
Array.isArray(modelExtendParams) && modelExtendParams.includes('preserveThinking');
@@ -763,6 +939,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 +1188,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,7 +1243,7 @@ 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,
},
@@ -1205,6 +1393,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 +2637,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 +2654,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,
@@ -3026,7 +3224,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 +3243,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,
@@ -58,6 +58,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
@@ -76,11 +79,11 @@ vi.mock('model-bank', () => ({
LOBE_DEFAULT_MODEL_LIST: mockBuiltinModels,
}));
// 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 +128,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' }),
@@ -4850,10 +4854,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 +4897,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 +5028,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 +5038,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 +5052,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 +5088,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 () => {
@@ -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
@@ -86,7 +86,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];
@@ -231,12 +231,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 +264,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 .
*/
@@ -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);
});
@@ -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({
+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.
*/
+16
View File
@@ -119,6 +119,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
+3 -2
View File
@@ -37,6 +37,7 @@ 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 +51,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';
@@ -115,7 +115,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;
@@ -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' };
}),
/**
+23 -4
View File
@@ -251,9 +251,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() }))
@@ -582,7 +591,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,
});
}),
/**
+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;
}
};
+115
View File
@@ -0,0 +1,115 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { PluginModel } from '@/database/models/plugin';
import { getComposioClient } from '@/libs/composio';
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { MCPService } from '@/server/services/mcp';
const composioProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const composioClient = getComposioClient();
const pluginModel = new PluginModel(opts.ctx.serverDB, opts.ctx.userId);
return opts.next({ ctx: { ...opts.ctx, composioClient, pluginModel } });
});
export const composioToolsRouter = router({
executeAction: composioProcedure
.input(
z.object({
identifier: z.string(),
toolArgs: z.record(z.unknown()).optional(),
toolSlug: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// Resolve the connected account server-side from the caller's own plugin
// record (PluginModel is user-scoped). Never trust a connectedAccountId
// supplied by the client — that would let a user drive another user's
// connection.
const plugin = await ctx.pluginModel.findById(input.identifier);
const connectedAccountId = plugin?.customParams?.composio?.connectedAccountId;
if (!connectedAccountId) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `No Composio connection found for "${input.identifier}".`,
});
}
const result = await (ctx.composioClient.tools as any).execute(input.toolSlug, {
arguments: input.toolArgs || {},
connectedAccountId,
// Toolkit version resolves to "latest"; allow manual execution without a
// pinned version (Composio otherwise throws ComposioToolVersionRequiredError).
dangerouslySkipVersionCheck: true,
userId: ctx.userId,
});
if (!result) {
return {
content: 'Unknown error',
state: { content: [{ text: 'Unknown error', type: 'text' }], isError: true },
success: false,
};
}
const data = result as any;
const content = data?.data || data?.result || data;
const contentStr = typeof content === 'string' ? content : JSON.stringify(content);
return await MCPService.processToolCallResult({
content: [{ text: contentStr, type: 'text' }],
isError: false,
});
}),
getActions: publicProcedure.input(z.object({ appSlug: z.string() })).query(async ({ input }) => {
const client = getComposioClient();
const response = await (client.tools as any).getRawComposioTools({
toolkits: [input.appSlug],
});
const items = response?.items || response || [];
const tools = Array.isArray(items)
? items.map((tool: any) => ({
description: tool.description || '',
inputSchema: tool.inputParameters ||
tool.inputSchema || {
properties: {},
type: 'object',
},
name: tool.slug || tool.name || '',
}))
: [];
return { tools };
}),
listActions: composioProcedure
.input(z.object({ appSlug: z.string() }))
.query(async ({ ctx, input }) => {
// Use getRawComposioTools (raw tool defs with slug/inputParameters), NOT
// tools.get() — the latter returns provider-wrapped (OpenAI-format) tools
// whose name/params live under `.function`, so slug/name/inputSchema come
// back empty and every tool collapses to the same `${identifier}____` name.
const response = await (ctx.composioClient.tools as any).getRawComposioTools({
toolkits: [input.appSlug],
});
const items = response?.items || response || [];
const tools = Array.isArray(items)
? items.map((tool: any) => ({
description: tool.description || '',
inputSchema: tool.inputParameters ||
tool.inputSchema || {
properties: {},
type: 'object',
},
name: tool.slug || tool.name || '',
}))
: [];
return { tools };
}),
});
+3 -2
View File
@@ -1,13 +1,14 @@
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { klavisRouter } from './klavis';
import { composioToolsRouter } from './composio';
import { marketRouter } from './market';
import { mcpRouter } from './mcp';
import { searchRouter } from './search';
export const toolsRouter = router({
healthcheck: publicProcedure.query(() => "i'm live!"),
klavis: klavisRouter,
composio: composioToolsRouter,
market: marketRouter,
mcp: mcpRouter,
search: searchRouter,
-141
View File
@@ -1,141 +0,0 @@
import { z } from 'zod';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { ConnectorModel } from '@/database/models/connector';
import { ConnectorToolModel } from '@/database/models/connectorTool';
import { ConnectorToolPermission } from '@/database/schemas';
import { getKlavisClient } from '@/libs/klavis';
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { MCPService } from '@/server/services/mcp';
/**
* Klavis procedure with client initialized in context
*/
const klavisProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const klavisClient = getKlavisClient();
return opts.next({
ctx: { ...opts.ctx, klavisClient },
});
});
/**
* Klavis router for tools
* Contains callTool and listTools which call external Klavis API
*/
export const klavisRouter = router({
/**
* Call a tool on a Klavis Strata server
*/
callTool: klavisProcedure
.input(
z.object({
/** Klavis server identifier (e.g. 'gmail', 'google-calendar') for precise permission lookup */
identifier: z.string().optional(),
serverUrl: z.string(),
toolArgs: z.record(z.unknown()).optional(),
toolName: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// ── Connector tool permission gate ────────────────────────────────────
// Use identifier + toolName when available for a precise lookup (avoids
// same-name collisions across connectors). Falls back to toolName-only
// if identifier is absent (legacy callers).
if (ctx.userId && ctx.serverDB) {
const wsId = ctx.workspaceId ?? undefined;
const connectorToolModel = new ConnectorToolModel(ctx.serverDB, ctx.userId, wsId);
let connectorTool:
| Awaited<ReturnType<typeof connectorToolModel.findByToolName>>
| undefined;
if (input.identifier) {
const connectorModel = new ConnectorModel(ctx.serverDB, ctx.userId, wsId);
const [connector] = await connectorModel.queryByIdentifiers([input.identifier]);
if (connector) {
const tools = await connectorToolModel.queryByConnector(connector.id);
connectorTool = tools.find((t) => t.toolName === input.toolName);
}
} else {
connectorTool = await connectorToolModel.findByToolName(input.toolName);
}
if (connectorTool?.permission === ConnectorToolPermission.disabled) {
const message =
`The tool "${input.toolName}" has been disabled by the user and cannot be executed. ` +
`Please inform the user that this tool is currently disabled. ` +
`They can re-enable it in Settings > Connectors.`;
return {
content: message,
state: { content: [{ text: message, type: 'text' }], isError: false },
success: true,
};
}
}
// ── End permission gate ───────────────────────────────────────────────
const response = await ctx.klavisClient.mcpServer.callTools({
serverUrl: input.serverUrl,
toolArgs: input.toolArgs,
toolName: input.toolName,
});
// Handle error case
if (!response.success || !response.result) {
return {
content: response.error || 'Unknown error',
state: {
content: [{ text: response.error || 'Unknown error', type: 'text' }],
isError: true,
},
success: false,
};
}
// Process the response using the common MCP tool call result processor
const processedResult = await MCPService.processToolCallResult({
content: (response.result.content || []) as any[],
isError: response.result.isError,
});
return processedResult;
}),
/**
* Get tools by server name (public endpoint, no auth required)
*/
getTools: publicProcedure
.input(
z.object({
serverName: z.string(),
}),
)
.query(async ({ input }) => {
const klavisClient = getKlavisClient();
const response = await klavisClient.mcpServer.getTools(input.serverName as any);
return {
tools: response.tools,
};
}),
/**
* List tools available on a Klavis Strata server
*/
listTools: klavisProcedure
.input(
z.object({
serverUrl: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const response = await ctx.klavisClient.mcpServer.listTools({
serverUrl: input.serverUrl,
});
return {
tools: response.tools,
};
}),
});
+2 -2
View File
@@ -23,7 +23,7 @@ import { createSandboxService } from '@/server/services/sandbox';
import { scheduleToolCallReport } from './_helpers';
import {
isMarketConnectionsTimeoutError,
listMarketConnectionsWithTimeout,
listOptionalMarketConnectionsWithTimeout,
MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
} from './_helpers/marketConnections';
@@ -540,7 +540,7 @@ export const marketRouter = router({
log('connectListConnections');
try {
const response = await listMarketConnectionsWithTimeout(ctx.marketSDK.connect);
const response = await listOptionalMarketConnectionsWithTimeout(ctx.marketSDK.connect);
// Debug logging
log('connectListConnections raw response: %O', response);
log('connectListConnections connections: %O', response.connections);
@@ -98,6 +98,8 @@ describe('AgentDocumentsService', () => {
findByFilename: vi.fn(),
findSkillDocsByAgent: vi.fn(),
hasByAgent: vi.fn(),
listByAgent: vi.fn(),
listByDocumentIds: vi.fn(),
rename: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
@@ -282,21 +284,19 @@ describe('AgentDocumentsService', () => {
describe('listDocuments', () => {
it('should return a list of documents with documentId, filename, id, and title', async () => {
mockModel.findByAgent.mockResolvedValue([
mockModel.listByAgent.mockResolvedValue([
{
content: 'c1',
documentId: 'documents-1',
filename: 'a.md',
id: 'doc-1',
policy: null,
loadPosition: undefined,
title: 'A',
},
{
content: 'c2',
documentId: 'documents-2',
filename: 'b.md',
id: 'doc-2',
policy: null,
loadPosition: undefined,
title: 'B',
},
]);
@@ -304,7 +304,8 @@ describe('AgentDocumentsService', () => {
const service = new AgentDocumentsService(db, userId);
const result = await service.listDocuments('agent-1');
expect(mockModel.findByAgent).toHaveBeenCalledWith('agent-1');
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1');
expect(mockModel.findByAgent).not.toHaveBeenCalled();
expect(result).toEqual([
{
documentId: 'documents-1',
@@ -322,6 +323,16 @@ describe('AgentDocumentsService', () => {
},
]);
});
it('should pass sourceType filtering to the model', async () => {
mockModel.listByAgent.mockResolvedValue([]);
const service = new AgentDocumentsService(db, userId);
await service.listDocuments('agent-1', 'web');
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1', { sourceType: 'web' });
expect(mockModel.findByAgent).not.toHaveBeenCalled();
});
});
describe('listDocumentsForTopic', () => {
@@ -330,19 +341,19 @@ describe('AgentDocumentsService', () => {
{ id: 'documents-2', title: 'B' },
{ id: 'documents-1', title: 'A' },
]);
mockModel.findByDocumentIds.mockResolvedValue([
mockModel.listByDocumentIds.mockResolvedValue([
{
documentId: 'documents-1',
filename: 'a.md',
id: 'agent-doc-1',
policy: null,
loadPosition: undefined,
title: 'A',
},
{
documentId: 'documents-2',
filename: 'b.md',
id: 'agent-doc-2',
policy: null,
loadPosition: undefined,
title: 'B',
},
]);
@@ -351,10 +362,11 @@ describe('AgentDocumentsService', () => {
const result = await service.listDocumentsForTopic('agent-1', 'topic-1');
expect(mockTopicDocumentModel.findByTopicId).toHaveBeenCalledWith('topic-1');
expect(mockModel.findByDocumentIds).toHaveBeenCalledWith('agent-1', [
expect(mockModel.listByDocumentIds).toHaveBeenCalledWith('agent-1', [
'documents-2',
'documents-1',
]);
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
expect(result).toEqual([
{
documentId: 'documents-2',
@@ -372,6 +384,19 @@ describe('AgentDocumentsService', () => {
},
]);
});
it('should pass sourceType filtering to the topic document summary query', async () => {
mockTopicDocumentModel.findByTopicId.mockResolvedValue([{ id: 'documents-1' }]);
mockModel.listByDocumentIds.mockResolvedValue([]);
const service = new AgentDocumentsService(db, userId);
await service.listDocumentsForTopic('agent-1', 'topic-1', 'web');
expect(mockModel.listByDocumentIds).toHaveBeenCalledWith('agent-1', ['documents-1'], {
sourceType: 'web',
});
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
});
});
describe('getDocumentByFilename', () => {
@@ -13,6 +13,8 @@ import type {
AgentDocument,
AgentDocumentContextPayload,
AgentDocumentContextRow,
AgentDocumentListItem,
AgentDocumentListSourceType,
AgentDocumentWithRules,
ToolUpdateLoadRule,
} from '@/database/models/agentDocuments';
@@ -611,54 +613,27 @@ export class AgentDocumentsService {
}
}
async listDocuments(agentId: string, sourceType?: 'all' | 'file' | 'web') {
const docs = await this.agentDocumentModel.findByAgent(agentId);
const filtered =
sourceType && sourceType !== 'all' ? docs.filter((d) => d.sourceType === sourceType) : docs;
return filtered.map((d) => ({
...deriveAgentDocumentFields(d),
description: d.description,
documentId: d.documentId,
fileType: d.fileType,
filename: d.filename,
id: d.id,
loadPosition: d.policy?.context?.position,
parentId: d.parentId,
sourceType: d.sourceType,
templateId: d.templateId,
title: d.title,
updatedAt: d.updatedAt,
}));
async listDocuments(agentId: string, sourceType?: AgentDocumentListSourceType) {
if (!sourceType) return this.agentDocumentModel.listByAgent(agentId);
return this.agentDocumentModel.listByAgent(agentId, { sourceType });
}
async listDocumentsForTopic(
agentId: string,
topicId: string,
sourceType?: 'all' | 'file' | 'web',
sourceType?: AgentDocumentListSourceType,
) {
const topicDocs = await this.topicDocumentModel.findByTopicId(topicId);
const documentIds = topicDocs.map((doc) => doc.id);
const docs = await this.agentDocumentModel.findByDocumentIds(agentId, documentIds);
const docs = sourceType
? await this.agentDocumentModel.listByDocumentIds(agentId, documentIds, { sourceType })
: await this.agentDocumentModel.listByDocumentIds(agentId, documentIds);
const docsByDocumentId = new Map(docs.map((doc) => [doc.documentId, doc]));
return topicDocs
.map((topicDoc) => docsByDocumentId.get(topicDoc.id))
.filter((doc): doc is AgentDocumentWithRules => Boolean(doc))
.filter((doc) => !sourceType || sourceType === 'all' || doc.sourceType === sourceType)
.map((doc) => ({
...deriveAgentDocumentFields(doc),
description: doc.description,
documentId: doc.documentId,
fileType: doc.fileType,
filename: doc.filename,
id: doc.id,
loadPosition: doc.policy?.context?.position,
parentId: doc.parentId,
sourceType: doc.sourceType,
templateId: doc.templateId,
title: doc.title,
updatedAt: doc.updatedAt,
}));
.filter((doc): doc is AgentDocumentListItem => Boolean(doc));
}
async getDocumentByFilename(agentId: string, filename: string) {
@@ -10,6 +10,7 @@ import type { LobeChatDatabase } from '@/database/type';
import { AgentRuntimeCoordinator } from '@/server/modules/AgentRuntime/AgentRuntimeCoordinator';
import { OperationTraceRecorder } from './OperationTraceRecorder';
import { createDefaultSnapshotStore } from './snapshotStore';
const log = debug('lobe-server:abandon-operation');
@@ -127,25 +128,3 @@ export class AbandonOperationService {
return result;
}
}
function createDefaultSnapshotStore(): ISnapshotStore | null {
if (process.env.ENABLE_AGENT_S3_TRACING === '1') {
try {
const { S3SnapshotStore } = require('@/server/modules/AgentTracing');
return new S3SnapshotStore();
} catch {
/* S3SnapshotStore not available */
}
}
if (process.env.NODE_ENV === 'development') {
try {
const { FileSnapshotStore } = require('@lobechat/agent-tracing');
return new FileSnapshotStore();
} catch {
/* agent-tracing not available */
}
}
return null;
}
@@ -17,6 +17,9 @@ import {
} from './types';
vi.mock('@lobechat/model-runtime', () => ({
// RuntimeExecutors (loaded transitively) resolves extend params via this
// helper; an empty result keeps the runtime payload unchanged.
applyModelExtendParams: vi.fn(() => ({})),
getModelPropertyWithFallback: vi.fn(),
// `llmErrorClassification.ts` reads these at module-load time; an empty
// spec map is fine here because this suite never exercises the runtime
@@ -1696,7 +1699,7 @@ describe('AgentRuntimeService', () => {
expect(casSpy).not.toHaveBeenCalled();
});
it('arms a one-shot verify when the parent has not parked yet and scheduleVerifyOnHold is set', async () => {
it('arms the first verify (attempt 1, 15s) when the parent has not parked yet and scheduleVerifyOnHold is set', async () => {
// Child completed before the parent's parking step persisted its state.
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [],
@@ -1712,14 +1715,15 @@ describe('AgentRuntimeService', () => {
expect(won).toBe(false);
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
expect.objectContaining({
delay: 15_000,
operationId: parentOpId,
payload: { verifyAsyncToolBarrier: true },
payload: { asyncToolVerifyAttempt: 1, verifyAsyncToolBarrier: true },
stepIndex: 2,
}),
);
});
it('arms a one-shot verify when the barrier is unsatisfied and scheduleVerifyOnHold is set', async () => {
it('arms a verify when the barrier is unsatisfied and scheduleVerifyOnHold is set', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [{ id: 'tc1' }],
status: 'waiting_for_async_tool',
@@ -1736,7 +1740,104 @@ describe('AgentRuntimeService', () => {
expect(won).toBe(false);
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
expect.objectContaining({ payload: { verifyAsyncToolBarrier: true } }),
expect.objectContaining({
payload: { asyncToolVerifyAttempt: 1, verifyAsyncToolBarrier: true },
}),
);
});
it('re-arms the next verify with exponential backoff while the barrier holds', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [{ id: 'tc1' }],
status: 'waiting_for_async_tool',
stepCount: 1,
});
(service as any).serverDB.query = {
messagePlugins: { findFirst: vi.fn().mockResolvedValue(null) },
};
// A verify handler running as attempt 2 re-arms attempt 3 (60s).
await service.tryResumeParentFromAsyncTool(
{ parentOperationId: parentOpId },
{ scheduleVerifyOnHold: true, verifyAttempt: 3 },
);
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
expect.objectContaining({
delay: 60_000,
payload: { asyncToolVerifyAttempt: 3, verifyAsyncToolBarrier: true },
}),
);
});
it('stops re-arming once the bounded attempts are exhausted', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [{ id: 'tc1' }],
status: 'waiting_for_async_tool',
stepCount: 1,
});
(service as any).serverDB.query = {
messagePlugins: { findFirst: vi.fn().mockResolvedValue(null) },
};
const won = await service.tryResumeParentFromAsyncTool(
{ parentOperationId: parentOpId },
{ scheduleVerifyOnHold: true, verifyAttempt: 6 },
);
expect(won).toBe(false);
expect(mockQueueService.scheduleMessage).not.toHaveBeenCalled();
});
it('trusts a just-backfilled message id without re-reading it (read-your-writes)', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [{ id: 'tc1' }],
status: 'waiting_for_async_tool',
stepCount: 3,
});
// Plugin row exists (created at park) but its state still reads stale.
const findById = vi.fn().mockResolvedValue({ content: '' });
(service as any).serverDB.query = {
messagePlugins: {
findFirst: vi.fn().mockResolvedValue({ id: 'msg-tc1', state: null, toolCallId: 'tc1' }),
},
};
(service as any).messageModel.findById = findById;
const casSpy = vi
.spyOn(AgentOperationModel.prototype, 'tryResumeFromAsyncTool')
.mockResolvedValue(true);
const won = await service.tryResumeParentFromAsyncTool(
{ parentOperationId: parentOpId },
{ knownFulfilledMessageId: 'msg-tc1' },
);
expect(won).toBe(true);
expect(casSpy).toHaveBeenCalledWith(parentOpId);
// The stale read must be skipped — barrier trusted the local backfill.
expect(findById).not.toHaveBeenCalled();
});
it('arms a fallback verify when a parked op has no pending tools', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [],
status: 'waiting_for_async_tool',
stepCount: 4,
});
const casSpy = vi.spyOn(AgentOperationModel.prototype, 'tryResumeFromAsyncTool');
const won = await service.tryResumeParentFromAsyncTool(
{ parentOperationId: parentOpId },
{ scheduleVerifyOnHold: true },
);
expect(won).toBe(false);
expect(casSpy).not.toHaveBeenCalled();
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
expect.objectContaining({
payload: { asyncToolVerifyAttempt: 1, verifyAsyncToolBarrier: true },
stepIndex: 4,
}),
);
});
@@ -1755,6 +1856,186 @@ describe('AgentRuntimeService', () => {
expect(won).toBe(false);
expect(mockQueueService.scheduleMessage).not.toHaveBeenCalled();
});
it('schedules a finish step when the parked tool requests onComplete=finish (skipCallSupervisor / delegate)', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [{ id: 'tc1' }],
status: 'waiting_for_async_tool',
stepCount: 4,
});
(service as any).serverDB.query = {
messagePlugins: {
findFirst: vi.fn().mockResolvedValue({
id: 'msg-tc1',
state: { onComplete: 'finish', status: 'completed' },
toolCallId: 'tc1',
}),
},
};
(service as any).messageModel.findById = vi.fn().mockResolvedValue({ content: 'answer' });
vi.spyOn(AgentOperationModel.prototype, 'tryResumeFromAsyncTool').mockResolvedValue(true);
const won = await service.tryResumeParentFromAsyncTool({ parentOperationId: parentOpId });
expect(won).toBe(true);
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
expect.objectContaining({ payload: { finishAfterAsyncTool: true }, stepIndex: 4 }),
);
});
});
describe('completeGroupActionMember', () => {
const memberState = {
messages: [
{ content: 'question', role: 'user' },
{ content: 'final answer', role: 'assistant' },
],
metadata: { agentId: 'agent-a' },
modelRuntimeConfig: { model: 'gpt-test' },
status: 'done',
usage: { llm: { tokens: { total: 42 } }, tools: { totalCalls: 2 } },
};
let updateToolMessage: ReturnType<typeof vi.fn>;
let resumeSpy: MockInstance<AgentRuntimeService['tryResumeParentFromAsyncTool']>;
beforeEach(() => {
updateToolMessage = vi.fn().mockResolvedValue({ success: true });
(service as any).messageModel.updateToolMessage = updateToolMessage;
resumeSpy = vi.spyOn(service, 'tryResumeParentFromAsyncTool').mockResolvedValue(true);
});
it('single in-group member: backfills a receipt onto the group tool and resumes', async () => {
const won = await service.completeGroupActionMember({
anchorMessageId: 'grp-tool-1',
expectedMembers: 1,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(won).toBe(true);
expect(updateToolMessage).toHaveBeenCalledWith(
'grp-tool-1',
expect.objectContaining({
content: 'Agent agent-a responded in the group.',
pluginState: expect.objectContaining({ status: 'completed' }),
}),
);
expect(resumeSpy).toHaveBeenCalledWith(
{ parentOperationId: 'parent-1' },
{ scheduleVerifyOnHold: true },
);
});
it('single isolated member: backfills the final answer', async () => {
await service.completeGroupActionMember({
anchorMessageId: 'grp-tool-1',
expectedMembers: 1,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'isolated',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(updateToolMessage).toHaveBeenCalledWith(
'grp-tool-1',
expect.objectContaining({ content: 'final answer' }),
);
});
it('multi-member: holds (no group-tool backfill, no resume) until the barrier is met', async () => {
(service as any).serverDB.query = {
messagePlugins: { findFirst: vi.fn() },
messages: {
findMany: vi
.fn()
.mockResolvedValue([{ content: 'a note', id: 'anchor-0', role: 'tool' }]),
},
};
mockCoordinator.loadAgentState.mockResolvedValue({
status: 'waiting_for_async_tool',
stepCount: 1,
});
const won = await service.completeGroupActionMember({
anchorMessageId: 'anchor-0',
expectedMembers: 2,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(won).toBe(false);
expect(updateToolMessage).toHaveBeenCalledWith('anchor-0', expect.anything());
expect(updateToolMessage).not.toHaveBeenCalledWith('grp-tool-1', expect.anything());
expect(resumeSpy).not.toHaveBeenCalled();
});
it('multi-member: last completion backfills the group tool and resumes', async () => {
(service as any).serverDB.query = {
messagePlugins: { findFirst: vi.fn() },
messages: {
findMany: vi.fn().mockResolvedValue([
{ content: 'a', id: 'anchor-0', role: 'tool' },
{ content: 'b', id: 'anchor-1', role: 'tool' },
]),
},
};
const won = await service.completeGroupActionMember({
anchorMessageId: 'anchor-1',
expectedMembers: 2,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-2',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(won).toBe(true);
expect(updateToolMessage).toHaveBeenCalledWith('anchor-1', expect.anything());
expect(updateToolMessage).toHaveBeenCalledWith(
'grp-tool-1',
expect.objectContaining({
content: 'All 2 agent members completed.',
pluginState: expect.objectContaining({ status: 'completed' }),
}),
);
expect(resumeSpy).toHaveBeenCalled();
});
it('throws when the anchor backfill fails so the webhook redelivers', async () => {
updateToolMessage.mockResolvedValue({ success: false });
await expect(
service.completeGroupActionMember({
anchorMessageId: 'grp-tool-1',
expectedMembers: 1,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
}),
).rejects.toThrow(/failed to backfill anchor/);
expect(resumeSpy).not.toHaveBeenCalled();
});
});
describe('completeSubAgentBridge', () => {
@@ -1805,7 +2086,7 @@ describe('AgentRuntimeService', () => {
});
expect(resumeSpy).toHaveBeenCalledWith(
{ parentOperationId: 'parent-op-1' },
{ scheduleVerifyOnHold: true },
{ knownFulfilledMessageId: 'tool-msg-1', scheduleVerifyOnHold: true },
);
});
@@ -1859,4 +2140,93 @@ describe('AgentRuntimeService', () => {
expect(resumeSpy).not.toHaveBeenCalled();
});
});
describe('resolveAsyncToolOnComplete', () => {
it('returns finish when ANY pending tool requests finish (not just the first)', async () => {
// First pending tool resumes; a later one is a group finish action. The
// disposition must scan all pending tools, not only pending[0].
(service as any).serverDB.query = {
messagePlugins: {
findFirst: vi
.fn()
.mockResolvedValueOnce({ state: { status: 'completed' } })
.mockResolvedValueOnce({ state: { onComplete: 'finish', status: 'completed' } }),
},
};
const result = await (service as any).resolveAsyncToolOnComplete([
{ id: 'tc1' },
{ id: 'tc2' },
]);
expect(result).toBe('finish');
});
it('returns resume when no pending tool requests finish', async () => {
(service as any).serverDB.query = {
messagePlugins: {
findFirst: vi.fn().mockResolvedValue({ state: { status: 'completed' } }),
},
};
const result = await (service as any).resolveAsyncToolOnComplete([
{ id: 'tc1' },
{ id: 'tc2' },
]);
expect(result).toBe('resume');
});
});
describe('group member timeout watchdog', () => {
const timeoutParams = {
anchorMessageId: 'anchor-1',
expectedMembers: 1,
groupToolMessageId: 'grp-tool-1',
memberOperationId: 'member-op-1',
mode: 'isolated' as const,
onComplete: 'resume' as const,
parentOperationId: 'parent-1',
};
it('no-ops when the member already reached a terminal state', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({ status: 'done' });
const interruptSpy = vi.spyOn(service, 'interruptOperation');
const bridgeSpy = vi.spyOn(service, 'completeGroupActionMember');
const result = await service.executeStep({
groupMemberTimeout: timeoutParams,
operationId: 'member-op-1',
stepIndex: 0,
} as any);
expect(result.success).toBe(true);
expect(result.nextStepScheduled).toBe(false);
expect(interruptSpy).not.toHaveBeenCalled();
expect(bridgeSpy).not.toHaveBeenCalled();
});
it('interrupts the member and bridges a timeout when it is still running', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({ status: 'running' });
const interruptSpy = vi.spyOn(service, 'interruptOperation').mockResolvedValue(true);
const bridgeSpy = vi.spyOn(service, 'completeGroupActionMember').mockResolvedValue(true);
const result = await service.executeStep({
groupMemberTimeout: timeoutParams,
operationId: 'member-op-1',
stepIndex: 0,
} as any);
expect(interruptSpy).toHaveBeenCalledWith('member-op-1');
expect(bridgeSpy).toHaveBeenCalledWith(
expect.objectContaining({
onComplete: 'resume',
operationId: 'member-op-1',
parentOperationId: 'parent-1',
reason: 'timeout',
}),
);
expect(result.nextStepScheduled).toBe(true);
});
});
});
@@ -20,6 +20,7 @@ import {
trace as otelTrace,
} from '@lobechat/observability-otel/api';
import {
asyncToolResumeCounter,
buildInvokeAgentAttributes,
buildInvokeAgentResultAttributes,
invokeAgentSpanName,
@@ -61,10 +62,16 @@ import { CompletionLifecycle } from './CompletionLifecycle';
import { hookDispatcher } from './hooks';
import { HumanInterventionHandler } from './HumanInterventionHandler';
import { OperationTraceRecorder } from './OperationTraceRecorder';
import { createDefaultSnapshotStore } from './snapshotStore';
import { buildStepPresentation, formatTokenCount } from './stepPresentation';
import {
type AgentExecutionParams,
type AgentExecutionResult,
type ExecGroupMemberParams,
type ExecGroupMemberResult,
type GroupActionMemberBridgeParams,
type GroupActionOnComplete,
type GroupMemberTimeoutParams,
type OperationCreationParams,
type OperationCreationResult,
type OperationStatusResult,
@@ -83,13 +90,37 @@ if (process.env.VERCEL) {
const log = debug('lobe-server:agent-runtime-service');
/**
* Delay before a one-shot `verifyAsyncToolBarrier` re-check fires after a
* Base delay before the first `verifyAsyncToolBarrier` re-check fires after a
* sub-agent completion found the parent not yet resumable. Long enough for
* the parent's parking step to finish persisting, short enough that a lost
* resume is recovered promptly.
* resume is recovered promptly. Subsequent attempts back off exponentially
* see {@link asyncToolVerifyDelayMs}.
*/
const ASYNC_TOOL_VERIFY_DELAY_MS = 15_000;
/**
* Maximum number of bounded watchdog re-checks armed per parked parent. The
* watchdog re-arms after each unsatisfied check (instead of the old single
* shot) so a transient miss a read-replica lag, a sibling dying between
* backfill and resume is retried rather than leaving the parent stuck in
* `waiting_for_async_tool` forever. With exponential backoff from a 15s base,
* 5 attempts span ~15s ~7.75min total before giving up. For details see: async sub-agent suspend/resume stability hardening bounded watchdog retry with exponential backoff instead of single-shot verification.
*/
const ASYNC_TOOL_VERIFY_MAX_ATTEMPTS = 5;
/** Hard ceiling on a single backoff delay so late attempts don't overshoot. */
const ASYNC_TOOL_VERIFY_MAX_DELAY_MS = 240_000;
/**
* Exponential backoff delay for the Nth (1-based) watchdog re-check:
* 15s, 30s, 60s, 120s, 240s, capped at {@link ASYNC_TOOL_VERIFY_MAX_DELAY_MS}.
*/
const asyncToolVerifyDelayMs = (attempt: number): number =>
Math.min(
ASYNC_TOOL_VERIFY_DELAY_MS * 2 ** (Math.max(1, attempt) - 1),
ASYNC_TOOL_VERIFY_MAX_DELAY_MS,
);
/**
* Format error for storage in message pluginError metadata.
* Handles Error objects which don't serialize properly with JSON.stringify.
@@ -130,6 +161,13 @@ const toAgentSignalSnapshotEvents = (
* top-level option. One named home for the whole upward-call surface.
*/
export interface AgentRuntimeDelegate {
/**
* Fork a group member ("call agent member") under a `lobe-group-management`
* tool call. Handles both in-group (non-isolated, shared group session) and
* isolated members, installing the group-action member completion bridge that
* enforces the K=N member barrier before resuming/finishing the supervisor.
*/
execGroupMember?: (params: ExecGroupMemberParams) => Promise<ExecGroupMemberResult>;
/**
* Run a legacy agent invocation through the full high-level pipeline
* (AiAgentService.execSubAgent execAgent: agent-config resolution, tool
@@ -256,7 +294,7 @@ export class AgentRuntimeService {
this.queueService =
options?.queueService === null ? null : (options?.queueService ?? new QueueService());
this.traceRecorder = new OperationTraceRecorder(
options?.snapshotStore ?? this.createDefaultSnapshotStore(),
options?.snapshotStore ?? createDefaultSnapshotStore(),
);
this.agentFactory = options?.agentFactory;
this.delegate = options?.delegate ?? {};
@@ -587,17 +625,39 @@ export class AgentRuntimeService {
rejectionReason,
rejectAndContinue,
resumeAsyncTool,
finishAfterAsyncTool,
groupMemberTimeout,
toolMessageId,
verifyAsyncToolBarrier,
asyncToolVerifyAttempt,
externalRetryCount = 0,
} = params;
// Group member timeout watchdog: enforce a member's deadline without claiming
// the step lock. No-op if the member already finished; otherwise interrupt it
// and bridge a `timeout` completion so the parked supervisor resumes/finishes.
if (groupMemberTimeout) {
return this.handleGroupMemberTimeout(groupMemberTimeout);
}
// Watchdog re-check for a parked async-tool wait: re-run the barrier + CAS
// without claiming the step lock or executing anything. Idempotent — the
// CAS guarantees at most one real resume regardless of how many checks run.
// Opt back into `scheduleVerifyOnHold` with the next attempt so an
// unsatisfied barrier re-arms (bounded backoff) instead of giving up after
// a single shot — bounded watchdog retry ensures transient misses are recovered.
if (verifyAsyncToolBarrier) {
log('[%s][%d] Running async-tool barrier verify', operationId, stepIndex);
const resumed = await this.tryResumeParentFromAsyncTool({ parentOperationId: operationId });
const attempt = asyncToolVerifyAttempt ?? 1;
log(
'[%s][%d] Running async-tool barrier verify (attempt %d)',
operationId,
stepIndex,
attempt,
);
const resumed = await this.tryResumeParentFromAsyncTool(
{ parentOperationId: operationId },
{ scheduleVerifyOnHold: true, verifyAttempt: attempt + 1 },
);
return {
nextStepScheduled: resumed,
state: {},
@@ -848,6 +908,29 @@ export class AgentRuntimeService {
);
}
// Finish a parked supervisor op WITHOUT another LLM turn (group
// orchestration skipCallSupervisor / delegate). Refresh messages so the
// final group conversation is captured, transition straight to `done`,
// and let the standard `!shouldContinue` finalization below record
// completion + dispatch hooks. Skips runtime.step entirely.
let forcedFinishState: AgentState | undefined;
if (finishAfterAsyncTool && currentState.status === 'waiting_for_async_tool') {
const refreshed = await this.refreshMessagesFromDB(currentState);
currentState = structuredClone(currentState);
currentState.messages = refreshed;
currentState.pendingToolsCalling = [];
currentState.status = 'done';
currentState.interruption = undefined;
currentState.lastModified = new Date().toISOString();
forcedFinishState = currentState;
log(
'[%s][%d] Finishing parked supervisor op after async tool (%d messages)',
operationId,
stepIndex,
refreshed.length,
);
}
// Pre-step computation: extract device context from DB messages
// Follows front-end computeStepContext pattern — computed at step boundary, not inside executors
if (!currentState.metadata?.activeDeviceId) {
@@ -865,9 +948,11 @@ export class AgentRuntimeService {
}
}
// Execute step
// Execute step (skipped when force-finishing a parked supervisor op).
const startAt = Date.now();
const stepResult = await runtime.step(currentState, currentContext);
const stepResult = forcedFinishState
? { events: [], newState: forcedFinishState, nextContext: undefined }
: await runtime.step(currentState, currentContext);
// Inner runtime.step() catches model-runtime exceptions and stuffs the
// raw error into newState.error without re-throwing — so the outer
@@ -1626,12 +1711,35 @@ export class AgentRuntimeService {
*/
async tryResumeParentFromAsyncTool(
params: { parentOperationId: string },
options?: { scheduleVerifyOnHold?: boolean },
options?: {
/**
* Message id of a tool placeholder the caller just backfilled to a
* terminal state. Trusted by the barrier as fulfilled without re-reading
* `message_plugins` closes the read-your-writes gap where the barrier
* query hits a read replica that hasn't seen the just-committed write.
*/
knownFulfilledMessageId?: string;
/**
* Group orchestration disposition (skipCallSupervisor / delegate finish).
* When omitted, resolved from the parked tool message's pluginState.
*/
onComplete?: GroupActionOnComplete;
scheduleVerifyOnHold?: boolean;
/** 1-based watchdog attempt to arm when the parent isn't resumable yet. */
verifyAttempt?: number;
},
): Promise<boolean> {
const { parentOperationId } = params;
const state = await this.coordinator.loadAgentState(parentOperationId);
if (!state) return false;
if (!state) {
// State expired (Redis TTL) or never persisted — nothing left to resume.
// Surface it: a missing state at completion time is how a parent silently
// strands. There is no stepCount/status to arm a verify against.
log('[%s] async-tool resume: parent state missing/expired, cannot resume', parentOperationId);
asyncToolResumeCounter.add(1, { outcome: 'no_state' });
return false;
}
if (state.status !== 'waiting_for_async_tool') {
// Not parked (yet). Either the op already resumed/finished — nothing to
@@ -1642,26 +1750,58 @@ export class AgentRuntimeService {
}
const pending = (state.pendingToolsCalling ?? []) as ChatToolPayload[];
if (pending.length === 0) return false;
// Barrier: every pending tool must have a fulfilled tool_result message.
const allFulfilled = await this.allPendingToolsFulfilled(pending);
if (!allFulfilled) {
log('[%s] async-tool barrier not yet satisfied, holding', parentOperationId);
if (pending.length === 0) {
// Parked but no pending tools recorded — usually the parked snapshot's
// `pendingToolsCalling` hasn't finished persisting yet. Warn, report, and
// arm a fallback re-check rather than returning silently (the old bug).
log(
'[%s] async-tool resume: parked op has no pending tools, arming fallback',
parentOperationId,
);
asyncToolResumeCounter.add(1, { outcome: 'no_pending' });
await this.maybeScheduleAsyncToolVerify(parentOperationId, state, options);
return false;
}
// Barrier: every pending tool must have a fulfilled tool_result message.
const allFulfilled = await this.allPendingToolsFulfilled(
pending,
options?.knownFulfilledMessageId,
);
if (!allFulfilled) {
log('[%s] async-tool barrier not yet satisfied, holding', parentOperationId);
asyncToolResumeCounter.add(1, { outcome: 'barrier_held' });
await this.maybeScheduleAsyncToolVerify(parentOperationId, state, options);
return false;
}
// Group orchestration's skipCallSupervisor / delegate ends the supervisor
// op without another LLM turn: the same CAS gate flips the parked op, but
// the scheduled step finishes it (`finishAfterAsyncTool`) instead of
// re-entering the LLM (`resumeAsyncTool`). Self-describing so the generic
// verify watchdog resolves it correctly: the option (if any) wins, else the
// hint persisted on the parked tool message's pluginState, else resume.
const onComplete: GroupActionOnComplete =
options?.onComplete ?? (await this.resolveAsyncToolOnComplete(pending));
// Single-fire guard: only one concurrent completion flips the op.
const won = await new AgentOperationModel(this.serverDB, this.userId).tryResumeFromAsyncTool(
parentOperationId,
);
if (!won) {
log('[%s] lost async-tool resume CAS, no-op', parentOperationId);
asyncToolResumeCounter.add(1, { outcome: 'lost_cas' });
return false;
}
log('[%s] won async-tool resume CAS, scheduling step %d', parentOperationId, state.stepCount);
asyncToolResumeCounter.add(1, { outcome: 'resumed' });
log(
'[%s] won async-tool resume CAS, scheduling step %d (onComplete: %s)',
parentOperationId,
state.stepCount,
onComplete,
);
if (this.queueService) {
await this.queueService.scheduleMessage({
@@ -1669,7 +1809,8 @@ export class AgentRuntimeService {
delay: 100,
endpoint: `${this.baseURL}/run`,
operationId: parentOperationId,
payload: { resumeAsyncTool: true },
payload:
onComplete === 'finish' ? { finishAfterAsyncTool: true } : { resumeAsyncTool: true },
priority: 'high',
stepIndex: state.stepCount,
});
@@ -1681,36 +1822,60 @@ export class AgentRuntimeService {
}
/**
* Arm a one-shot delayed `verifyAsyncToolBarrier` re-check for a parent op
* whose resume attempt found it not yet resumable. Skipped for terminal
* states (nothing left to resume) and when the caller didn't opt in the
* verify execution itself never re-arms, keeping retries bounded to one
* per completion event.
* Arm the next bounded `verifyAsyncToolBarrier` re-check for a parent op whose
* resume attempt found it not yet resumable. Skipped for terminal states
* (nothing left to resume) and when the caller didn't opt in.
*
* Unlike the original single shot, the watchdog re-arms after each unsatisfied
* check: the verify handler re-enters here with `verifyAttempt + 1`, backing
* off exponentially up to {@link ASYNC_TOOL_VERIFY_MAX_ATTEMPTS}. A transient
* miss (read-replica lag, a sibling dying between backfill and resume) is thus
* retried instead of permanently stranding the parent. Once attempts are
* exhausted the chain stops and the `verify_exhausted` metric fires so the
* orphan is observable. For details see: async sub-agent suspend/resume stability hardening bounded watchdog retry with exponential backoff.
*/
private async maybeScheduleAsyncToolVerify(
parentOperationId: string,
state: AgentState,
options?: { scheduleVerifyOnHold?: boolean },
options?: { scheduleVerifyOnHold?: boolean; verifyAttempt?: number },
): Promise<void> {
if (!options?.scheduleVerifyOnHold || !this.queueService) return;
const status = state.status as string;
if (status === 'done' || status === 'error' || status === 'interrupted') return;
const attempt = options.verifyAttempt ?? 1;
if (attempt > ASYNC_TOOL_VERIFY_MAX_ATTEMPTS) {
// Bounded retries spent and the parent is still not resumable — give up
// re-arming and report so the stuck wait can be detected, not silently
// accumulated.
log(
'[%s] async-tool barrier verify exhausted after %d attempts, giving up (status: %s)',
parentOperationId,
ASYNC_TOOL_VERIFY_MAX_ATTEMPTS,
status,
);
asyncToolResumeCounter.add(1, { outcome: 'verify_exhausted' });
return;
}
const delay = asyncToolVerifyDelayMs(attempt);
log(
'[%s] scheduling async-tool barrier verify in %dms (status: %s)',
'[%s] scheduling async-tool barrier verify attempt %d/%d in %dms (status: %s)',
parentOperationId,
ASYNC_TOOL_VERIFY_DELAY_MS,
attempt,
ASYNC_TOOL_VERIFY_MAX_ATTEMPTS,
delay,
status,
);
try {
await this.queueService.scheduleMessage({
context: undefined,
delay: ASYNC_TOOL_VERIFY_DELAY_MS,
delay,
endpoint: `${this.baseURL}/run`,
operationId: parentOperationId,
payload: { verifyAsyncToolBarrier: true },
payload: { asyncToolVerifyAttempt: attempt, verifyAsyncToolBarrier: true },
priority: 'high',
stepIndex: state.stepCount,
});
@@ -1791,22 +1956,40 @@ export class AgentRuntimeService {
);
}
// 2. Barrier + CAS + resume the parent op (infra errors propagate too)
return this.tryResumeParentFromAsyncTool({ parentOperationId }, { scheduleVerifyOnHold: true });
// 2. Barrier + CAS + resume the parent op (infra errors propagate too).
// Pass the just-backfilled message id so the barrier trusts this write
// instead of re-reading a possibly-stale replica.
return this.tryResumeParentFromAsyncTool(
{ parentOperationId },
{ knownFulfilledMessageId: toolMessageId, scheduleVerifyOnHold: true },
);
}
/**
* Whether every pending tool call has a fulfilled tool_result message i.e.
* a tool message exists for its `tool_call_id` with non-empty content or a
* terminal pluginState. Looks up by `tool_call_id` (plugin id === message id).
*
* `knownFulfilledMessageId` short-circuits the per-tool content/state read for
* a placeholder the caller just backfilled in the same request: its terminal
* write is a local fact, so re-reading it (possibly from a lagging read
* replica) would only risk a false negative that strands the parent. The
* plugin row itself predates the park, so the `tool_call_id → plugin.id`
* lookup still resolves; only the freshly written content/state is trusted.
*/
private async allPendingToolsFulfilled(pending: ChatToolPayload[]): Promise<boolean> {
private async allPendingToolsFulfilled(
pending: ChatToolPayload[],
knownFulfilledMessageId?: string,
): Promise<boolean> {
for (const tc of pending) {
const plugin = await this.serverDB.query.messagePlugins.findFirst({
where: (mp, { eq }) => eq(mp.toolCallId, tc.id),
});
if (!plugin) return false;
// Trust the caller's own just-committed backfill (read-your-writes).
if (knownFulfilledMessageId && plugin.id === knownFulfilledMessageId) continue;
const message = await this.messageModel.findById(plugin.id);
const pluginState = plugin.state as { status?: string } | null;
const fulfilled =
@@ -1818,6 +2001,252 @@ export class AgentRuntimeService {
return true;
}
/**
* Resolve the resume disposition for a parked op from the disposition hint
* persisted on its first pending tool message's pluginState. Group
* orchestration stamps `onComplete: 'finish'` there for skipCallSupervisor /
* delegate; everything else (sub-agents, client tools) resolves to `resume`.
* Self-describing so the generic verify watchdog finishes the right ops.
*/
private async resolveAsyncToolOnComplete(
pending: ChatToolPayload[],
): Promise<GroupActionOnComplete> {
// A batched turn can park multiple deferred/client tools. If ANY of them is
// a group action requesting finish (skipCallSupervisor / delegate), the
// orchestration must finish — reading only pending[0] would miss a group
// finish call that isn't the first pending tool and wrongly resume.
for (const tool of pending) {
const plugin = await this.serverDB.query.messagePlugins.findFirst({
where: (mp, { eq }) => eq(mp.toolCallId, tool.id),
});
const pluginState = plugin?.state as { onComplete?: string } | null;
if (pluginState?.onComplete === 'finish') return 'finish';
}
return 'resume';
}
/**
* Count fulfilled member anchors under a group-management tool call child
* `role: 'tool'` messages whose content is non-empty or whose pluginState is
* terminal. The K=N member barrier for broadcast / executeAgentTasks: the
* group tool message is only backfilled (satisfying the parked op's
* single-tool barrier) once this reaches the expected member count.
*/
private async countFulfilledMemberAnchors(groupToolMessageId: string): Promise<number> {
const children = await this.serverDB.query.messages.findMany({
where: (m, { and, eq }) => and(eq(m.parentId, groupToolMessageId), eq(m.role, 'tool')),
});
let fulfilled = 0;
for (const child of children) {
if (child.content && child.content.length > 0) {
fulfilled += 1;
continue;
}
const plugin = await this.serverDB.query.messagePlugins.findFirst({
where: (mp, { eq }) => eq(mp.id, child.id),
});
const pluginState = plugin?.state as { status?: string } | null;
if (pluginState?.status === 'completed' || pluginState?.status === 'error') fulfilled += 1;
}
return fulfilled;
}
/**
* Completion bridge for the group orchestration "call agent member" path
* (`lobe-group-management`: speak / broadcast / delegate / executeAgentTask(s)).
* Mirrors {@link completeSubAgentBridge} but enforces a K=N member barrier:
*
* 1. Backfill this member's anchor tool message (in_group a short receipt,
* since the member already spoke in the shared group conversation;
* isolated the member's final answer from its hidden thread).
* 2. Multi-member actions: hold until every member anchor is fulfilled, then
* backfill the supervisor's group tool message so the parked op's
* single-tool barrier passes. Single-member actions collapse the anchor
* onto the group tool call, so step 1 already satisfies the barrier.
* 3. Barrier-check + CAS resume/finish the parked supervisor via
* `tryResumeParentFromAsyncTool` (finish disposition read from the group
* tool message's pluginState).
*
* THROWS on infra failure of any backfill so the queue-mode callback returns
* non-2xx and QStash redelivers backfills are idempotent and the resume is
* CAS-guarded, so redelivery is safe.
*/
async completeGroupActionMember(params: GroupActionMemberBridgeParams): Promise<boolean> {
const {
anchorMessageId,
expectedMembers,
groupToolMessageId,
mode,
operationId,
parentOperationId,
reason,
threadId,
} = params;
const failed = reason === 'error' || reason === 'interrupted' || reason === 'timeout';
const finalState =
params.finalState ?? (await this.coordinator.loadAgentState(operationId)) ?? undefined;
log(
'[%s] group-member bridge → parent %s (mode: %s, reason: %s, %d members)',
operationId,
parentOperationId,
mode,
reason,
expectedMembers,
);
// 1. Backfill this member's anchor.
const messages = Array.isArray(finalState?.messages) ? finalState.messages : [];
const lastAssistant = [...messages]
.reverse()
.find((m: { role?: string }) => m?.role === 'assistant');
const agentLabel = (finalState?.metadata?.agentId as string | undefined) ?? 'member';
const anchorContent = failed
? `Agent member did not complete (${reason}).`
: mode === 'in_group'
? `Agent ${agentLabel} responded in the group.`
: (lastAssistant?.content as string | undefined) ||
'Agent member completed without a textual answer.';
const anchorBackfill = await this.messageModel.updateToolMessage(anchorMessageId, {
content: anchorContent,
pluginError: failed ? formatErrorForMetadata(finalState?.error) : undefined,
pluginState: {
model: finalState?.modelRuntimeConfig?.model,
status: failed ? 'error' : 'completed',
threadId,
totalToolCalls: finalState?.usage?.tools?.totalCalls,
totalTokens: finalState?.usage?.llm?.tokens?.total,
},
});
if (!anchorBackfill.success) {
throw new Error(
`Group-member bridge: failed to backfill anchor ${anchorMessageId} for parent ${parentOperationId}`,
);
}
// 2. K=N member barrier (multi-member actions only — single-member actions
// use the group tool call itself as the anchor, already backfilled above).
if (expectedMembers > 1 && anchorMessageId !== groupToolMessageId) {
const fulfilled = await this.countFulfilledMemberAnchors(groupToolMessageId);
if (fulfilled < expectedMembers) {
log(
'[%s] group-member barrier %d/%d, holding parent %s',
operationId,
fulfilled,
expectedMembers,
parentOperationId,
);
const parentState = await this.coordinator.loadAgentState(parentOperationId);
if (parentState) {
await this.maybeScheduleAsyncToolVerify(parentOperationId, parentState, {
scheduleVerifyOnHold: true,
});
}
return false;
}
// All members done — backfill the group tool call so the parked op's
// single-tool barrier ([groupTool]) passes. Idempotent across racing
// last-committers; the resume/finish CAS guarantees one transition.
const groupBackfill = await this.messageModel.updateToolMessage(groupToolMessageId, {
content: `All ${expectedMembers} agent members completed.`,
pluginState: { expectedMembers, status: 'completed' },
});
if (!groupBackfill.success) {
throw new Error(
`Group-member bridge: failed to backfill group tool ${groupToolMessageId} for parent ${parentOperationId}`,
);
}
}
// 3. Barrier + CAS + resume/finish the parked supervisor op.
return this.tryResumeParentFromAsyncTool({ parentOperationId }, { scheduleVerifyOnHold: true });
}
/**
* Schedule the group-member timeout watchdog. Fired `delayMs` after the member
* op is forked; if the member hasn't finished by then, the watchdog interrupts
* it and bridges a `timeout` completion so the parked supervisor doesn't wait
* forever. No-op when the queue is disabled or the timeout is non-positive.
*/
async scheduleGroupMemberTimeout(
params: GroupMemberTimeoutParams,
delayMs: number,
): Promise<void> {
if (!this.queueService || !(delayMs > 0)) return;
try {
await this.queueService.scheduleMessage({
context: undefined,
delay: delayMs,
endpoint: `${this.baseURL}/run`,
// Keyed on the member op so the /run worker can resolve userId from its
// metadata, same trust chain as every other scheduled step.
operationId: params.memberOperationId,
payload: { groupMemberTimeout: params },
priority: 'normal',
stepIndex: 0,
});
log(
'[%s] scheduled group-member timeout in %dms (parent %s)',
params.memberOperationId,
delayMs,
params.parentOperationId,
);
} catch (error) {
log(
'[%s] failed to schedule group-member timeout (non-fatal): %O',
params.memberOperationId,
error,
);
}
}
/**
* Enforce a group member's timeout. No-op if the member already reached a
* terminal state (its own completion bridge handles that). Otherwise interrupt
* the member and bridge a `timeout` completion backfilling its anchor and
* resuming/finishing the parked supervisor via the K=N barrier. The member's
* own interrupt bridge may also fire; both are idempotent (anchor rewrite +
* CAS-guarded resume).
*/
private async handleGroupMemberTimeout(
params: GroupMemberTimeoutParams,
): Promise<AgentExecutionResult> {
const state = await this.coordinator.loadAgentState(params.memberOperationId);
const status = state?.status as string | undefined;
if (!state || status === 'done' || status === 'error' || status === 'interrupted') {
log(
'[%s] group-member timeout: member already terminal (%s), no-op',
params.memberOperationId,
status,
);
return { nextStepScheduled: false, state: {}, success: true };
}
log(
'[%s] group-member timeout fired, interrupting + bridging timeout to parent %s',
params.memberOperationId,
params.parentOperationId,
);
await this.interruptOperation(params.memberOperationId);
const resumed = await this.completeGroupActionMember({
anchorMessageId: params.anchorMessageId,
expectedMembers: params.expectedMembers,
finalState: state,
groupToolMessageId: params.groupToolMessageId,
mode: params.mode,
onComplete: params.onComplete,
operationId: params.memberOperationId,
parentOperationId: params.parentOperationId,
reason: 'timeout',
});
return { nextStepScheduled: resumed, state: {}, success: true };
}
/**
* Reload the conversation messages from the database and flatten them for the
* runtime. Used when resuming a parked op so the next LLM step sees tool
@@ -1951,6 +2380,7 @@ export class AgentRuntimeService {
evalContext: metadata?.evalContext,
execSubAgent: this.delegate.execSubAgent,
execVirtualSubAgent: this.delegate.execVirtualSubAgent,
execGroupMember: this.delegate.execGroupMember,
hookDispatcher,
loadAgentState: this.coordinator.loadAgentState.bind(this.coordinator),
messageModel: this.messageModel,
@@ -1974,34 +2404,6 @@ export class AgentRuntimeService {
return { agent, runtime };
}
/**
* Create default snapshot store based on environment.
* - ENABLE_AGENT_S3_TRACING=1 S3SnapshotStore
* - NODE_ENV=development FileSnapshotStore
* - Otherwise null (no tracing)
*/
private createDefaultSnapshotStore(): ISnapshotStore | null {
if (process.env.ENABLE_AGENT_S3_TRACING === '1') {
try {
const { S3SnapshotStore } = require('@/server/modules/AgentTracing');
return new S3SnapshotStore();
} catch {
// S3SnapshotStore not available
}
}
if (process.env.NODE_ENV === 'development') {
try {
const { FileSnapshotStore } = require('@lobechat/agent-tracing');
return new FileSnapshotStore();
} catch {
// agent-tracing not available
}
}
return null;
}
/**
* Compute device context from DB messages at step boundary.
* Uses findInMessages visitor to scan tool messages for device activation.
@@ -0,0 +1,71 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createDefaultSnapshotStore, shouldUseAgentS3Tracing } from '../snapshotStore';
const s3SnapshotStoreMock = vi.fn(() => ({ kind: 's3' }));
const fileSnapshotStoreMock = vi.fn(() => ({ kind: 'file' }));
const setEnv = (nodeEnv: string, agentS3Tracing?: string) => {
vi.stubEnv('NODE_ENV', nodeEnv);
vi.stubEnv('ENABLE_AGENT_S3_TRACING', agentS3Tracing);
};
const loadModule = vi.fn((moduleName: string) => {
if (moduleName === '@/server/modules/AgentTracing') {
return { S3SnapshotStore: s3SnapshotStoreMock };
}
if (moduleName === '@lobechat/agent-tracing') {
return { FileSnapshotStore: fileSnapshotStoreMock };
}
throw new Error(`Unexpected module: ${moduleName}`);
});
describe('agent runtime snapshot store defaults', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
it('enables S3 tracing by default in production when env is unset', () => {
setEnv('production');
expect(shouldUseAgentS3Tracing()).toBe(true);
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
});
it('uses the local file snapshot store in development when env is unset', () => {
setEnv('development');
expect(shouldUseAgentS3Tracing()).toBe(false);
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 'file' });
expect(loadModule).toHaveBeenCalledWith('@lobechat/agent-tracing');
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
expect(fileSnapshotStoreMock).toHaveBeenCalledTimes(1);
});
it('lets ENABLE_AGENT_S3_TRACING=1 force S3 tracing outside production', () => {
setEnv('development', '1');
expect(shouldUseAgentS3Tracing()).toBe(true);
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
});
it('lets an explicit ENABLE_AGENT_S3_TRACING value disable the production default', () => {
setEnv('production', '0');
expect(shouldUseAgentS3Tracing()).toBe(false);
expect(createDefaultSnapshotStore(loadModule)).toBeNull();
expect(loadModule).not.toHaveBeenCalled();
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,59 @@
import type { ISnapshotStore } from '@lobechat/agent-tracing';
const ENABLE_AGENT_S3_TRACING_VALUE = '1';
type SnapshotStoreConstructor = new () => ISnapshotStore;
type SnapshotStoreModuleLoader = (moduleName: string) => unknown;
interface FileSnapshotStoreModule {
FileSnapshotStore: SnapshotStoreConstructor;
}
interface S3SnapshotStoreModule {
S3SnapshotStore: SnapshotStoreConstructor;
}
const nodeRequire: SnapshotStoreModuleLoader = (moduleName) => require(moduleName);
export const shouldUseAgentS3Tracing = () => {
const explicitValue = process.env.ENABLE_AGENT_S3_TRACING;
if (explicitValue !== undefined) return explicitValue === ENABLE_AGENT_S3_TRACING_VALUE;
return process.env.NODE_ENV === 'production';
};
/**
* Create default snapshot store based on environment.
* - ENABLE_AGENT_S3_TRACING=1 -> S3SnapshotStore
* - NODE_ENV=production with ENABLE_AGENT_S3_TRACING unset -> S3SnapshotStore
* - NODE_ENV=development -> FileSnapshotStore
* - Otherwise -> null (no tracing)
*/
export const createDefaultSnapshotStore = (
loadModule: SnapshotStoreModuleLoader = nodeRequire,
): ISnapshotStore | null => {
if (shouldUseAgentS3Tracing()) {
try {
const { S3SnapshotStore } = loadModule(
'@/server/modules/AgentTracing',
) as S3SnapshotStoreModule;
return new S3SnapshotStore();
} catch {
// S3SnapshotStore not available
}
}
if (process.env.NODE_ENV === 'development') {
try {
const { FileSnapshotStore } = loadModule(
'@lobechat/agent-tracing',
) as FileSnapshotStoreModule;
return new FileSnapshotStore();
} catch {
// agent-tracing not available
}
}
return null;
};
+132 -7
View File
@@ -121,8 +121,30 @@ export type StepCompletionReason =
export interface AgentExecutionParams {
approvedToolCall?: any;
/**
* 1-based attempt number carried by a `verifyAsyncToolBarrier` re-check so the
* bounded watchdog can back off and stop after a fixed number of tries. Absent
* (treated as attempt 1) on the first re-check armed by a completion bridge.
*/
asyncToolVerifyAttempt?: number;
context?: AgentRuntimeContext;
externalRetryCount?: number;
/**
* Finish (rather than resume) a `waiting_for_async_tool` supervisor op after
* its group members have completed. Used by `skipCallSupervisor` / delegate in
* group orchestration: the orchestration ends without another supervisor LLM
* turn. Scheduled by the group-action member barrier via
* `tryResumeParentFromAsyncTool({ onComplete: 'finish' })`.
*/
finishAfterAsyncTool?: boolean;
/**
* Watchdog payload to enforce a group member's timeout: when the member op
* hasn't reached a terminal state by its deadline, interrupt it and bridge a
* `timeout` completion so the parked supervisor resumes/finishes instead of
* waiting forever. Scheduled by `scheduleGroupMemberTimeout` after the member
* op is forked.
*/
groupMemberTimeout?: GroupMemberTimeoutParams;
humanInput?: any;
operationId: string;
/**
@@ -144,10 +166,13 @@ export interface AgentExecutionParams {
/**
* Watchdog re-check for a parked `waiting_for_async_tool` op: re-runs the
* resume barrier + CAS without claiming the step lock or executing a step.
* A no-op when the op already resumed or the barrier is still unsatisfied.
* Scheduled one-shot by `tryResumeParentFromAsyncTool` when a sub-agent
* completion found the parent not yet resumable (covers the
* child-finishes-before-parent-parks race and transient barrier failures).
* A no-op when the op already resumed. While the barrier is still unsatisfied
* it re-arms the next check with exponential backoff (see
* `asyncToolVerifyAttempt`) up to a bounded number of attempts, so a transient
* miss is retried rather than permanently stranding the parent. First armed by
* `tryResumeParentFromAsyncTool` when a sub-agent completion found the parent
* not yet resumable (covers the child-finishes-before-parent-parks race and
* transient barrier failures).
*/
verifyAsyncToolBarrier?: boolean;
}
@@ -180,6 +205,106 @@ export interface SubAgentBridgeParams {
toolMessageId: string;
}
// ==================== Group Orchestration (call agent member) ====================
/** Whether a group member runs in the shared group session or an isolated thread. */
export type GroupActionMemberMode = 'in_group' | 'isolated';
/** Whether the supervisor resumes or finishes once all members complete. */
export type GroupActionOnComplete = 'resume' | 'finish';
/**
* Params for the group-action member completion bridge see
* `AgentRuntimeService.completeGroupActionMember`. Mirrors the sub-agent bridge
* but enforces a K=N member barrier: each member backfills its own anchor, and
* the supervisor's group tool message is only backfilled (which satisfies the
* parked op's barrier) once every member's anchor is fulfilled.
*/
export interface GroupActionMemberBridgeParams {
/**
* The per-member anchor `role: 'tool'` message to backfill. Equals
* `groupToolMessageId` when `expectedMembers === 1` (single-member actions
* collapse the anchor onto the group tool call itself).
*/
anchorMessageId: string;
/** Total members forked under this group tool call — the K=N barrier target. */
expectedMembers: number;
/** Child member op's final state — passed in local mode; loaded otherwise. */
finalState?: AgentState;
/** The supervisor's parked group-management tool message (`tool_call_id` = call id). */
groupToolMessageId: string;
/** in_group → backfill a short note; isolated → backfill the member's final answer. */
mode: GroupActionMemberMode;
/** Resume the supervisor LLM, or finish the orchestration (skipCallSupervisor/delegate). */
onComplete: GroupActionOnComplete;
/** Child (member) operation ID. */
operationId: string;
parentOperationId: string;
reason: string;
/** Isolation thread id (isolated mode only). */
threadId?: string;
}
/**
* Watchdog payload that enforces a group member's timeout. Scheduled after an
* isolated member op is forked; when it fires, if the member op hasn't reached a
* terminal state it is interrupted and a `timeout` completion is bridged so the
* parked supervisor resumes/finishes (satisfying the K=N barrier) instead of
* waiting indefinitely.
*/
export interface GroupMemberTimeoutParams {
anchorMessageId: string;
expectedMembers: number;
groupToolMessageId: string;
/** The forked member operation id whose deadline this enforces. */
memberOperationId: string;
mode: GroupActionMemberMode;
onComplete: GroupActionOnComplete;
parentOperationId: string;
}
/**
* Params handed to the {@link AgentRuntimeDelegate.execGroupMember} callback
* fork one group member (in-group or isolated) under a group-management tool
* call, installing the group-action member completion bridge.
*/
export interface ExecGroupMemberParams {
/** Member agent id. */
agentId: string;
/** Per-member anchor message id the bridge backfills. */
anchorMessageId: string;
/** Disable tools for this member (broadcast — voice opinions only). */
disableTools?: boolean;
/** K=N barrier target stored on the group tool message. */
expectedMembers: number;
/** Group id. */
groupId: string;
/** Supervisor's group-management tool message id (the parked tool call). */
groupToolMessageId: string;
/** Optional supervisor instruction guiding the member's response. */
instruction?: string;
/** in_group (non-isolated group session) or isolated (own thread). */
mode: GroupActionMemberMode;
/** Resume or finish the supervisor once all members complete. */
onComplete: GroupActionOnComplete;
/** Parent (supervisor) operation id. */
parentOperationId: string;
/** Per-member timeout (ms), isolated mode. */
timeout?: number;
/** Group topic id. */
topicId: string;
}
export interface ExecGroupMemberResult {
error?: string;
/** Forked member operation id (when started). */
operationId?: string;
/** Whether the member op was forked. */
started: boolean;
/** Isolation thread id (isolated mode only). */
threadId?: string;
}
export interface OperationCreationParams {
activeDeviceId?: string;
agentConfig?: any;
@@ -221,6 +346,9 @@ export interface OperationCreationParams {
deviceAccessPolicy?: { canUseDevice: boolean; reason: DeviceAccessReason };
/** Device system info for placeholder variable replacement in Local System systemRole */
deviceSystemInfo?: Record<string, string>;
/** Discord context for injecting channel/guild info into agent system message */
discordContext?: any;
evalContext?: any;
/**
* Resolved execution plan for the run (see `resolveExecutionPlan`).
* Forwarded into `state.metadata.executionPlan` so step-level layers (the
@@ -228,9 +356,6 @@ export interface OperationCreationParams {
* device capability from raw config.
*/
executionPlan?: ExecutionPlan;
/** Discord context for injecting channel/guild info into agent system message */
discordContext?: any;
evalContext?: any;
/**
* External lifecycle hooks
* Registered once, auto-adapt to local (in-memory) or production (webhook) mode
@@ -54,6 +54,17 @@ const MEMORY_WRITE_TARGET_BY_API_NAME: Record<string, { idKey: string; layer: La
[MemoryApiName.updateIdentityMemory]: { idKey: 'identityId', layer: LayersEnum.Identity },
};
const TOOL_NAME_SEPARATOR = '____';
const DEFAULT_MEMORY_TARGET_TITLE = 'Memory saved';
const getMemoryWriteApiNameFromToolName = (name: unknown) => {
const toolName = getString(name);
if (!toolName) return;
const slashIndex = toolName.indexOf('/');
const apiName = slashIndex >= 0 ? toolName.slice(slashIndex + 1) : toolName;
return MEMORY_WRITE_API_NAME_SET.has(apiName) ? apiName : undefined;
};
const hasSuccessfulMemoryWrite = (state: AgentState) => {
const byTool = state.usage?.tools?.byTool ?? [];
@@ -72,6 +83,19 @@ const hasFailedMemoryWrite = (state: AgentState) => {
);
};
const getSuccessfulMemoryWriteTargetConfig = (state: AgentState) => {
const byTool = state.usage?.tools?.byTool ?? [];
for (const entry of byTool) {
if (entry.calls <= entry.errors) continue;
const apiName = getMemoryWriteApiNameFromToolName(entry.name);
if (!apiName) continue;
return MEMORY_WRITE_TARGET_BY_API_NAME[apiName];
}
};
const getString = (value: unknown) => {
return pickTrimmedString(value);
};
@@ -257,6 +281,15 @@ export const resolveMemoryActionTargetFromState = (
if (target) return target;
}
}
const targetConfig = getSuccessfulMemoryWriteTargetConfig(state);
if (!targetConfig) return;
return {
memoryLayer: targetConfig.layer,
title: DEFAULT_MEMORY_TARGET_TITLE,
type: 'memory',
};
};
/**
@@ -1,3 +1,4 @@
import { LayersEnum } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import type { AgentSignalOperationMarker } from '@/server/services/agentSignal/operationMarker';
@@ -69,6 +70,42 @@ describe('buildSelfIterationReceipts', () => {
});
});
it('preserves structured memory target metadata for layer-specific navigation', () => {
const [, memory] = buildSelfIterationReceipts({
...baseInput,
mutations: [
{
apiName: 'writeMemory',
data: {
kind: 'mutation',
resourceId: 'pref_1',
status: 'applied',
summary: 'Use concise implementation notes.',
target: {
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
title: 'Prefers concise implementation notes',
type: 'memory',
},
},
kind: 'mutation',
toolCallId: 'call_pref',
},
],
});
expect(memory.title).toBe('Prefers concise implementation notes');
expect(memory.target).toEqual({
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
summary: 'Use concise implementation notes.',
title: 'Prefers concise implementation notes',
type: 'memory',
});
});
it('maps proposal creation to a proposed review receipt without a target', () => {
const [, proposal] = buildSelfIterationReceipts({
...baseInput,
@@ -1,5 +1,6 @@
// @vitest-environment node
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
import { LayersEnum } from '@lobechat/types';
import { describe, expect, it, vi } from 'vitest';
import { createCompletionPolicy } from '../../../../policies/completionPolicy';
@@ -149,7 +150,18 @@ describe('S2 completion loop (policy → handler → projection → persist)', (
mutations: [
{
apiName: 'writeMemory',
data: { resourceId: 'mem_1', status: 'applied', summary: 'Saved tone preference' },
data: {
resourceId: 'pref_1',
status: 'applied',
summary: 'Saved tone preference',
target: {
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
title: 'Tone preference',
type: 'memory',
},
},
kind: 'mutation',
},
],
@@ -169,7 +181,12 @@ describe('S2 completion loop (policy → handler → projection → persist)', (
expect(memory.anchorMessageId).toBe('assistant_msg_1');
expect(memory.triggerMessageId).toBe('user_msg_1');
expect(memory.topicId).toBe('topic_1');
expect(memory.target).toMatchObject({ id: 'mem_1', type: 'memory' });
expect(memory.target).toMatchObject({
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
type: 'memory',
});
});
it('no-ops when the completion carries no self-iteration payload (no marker stamped)', async () => {
@@ -1,4 +1,5 @@
import { MemoryApiName, MemoryIdentifier } from '@lobechat/builtin-tool-memory';
import { LayersEnum } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { extractSelfIterationCompletionPayload } from '../extractCompletionPayload';
@@ -57,7 +58,7 @@ describe('extractSelfIterationCompletionPayload', () => {
expect(result?.artifacts).toHaveLength(1);
});
it('synthesizes a writeMemory mutation for a memory-kind run from finalState usage', () => {
it('synthesizes a writeMemory mutation with a preference target for a memory-kind run', () => {
const result = extractSelfIterationCompletionPayload(
buildState(
{
@@ -66,6 +67,34 @@ describe('extractSelfIterationCompletionPayload', () => {
userId: 'user_1',
},
{
messages: [
{
id: 'msg_preference',
role: 'assistant',
tool_calls: [
{
function: {
arguments: JSON.stringify({
summary: 'Prefer direct implementation with focused tests.',
title: 'Prefers direct implementation',
withPreference: {
conclusionDirectives: 'Prefer direct implementation with focused tests.',
},
}),
name: `${MemoryIdentifier}____${MemoryApiName.addPreferenceMemory}`,
},
id: 'call_preference',
type: 'function',
},
],
},
{
content:
'Preference memory "Prefers direct implementation" saved with memoryId: "mem_1" and preferenceId: "pref_1"',
role: 'tool',
tool_call_id: 'call_preference',
},
],
status: 'finished',
usage: {
tools: {
@@ -87,6 +116,48 @@ describe('extractSelfIterationCompletionPayload', () => {
expect(result?.mutations).toHaveLength(1);
expect(result?.mutations[0].apiName).toBe('writeMemory');
expect((result?.mutations[0].data as { status?: string }).status).toBe('applied');
expect((result?.mutations[0].data as { resourceId?: string }).resourceId).toBe('pref_1');
expect((result?.mutations[0].data as { target?: Record<string, unknown> }).target).toEqual({
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
summary: 'Prefer direct implementation with focused tests.',
title: 'Prefers direct implementation',
type: 'memory',
});
});
it('falls back to the successful memory tool api when finalState lacks tool call details', () => {
const result = extractSelfIterationCompletionPayload(
buildState(
{
agentId: 'agent_user_1',
agentSignal: { kind: 'memory', sourceId: 'mem-src_fallback' },
userId: 'user_1',
},
{
status: 'finished',
usage: {
tools: {
byTool: [
{
calls: 1,
errors: 0,
name: `${MemoryIdentifier}/${MemoryApiName.addPreferenceMemory}`,
},
],
},
},
},
),
);
expect(result?.mutations).toHaveLength(1);
expect((result?.mutations[0].data as { target?: Record<string, unknown> }).target).toEqual({
memoryLayer: LayersEnum.Preference,
title: 'Memory saved',
type: 'memory',
});
});
it('yields no memory mutation when the memory run did not apply a write', () => {
@@ -1,3 +1,5 @@
import { LayersEnum } from '@lobechat/types';
import type { AgentSignalOperationMarker } from '@/server/services/agentSignal/operationMarker';
import type { AgentSignalReceipt } from '../../receiptService';
@@ -53,6 +55,9 @@ const str = (value: unknown): string | undefined =>
const isSkippedStatus = (status: unknown): boolean =>
typeof status === 'string' && status.startsWith('skipped');
const isMemoryLayer = (value: unknown): value is LayersEnum =>
Object.values(LayersEnum).includes(value as LayersEnum);
export interface BuildSelfIterationReceiptsInput {
agentId: string;
/** Non-actionable idea / intent recorder outputs (kind: artifact). */
@@ -151,9 +156,15 @@ export const buildSelfIterationReceipts = (
? 'skipped'
: (SUCCESS_STATUS_BY_API[apiName] ?? 'applied');
const target = isRecord(data.target) ? data.target : undefined;
const targetId = str(target?.id) ?? str(data.resourceId);
const memoryId = kind === 'memory' ? str(target?.memoryId) : undefined;
const memoryLayer =
kind === 'memory' && isMemoryLayer(target?.memoryLayer) ? target.memoryLayer : undefined;
const summaryText = str(data.summary);
const resourceId = str(data.resourceId);
const title = summaryText ?? DEFAULT_TITLE_BY_API[apiName] ?? 'Agent Signal action';
const targetTitle = str(target?.title);
const title =
targetTitle ?? summaryText ?? DEFAULT_TITLE_BY_API[apiName] ?? 'Agent Signal action';
return [
{
@@ -172,7 +183,9 @@ export const buildSelfIterationReceipts = (
? {}
: {
target: {
...(resourceId ? { id: resourceId } : {}),
...(targetId ? { id: targetId } : {}),
...(memoryId ? { memoryId } : {}),
...(memoryLayer ? { memoryLayer } : {}),
...(summaryText ? { summary: summaryText } : {}),
title,
type: kind,
@@ -49,9 +49,10 @@ const extractMemoryMutations = (finalState: AgentState): ToolResultWithKind[] =>
apiName: 'writeMemory',
data: {
kind: 'mutation',
...(result.target ? { target: result.target } : {}),
resourceId: result.target?.id ?? result.target?.memoryId,
status: 'applied',
summary: result.detail,
summary: result.detail ?? result.target?.summary,
},
kind: 'mutation',
},
@@ -141,9 +141,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -97,9 +97,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -101,9 +101,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -98,9 +98,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -7,7 +7,7 @@ const {
mockCreateOperation,
mockCreateServerAgentToolsEngine,
mockGetAgentConfig,
mockGetKlavisManifests,
mockGetComposioManifests,
mockGetLobehubSkillManifests,
mockMessageCreate,
mockPluginQuery,
@@ -18,7 +18,7 @@ const {
getEnabledPluginManifests: vi.fn().mockReturnValue(new Map()),
}),
mockGetAgentConfig: vi.fn(),
mockGetKlavisManifests: vi.fn().mockResolvedValue([]),
mockGetComposioManifests: vi.fn().mockResolvedValue([]),
mockGetLobehubSkillManifests: vi.fn().mockResolvedValue([]),
mockMessageCreate: vi.fn(),
mockPluginQuery: vi.fn().mockResolvedValue([]),
@@ -97,9 +97,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: mockGetKlavisManifests,
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: mockGetComposioManifests,
})),
}));
@@ -176,7 +176,7 @@ describe('AiAgentService.execAgent - disableTools', () => {
// Manifest fetches should NOT be called
expect(mockGetLobehubSkillManifests).not.toHaveBeenCalled();
expect(mockGetKlavisManifests).not.toHaveBeenCalled();
expect(mockGetComposioManifests).not.toHaveBeenCalled();
// ToolsEngine should NOT be created
expect(mockCreateServerAgentToolsEngine).not.toHaveBeenCalled();
@@ -196,7 +196,7 @@ describe('AiAgentService.execAgent - disableTools', () => {
// All tool discovery steps should be called
expect(mockPluginQuery).toHaveBeenCalledTimes(1);
expect(mockGetLobehubSkillManifests).toHaveBeenCalledTimes(1);
expect(mockGetKlavisManifests).toHaveBeenCalledTimes(1);
expect(mockGetComposioManifests).toHaveBeenCalledTimes(1);
expect(mockCreateServerAgentToolsEngine).toHaveBeenCalledTimes(1);
});
});
@@ -100,9 +100,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -424,8 +424,9 @@ describe('AiAgentService.execAgent - file upload handling', () => {
expect(mockCreateOperation).toHaveBeenCalled();
const userMessageCall = mockMessageCreate.mock.calls.find((call) => call[0].role === 'user');
// files array is empty since upload failed, so should be undefined-ish
expect(userMessageCall![0].files).toEqual([]);
// all uploads failed → no fileIds, normalized to undefined (no empty
// messagesFiles relation attached)
expect(userMessageCall![0].files).toBeUndefined();
});
});
@@ -68,9 +68,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -2,13 +2,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AiAgentService } from '../index';
const { mockMessageCreate, mockResolveAttachmentMetadata, mockSpawnHeteroSandbox } = vi.hoisted(
() => ({
mockMessageCreate: vi.fn(),
mockResolveAttachmentMetadata: vi.fn(),
mockSpawnHeteroSandbox: vi.fn().mockResolvedValue(undefined),
}),
);
const {
mockMessageCreate,
mockResolveAttachmentsByFileIds,
mockSpawnHeteroSandbox,
mockIngestAttachment,
} = vi.hoisted(() => ({
mockIngestAttachment: vi.fn(),
mockMessageCreate: vi.fn(),
mockResolveAttachmentsByFileIds: vi.fn(),
mockSpawnHeteroSandbox: vi.fn().mockResolvedValue(undefined),
}));
const emptyResolvedAttachments = {
fileList: [],
imageList: [],
orderedFileIds: [],
videoList: [],
warnings: [],
};
vi.mock('@/server/services/file', () => ({
FileService: vi.fn().mockImplementation(() => ({})),
}));
vi.mock('../ingestAttachment', () => ({
ingestAttachment: mockIngestAttachment,
}));
vi.mock('@/libs/trusted-client', () => ({
generateTrustedClientToken: vi.fn().mockReturnValue(undefined),
@@ -100,14 +120,13 @@ vi.mock('@/server/services/heterogeneousAgent/sandboxRunner', () => ({
}));
vi.mock('@/server/services/file/resolveAttachments', () => ({
resolveAttachmentMetadata: mockResolveAttachmentMetadata,
resolveAttachmentsByFileIds: vi.fn().mockResolvedValue({
fileList: [],
imageList: [],
orderedFileIds: [],
videoList: [],
warnings: [],
}),
resolveAttachmentsByFileIds: mockResolveAttachmentsByFileIds,
}));
vi.mock('@/server/services/document', () => ({
DocumentService: vi.fn().mockImplementation(() => ({
parseFile: vi.fn().mockResolvedValue({ content: '' }),
})),
}));
vi.mock('@/server/services/agentRuntime', () => ({
@@ -147,8 +166,9 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
topicMock.findById.mockResolvedValue(undefined);
topicMock.updateMetadata.mockResolvedValue(undefined);
mockMessageCreate.mockResolvedValue({ id: 'msg-1' });
mockResolveAttachmentMetadata.mockResolvedValue([]);
mockResolveAttachmentsByFileIds.mockResolvedValue({ ...emptyResolvedAttachments });
mockSpawnHeteroSandbox.mockResolvedValue(undefined);
mockIngestAttachment.mockReset();
service = new AiAgentService(mockDb, userId);
});
@@ -165,6 +185,11 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
// without `files`, so images attached in device mode were never linked
// via messagesFiles and disappeared after the optimistic message was
// replaced by the server snapshot.
mockResolveAttachmentsByFileIds.mockResolvedValue({
...emptyResolvedAttachments,
orderedFileIds: ['file-1', 'file-2'],
});
await service.execAgent({
agentId: 'agent-1',
fileIds: ['file-1', 'file-2'],
@@ -176,13 +201,23 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
expect(userCall![0].files).toEqual(['file-1', 'file-2']);
});
it('should dedupe repeated fileIds (messagesFiles PK is fileId+messageId)', async () => {
it('should attach the resolver-deduped fileIds (dedup lives in resolveAttachmentsByFileIds)', async () => {
// resolveAttachmentsByFileIds dedupes internally and returns orderedFileIds;
// execAgent attaches exactly what it returns (messagesFiles PK is fileId+messageId).
mockResolveAttachmentsByFileIds.mockResolvedValue({
...emptyResolvedAttachments,
orderedFileIds: ['file-1', 'file-2'],
});
await service.execAgent({
agentId: 'agent-1',
fileIds: ['file-1', 'file-1', 'file-2'],
prompt: 'Look at this image',
});
expect(mockResolveAttachmentsByFileIds).toHaveBeenCalledWith(
expect.objectContaining({ fileIds: ['file-1', 'file-1', 'file-2'] }),
);
const userCall = findUserMessageCreate();
expect(userCall![0].files).toEqual(['file-1', 'file-2']);
});
@@ -211,22 +246,21 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
describe('image delivery to the dispatched CLI', () => {
it('should resolve image attachments and pass imageList to the sandbox dispatch', async () => {
mockResolveAttachmentMetadata.mockResolvedValue([
{
fileType: 'image/png',
id: 'file-1',
name: 'screenshot.png',
size: 100,
url: 'https://signed/file-1.png',
},
{
fileType: 'application/pdf',
id: 'file-2',
name: 'doc.pdf',
size: 200,
url: 'https://signed/file-2.pdf',
},
]);
mockResolveAttachmentsByFileIds.mockResolvedValue({
...emptyResolvedAttachments,
fileList: [
{
content: '',
fileType: 'application/pdf',
id: 'file-2',
name: 'doc.pdf',
size: 200,
url: 'https://signed/file-2.pdf',
},
],
imageList: [{ alt: 'screenshot.png', id: 'file-1', url: 'https://signed/file-1.png' }],
orderedFileIds: ['file-1', 'file-2'],
});
await service.execAgent({
agentId: 'agent-1',
@@ -242,15 +276,20 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
});
it('should pass imageList undefined when attachments contain no images', async () => {
mockResolveAttachmentMetadata.mockResolvedValue([
{
fileType: 'application/pdf',
id: 'file-2',
name: 'doc.pdf',
size: 200,
url: 'https://signed/file-2.pdf',
},
]);
mockResolveAttachmentsByFileIds.mockResolvedValue({
...emptyResolvedAttachments,
fileList: [
{
content: '',
fileType: 'application/pdf',
id: 'file-2',
name: 'doc.pdf',
size: 200,
url: 'https://signed/file-2.pdf',
},
],
orderedFileIds: ['file-2'],
});
await service.execAgent({
agentId: 'agent-1',
@@ -264,7 +303,7 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
});
it('should not block the run when attachment resolution fails', async () => {
mockResolveAttachmentMetadata.mockRejectedValue(new Error('S3 down'));
mockResolveAttachmentsByFileIds.mockRejectedValue(new Error('S3 down'));
const result = await service.execAgent({
agentId: 'agent-1',
@@ -287,7 +326,90 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
prompt: 'No attachments here',
});
expect(mockResolveAttachmentMetadata).not.toHaveBeenCalled();
expect(mockResolveAttachmentsByFileIds).not.toHaveBeenCalled();
});
});
describe('raw bot/IM file ingestion (files param)', () => {
// regression: bot/IM channels deliver attachments as raw `files` buffers
// (not pre-uploaded `fileIds`). The hetero branch returns before the main
// ingestion block, so images sent through a bot were silently dropped and
// the CLI received text only.
it('should ingest raw files, attach them to the user message and forward images', async () => {
mockIngestAttachment.mockResolvedValue({
fileId: 'uploaded-1',
isImage: true,
isVideo: false,
resolvedUrl: 'https://signed/uploaded-1.png',
});
await service.execAgent({
agentId: 'agent-1',
files: [{ mimeType: 'image/png', name: 'shot.png', url: 'https://im/shot.png' }],
prompt: 'What is this image?',
});
expect(mockIngestAttachment).toHaveBeenCalledTimes(1);
const userCall = findUserMessageCreate();
expect(userCall![0].files).toEqual(['uploaded-1']);
expect(mockSpawnHeteroSandbox).toHaveBeenCalledWith(
expect.objectContaining({
imageList: [{ id: 'uploaded-1', url: 'https://signed/uploaded-1.png' }],
}),
);
});
it('should merge ingested files with pre-uploaded fileIds (both images forwarded)', async () => {
mockIngestAttachment.mockResolvedValue({
fileId: 'uploaded-1',
isImage: true,
isVideo: false,
resolvedUrl: 'https://signed/uploaded-1.png',
});
mockResolveAttachmentsByFileIds.mockResolvedValue({
...emptyResolvedAttachments,
imageList: [{ alt: 'pre.jpg', id: 'file-1', url: 'https://signed/file-1.jpg' }],
orderedFileIds: ['file-1'],
});
await service.execAgent({
agentId: 'agent-1',
fileIds: ['file-1'],
files: [{ mimeType: 'image/png', name: 'shot.png', url: 'https://im/shot.png' }],
prompt: 'Compare these images',
});
// Raw `files` are ingested first, then pre-uploaded `attachedFileIds`.
const userCall = findUserMessageCreate();
expect(userCall![0].files).toEqual(['uploaded-1', 'file-1']);
expect(mockSpawnHeteroSandbox).toHaveBeenCalledWith(
expect.objectContaining({
imageList: [
{ id: 'uploaded-1', url: 'https://signed/uploaded-1.png' },
{ id: 'file-1', url: 'https://signed/file-1.jpg' },
],
}),
);
});
it('should not block the run when a raw file fails to ingest', async () => {
mockIngestAttachment.mockRejectedValue(new Error('S3 down'));
const result = await service.execAgent({
agentId: 'agent-1',
files: [{ mimeType: 'image/png', name: 'shot.png', url: 'https://im/shot.png' }],
prompt: 'What is this image?',
});
expect(result.success).toBe(true);
const userCall = findUserMessageCreate();
expect(userCall![0].files).toBeUndefined();
expect(mockSpawnHeteroSandbox).toHaveBeenCalledWith(
expect.objectContaining({ imageList: undefined }),
);
});
});
});
@@ -68,9 +68,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -91,9 +91,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -101,9 +101,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -100,10 +100,10 @@ vi.mock('@/server/services/market', () => ({
})),
}));
// Mock KlavisService (for getKlavisManifests)
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
// Mock ComposioService (for getComposioManifests)
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -90,9 +90,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -87,10 +87,10 @@ vi.mock('@/server/services/market', () => ({
})),
}));
// Mock KlavisService
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
// Mock ComposioService
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
})),
}));
File diff suppressed because it is too large Load Diff
@@ -23,6 +23,7 @@ import type {
DeviceGitWorkingTreeFiles,
DeviceGitWorkingTreePatches,
DeviceGitWorkingTreeStatus,
DeviceGitWorktreeListItem,
DeviceListProjectSkillsResult,
DeviceLocalFilePreviewResult,
DeviceProjectFileIndexResult,
@@ -207,6 +208,13 @@ export class DeviceGateway {
});
}
/** Git worktrees attached to the same repository as a directory on a remote device. */
listGitWorktrees(params: { deviceId: string; path: string; userId: string }) {
return this.invokeGitRead<DeviceGitWorktreeListItem[]>('listGitWorktrees', params, {
path: params.path,
});
}
/**
* List the local branches of a directory on a remote device via the
* `listGitBranches` device RPC, so the web/remote branch switcher can populate
+14 -14
View File
@@ -1,10 +1,10 @@
import {
COMPOSIO_APP_TYPES,
CURRENT_VERSION,
DEFAULT_DISCOVER_ASSISTANT_ITEM,
DEFAULT_DISCOVER_PLUGIN_ITEM,
DEFAULT_DISCOVER_PROVIDER_ITEM,
isDesktop,
KLAVIS_SERVER_TYPES,
} from '@lobechat/const';
import {
type AgentStatus,
@@ -1165,29 +1165,29 @@ export class DiscoverService {
return plugin;
}
// Step 4: Try to find in Klavis server types (builtin tools that require env config)
const klavisTool = KLAVIS_SERVER_TYPES.find((tool) => tool.identifier === identifier);
if (klavisTool) {
log('getPluginDetail: found Klavis tool for identifier=%s', identifier);
// Step 4: Try to find in Composio server types (builtin tools that require env config)
const composioTool = COMPOSIO_APP_TYPES.find((tool) => tool.identifier === identifier);
if (composioTool) {
log('getPluginDetail: found Composio tool for identifier=%s', identifier);
// Avatar is empty here because frontend will render Klavis icons using KlavisIcon component
// Avatar is empty here because frontend will render Composio icons using ComposioIcon component
// which handles both string URLs and React component icons
const plugin: DiscoverPluginDetail = {
author: 'Klavis',
avatar: typeof klavisTool.icon === 'string' ? klavisTool.icon : '',
author: 'Composio',
avatar: typeof composioTool.icon === 'string' ? composioTool.icon : '',
category: undefined,
createdAt: '',
description: `LobeHub Mcp Server: ${klavisTool.label}`,
homepage: 'https://klavis.ai',
identifier: klavisTool.identifier,
description: `LobeHub Mcp Server: ${composioTool.label}`,
homepage: 'https://composio.dev',
identifier: composioTool.identifier,
manifest: undefined,
related: [],
schemaVersion: 1,
source: 'builtin',
tags: ['klavis', 'mcp'],
title: klavisTool.label,
tags: ['composio', 'mcp'],
title: composioTool.label,
};
log('getPluginDetail: returning Klavis tool plugin');
log('getPluginDetail: returning Composio tool plugin');
return plugin;
}
@@ -115,6 +115,14 @@ interface OperationState {
main: MainAgentRunState;
operationId: string;
processedKeys: Set<string>;
/**
* The operation's seeded placeholder assistant (the row `execAgent` creates
* before the first ingest). Immutable for the run's lifetime. Used as the
* `createdAt` floor when anchoring the chain to the run's real last tool
* a topic runs at most one operation at a time, so "tool messages on/after
* the seed" scopes to THIS run without a recursive parent walk.
*/
seedAssistantMessageId: string;
/**
* Run-global DB index for every tool message in the topic, keyed by
* `tool_call_id`. Main and subagent reducers keep only their per-turn maps;
@@ -396,6 +404,7 @@ export class HeterogeneousPersistenceHandler {
main: createMainAgentRunState(currentAssistantMessageId),
operationId,
processedKeys: new Set(),
seedAssistantMessageId: baseAssistantMessageId,
toolMsgIdByCallId: new Map(),
topicId,
};
@@ -502,6 +511,14 @@ export class HeterogeneousPersistenceHandler {
const currentMsg = await this.deps.messageModel.findById(state.main.currentAssistantId);
const snapshot = this.toAssistantSnapshot(currentMsg);
// Recover the in-flight turn's CC message.id so a replayed `newStep` (cold
// replica retry) is recognized as the SAME turn — no duplicate assistant,
// no usage-only empty shell. Mirrors the subagent path's recovery of
// `currentSubagentMessageId` from `metadata.subagentMessageId`.
if (typeof snapshot.metadata.mainMessageId === 'string') {
state.main.currentMainMessageId = snapshot.metadata.mainMessageId;
}
if (snapshot.textSnapshotSeq > state.main.lastTextSnapshotSeq) {
state.main.accContent = snapshot.content;
state.main.lastTextSnapshotSeq = snapshot.textSnapshotSeq;
@@ -536,11 +553,23 @@ export class HeterogeneousPersistenceHandler {
if (snapshot.model) state.main.turnModel = snapshot.model;
if (snapshot.provider) state.main.turnProvider = snapshot.provider;
// Prefer the authoritative child tool row over the assistant.tools[] JSONB
// mirror. During multi-tool batches, an earlier tool may already have
// result_msg_id backfilled while a later tool row exists but Phase 3 has not
// rewritten the JSONB payload yet; anchoring from the snapshot would pick
// the earlier tool and fork the main wire.
// Anchor the chain to the RUN's real latest main-thread tool message, read
// straight from the DB and independent of `currentAssistantId`. The latter
// can regress to the seeded placeholder on a cold / non-sticky replica
// (see the multi-replica caveat on the class) when `heteroCurrentMsgId` is
// not yet bound to this operation: anchoring off its child tools would then
// collapse onto the run's FIRST tool, and every later step opens off that
// same node — forking the wire into orphan siblings. Ordering by createdAt
// 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.
const runLastToolId = await this.getLastRunToolMessageId(state);
if (runLastToolId) {
state.main.lastToolMsgIdEver = runLastToolId;
return;
}
// No tool persisted in this run yet — fall back to the per-assistant lookups
// so the very first turn still chains correctly before any tool exists.
const currentTurnToolId =
(await this.getLastChildToolMessageId(state.main.currentAssistantId)) ??
this.getLastSnapshotToolMessageId(snapshot, state.toolMsgIdByCallId);
@@ -555,6 +584,20 @@ export class HeterogeneousPersistenceHandler {
}
}
/**
* Latest main-thread tool message created on/after the run's seed assistant.
* Scopes to the current operation via the seed's `createdAt` floor without a
* recursive walk, and stays correct even when `currentAssistantId` has
* regressed on a cold replica. Optional on the model so test mocks that don't
* implement it transparently fall back to the per-assistant anchors.
*/
private async getLastRunToolMessageId(state: OperationState): Promise<string | undefined> {
return await this.deps.messageModel.getLastMainThreadToolMessageIdSince?.(
state.topicId,
state.seedAssistantMessageId,
);
}
/**
* Rebuild the in-flight subagent runs (`state.main.subagents`) from DB.
*
@@ -567,9 +610,16 @@ export class HeterogeneousPersistenceHandler {
*
* Merge semantics: only runs MISSING from the in-memory map are rehydrated, so
* a warm replica's live per-turn accumulators (`accContent`, current
* `toolState`) are never clobbered by the DB projection. Finalized runs are
* excluded (their thread is `Active`, not `Processing`), so a completed spawn
* is never resurrected.
* `toolState`) are never clobbered by the DB projection.
*
* Finalized (`Active`) spawns are NOT rehydrated as live runs (a completed
* spawn is never resurrected that would mint spurious empty assistants and
* re-finalize churn), but their `sourceToolCallId` IS recorded in
* `finalizedParents` so a REPLAYED first-event on a cold replica can't fork a
* duplicate thread for a spawn that already finished (the "
* thread" bug). This mirrors #15838's main-turn idempotency for the subagent
* thread-create step: dedup keyed by the DB-homed `sourceToolCallId`,
* independent of in-memory state and of thread status.
*
* Best-effort: any DB hiccup (or a partial test mock without the query
* methods) leaves `state.main.subagents` untouched rather than aborting the
@@ -580,12 +630,13 @@ export class HeterogeneousPersistenceHandler {
const threads = await this.deps.threadModel.queryByTopicId(state.topicId);
const existing = state.main.subagents.runs;
const snapshots: SubagentRunSnapshot[] = [];
// Union with any parents finalized in-memory on a warm replica.
const finalizedParents = new Set(state.main.subagents.finalizedParents);
for (const thread of threads ?? []) {
if (thread.type !== ThreadType.Isolation) continue;
if (thread.status !== ThreadStatus.Processing) continue;
const meta = thread.metadata as { operationId?: string; sourceToolCallId?: string } | null;
// Operation-scoped: only rehydrate threads THIS operation created.
// Operation-scoped: only attend to threads THIS operation created.
// Topics are reused across turns, so a prior run that crashed / was
// cancelled without an ingested terminal event can leave its subagent
// thread stuck in `Processing`. Without this guard the next operation
@@ -597,6 +648,13 @@ export class HeterogeneousPersistenceHandler {
const parentToolCallId = meta?.sourceToolCallId;
if (!parentToolCallId || existing.has(parentToolCallId)) continue;
// Finalized spawn → remember the key (blocks duplicate create), don't
// rehydrate it as a live run.
if (thread.status !== ThreadStatus.Processing) {
finalizedParents.add(parentToolCallId);
continue;
}
const messages = await this.deps.messageModel.query({
threadId: thread.id,
topicId: state.topicId,
@@ -605,11 +663,20 @@ export class HeterogeneousPersistenceHandler {
if (snapshot) snapshots.push(snapshot);
}
if (snapshots.length === 0) return;
// Nothing new to project: no rehydratable runs AND no finalized keys
// beyond what memory already tracked (the set started as a copy of it and
// only grows, so an unchanged size means no new Active threads were found).
if (
snapshots.length === 0 &&
finalizedParents.size === state.main.subagents.finalizedParents.size
) {
return;
}
// Union: rehydrated (missing) runs + the in-memory ones (which win, since
// they carry live accumulators the DB hasn't caught up to yet).
const merged = rehydrateSubagentRunsState(snapshots);
// they carry live accumulators the DB hasn't caught up to yet) + the
// finalized-parent guard set.
const merged = rehydrateSubagentRunsState(snapshots, [...finalizedParents]);
for (const [parentToolCallId, run] of existing) merged.runs.set(parentToolCallId, run);
state.main = { ...state.main, subagents: merged };
} catch (err) {
@@ -626,7 +693,13 @@ export class HeterogeneousPersistenceHandler {
private buildSubagentSnapshot(
parentToolCallId: string,
threadId: string,
messages: Array<{ id: string; parentId?: string | null; role: string; tool_call_id?: string }>,
messages: Array<{
id: string;
metadata?: Record<string, any> | null;
parentId?: string | null;
role: string;
tool_call_id?: string;
}>,
): SubagentRunSnapshot | undefined {
const assistants = messages.filter((m) => m.role === 'assistant');
const currentAssistant = assistants.at(-1);
@@ -635,9 +708,16 @@ export class HeterogeneousPersistenceHandler {
const toolRows = messages.filter((m) => m.role === 'tool' && m.tool_call_id);
const childTools = toolRows.filter((m) => m.parentId === currentAssistant.id);
const lastChainParentId = childTools.at(-1)?.id ?? currentAssistant.id;
// Recover the in-flight turn's CC message.id so a continuation event is
// recognized as the SAME turn (no spurious boundary → no fragmentation).
const currentSubagentMessageId =
typeof currentAssistant.metadata?.subagentMessageId === 'string'
? currentAssistant.metadata.subagentMessageId
: undefined;
return {
currentAssistantId: currentAssistant.id,
currentSubagentMessageId,
lastChainParentId,
lifetimeToolCallIds: toolRows.map((m) => m.tool_call_id!),
parentToolCallId,
@@ -727,11 +807,17 @@ export class HeterogeneousPersistenceHandler {
private async applyMainIntent(state: OperationState, intent: MainAgentIntent) {
switch (intent.kind) {
case 'createAssistant': {
const createMetadata: Record<string, any> = {};
if (intent.signal) createMetadata.signal = intent.signal;
// Persist the turn's CC message.id so a cold replica can recover
// `currentMainMessageId` (via refreshMainStateFromDb) and dedupe a
// replayed `newStep` instead of forking a duplicate + empty shell.
if (intent.mainMessageId) createMetadata.mainMessageId = intent.mainMessageId;
await this.deps.messageModel.create(
{
agentId: intent.agentId ?? undefined,
content: '',
...(intent.signal ? { metadata: { signal: intent.signal } } : {}),
...(Object.keys(createMetadata).length > 0 ? { metadata: createMetadata } : {}),
model: intent.model,
parentId: intent.parentId,
provider: intent.provider,
@@ -962,11 +1048,18 @@ export class HeterogeneousPersistenceHandler {
{
agentId: intent.agentId ?? undefined,
content: intent.content,
// Persist the turn's CC message.id so a cold replica can recover
// `currentSubagentMessageId` (via buildSubagentSnapshot) and avoid
// a spurious turn boundary that fragments one CC turn into multiple
// in-thread assistant rows + empty shells.
...(intent.subagentMessageId
? { metadata: { subagentMessageId: intent.subagentMessageId } }
: {}),
parentId: intent.parentId,
role: intent.role,
threadId: intent.threadId,
topicId: intent.topicId ?? state.topicId,
},
} as any,
intent.messageId,
);
return;
@@ -0,0 +1,226 @@
// @vitest-environment node
import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
__resetOperationStatesForTesting,
HeterogeneousPersistenceHandler,
} from '../HeterogeneousPersistenceHandler';
/**
* Regression for the remote-device chain-fork (observed on tpc_3DKmFfAmx9YA):
* several CONSECUTIVE, DISTINCT main-agent steps all parented onto the run's
* FIRST tool message instead of chaining linearly.
*
* Root cause: `refreshMainStateFromDb` used to anchor `lastToolMsgIdEver` off
* `getLastChildToolMessageId(currentAssistantId)`. On a non-sticky / cold
* replica (a WS reconnect storm spreads one run's batches across replicas),
* `currentAssistantId` regresses to the operation's seeded placeholder when the
* `heteroCurrentMsgId` pointer is not yet visible. The anchor then collapses to
* the SEED's first child tool, and every later `newStep` opens off that same
* node orphan sibling forks.
*
* The fix anchors the chain to the RUN's real latest main-thread tool, read
* from the DB and ordered by createdAt, independent of `currentAssistantId`.
*
* This harness models the precondition deterministically: `updateMetadata`
* never persists `heteroCurrentMsgId`, so every cold load regresses
* `currentAssistantId` to the seed exactly the cross-replica window.
*/
interface FakeMessage {
content: string;
id: string;
parentId?: string | null;
role: 'user' | 'assistant' | 'tool';
seq: number;
threadId?: string | null;
tool_call_id?: string;
tools?: any[];
topicId: string | null;
}
const SEED = 'asst-seed';
const T1 = 'tool-1'; // the run's first-turn tool, a child of the seed assistant
const OP = 'op-1';
const TOPIC = 'topic-1';
const createHarness = () => {
let seq = 0;
const messages = new Map<string, FakeMessage>();
// No `heteroCurrentMsgId` — and updateMetadata below refuses to persist it —
// so loadOrCreateState always falls back to runningOperation.assistantMessageId.
let topicMetadata: Record<string, any> = {
runningOperation: { assistantMessageId: SEED, operationId: OP },
};
messages.set(SEED, {
content: 'first turn answer',
id: SEED,
role: 'assistant',
seq: seq++,
topicId: TOPIC,
});
messages.set(T1, {
content: '',
id: T1,
parentId: SEED,
role: 'tool',
seq: seq++,
threadId: null,
tool_call_id: 'tc-0',
topicId: TOPIC,
});
const messageModel = {
create: vi.fn(async (input: Partial<FakeMessage>, id?: string) => {
const msgId = id ?? `msg_${seq}`;
const msg: FakeMessage = {
content: input.content ?? '',
id: msgId,
parentId: input.parentId ?? null,
role: input.role!,
seq: seq++,
threadId: input.threadId ?? null,
tool_call_id: input.tool_call_id,
tools: input.tools,
topicId: input.topicId ?? null,
};
messages.set(msgId, msg);
return msg;
}),
update: vi.fn(async (id: string, patch: Partial<FakeMessage>) => {
const existing = messages.get(id);
if (!existing) return { success: false };
messages.set(id, { ...existing, ...patch });
return { success: true };
}),
updateToolMessage: vi.fn(async () => ({ success: true })),
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
query: vi.fn(async (params: { threadId?: string; topicId?: string }) => {
if (params?.threadId)
return [...messages.values()].filter((m) => m.threadId === params.threadId);
return [...messages.values()].filter((m) => !m.threadId && m.topicId === params?.topicId);
}),
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
const match = [...messages.values()]
.filter((m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId)
.sort((a, b) => b.seq - a.seq)[0];
return match?.id;
}),
getLastMainThreadToolMessageIdSince: vi.fn(async (topicId: string, sinceMessageId: string) => {
const seed = messages.get(sinceMessageId);
if (!seed) return undefined;
const match = [...messages.values()]
.filter(
(m) => m.topicId === topicId && m.role === 'tool' && !m.threadId && m.seq >= seed.seq,
)
.sort((a, b) => b.seq - a.seq)[0];
return match?.id;
}),
listMessagePluginsByTopic: vi.fn(async () =>
[...messages.values()]
.filter((m) => m.role === 'tool' && m.tool_call_id)
.map((m) => ({ id: m.id, toolCallId: m.tool_call_id! })),
),
};
const topicModel = {
findById: vi.fn(async (id: string) =>
id === TOPIC ? { agentId: null, id, metadata: topicMetadata } : null,
),
updateMetadata: vi.fn(async (_id: string, patch: Record<string, any>) => {
// Drop heteroCurrentMsgId to model a cold replica that never sees the
// current-assistant pointer written by a concurrent replica.
const { heteroCurrentMsgId: _drop, ...rest } = patch;
topicMetadata = { ...topicMetadata, ...rest };
}),
};
const threadModel = {
create: vi.fn(async () => {}),
findById: vi.fn(async () => null),
queryByTopicId: vi.fn(async () => []),
update: vi.fn(async () => {}),
};
const handler = new HeterogeneousPersistenceHandler({
messageModel: messageModel as any,
threadModel: threadModel as any,
topicModel: topicModel as any,
});
return { handler, messages };
};
const buildEvent = (
type: AgentStreamEvent['type'],
stepIndex: number,
data: Record<string, unknown>,
): AgentStreamEvent => ({
data,
operationId: OP,
stepIndex,
timestamp: 1_700_000_000_000 + stepIndex,
type,
});
const stepBatch = (stepIndex: number, ccMsgId: string, toolCallId: string): AgentStreamEvent[] => [
buildEvent('stream_start', stepIndex, {
messageId: ccMsgId,
newStep: true,
provider: 'claude-code',
}),
buildEvent('stream_chunk', stepIndex, {
chunkType: 'tools_calling',
toolsCalling: [
{ apiName: 'Bash', arguments: '{}', id: toolCallId, identifier: 'bash', type: 'default' },
],
}),
];
describe('HeterogeneousPersistenceHandler — chain anchor survives a regressed currentAssistantId', () => {
beforeEach(() => __resetOperationStatesForTesting());
afterEach(() => __resetOperationStatesForTesting());
it('chains consecutive cold-replica steps off the run last tool, not the seed first tool', async () => {
const h = createHarness();
// Step 1 on a cold replica (currentAssistantId regresses to SEED).
await h.handler.ingest({
assistantMessageId: SEED,
events: stepBatch(1, 'cc-A', 'tc-A'),
operationId: OP,
topicId: TOPIC,
});
__resetOperationStatesForTesting();
// Step 2 on ANOTHER cold replica (currentAssistantId regresses to SEED again).
await h.handler.ingest({
assistantMessageId: SEED,
events: stepBatch(2, 'cc-B', 'tc-B'),
operationId: OP,
topicId: TOPIC,
});
const assistants = [...h.messages.values()].filter(
(m) => m.role === 'assistant' && m.id !== SEED,
);
expect(assistants).toHaveLength(2);
const [a1, a2] = assistants.sort((x, y) => x.seq - y.seq);
const toolA = [...h.messages.values()].find((m) => m.tool_call_id === 'tc-A')!;
// First step still chains off the run's only existing tool (T1, seed's child).
expect(a1.parentId).toBe(T1);
// Second step must chain off step 1's tool — NOT collapse back onto T1.
expect(a2.parentId).toBe(toolA.id);
expect(a2.parentId).not.toBe(T1);
// No fork: T1 has exactly one assistant child across the whole run.
const t1AssistantChildren = [...h.messages.values()].filter(
(m) => m.role === 'assistant' && m.parentId === T1,
);
expect(t1AssistantChildren).toHaveLength(1);
});
});
@@ -0,0 +1,261 @@
// @vitest-environment node
import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
__resetOperationStatesForTesting,
HeterogeneousPersistenceHandler,
} from '../HeterogeneousPersistenceHandler';
/**
* Regression for the MAIN-chain analog of #15808 (which only fixed the SUBAGENT
* coordinator).
*
* The main-agent reducer (`packages/heterogeneous-agents/src/mainAgentCoordinator`)
* cuts a turn purely on the adapter's `stream_start { newStep: true }` signal
* it tracks NO CC `message.id` and `openTurn` mints a fresh random assistant id
* via `ctx.newId('message')`. So unlike the subagent path (which now persists the
* turn's CC message.id on `metadata.subagentMessageId` and dedupes a replayed
* turn), the main chain has NO DB-homed idempotency key for a turn.
*
* The serverless failure mode:
* - `processedKeys` (the per-event dedupe set) lives ONLY in the in-memory
* `operationStates` map. On a cold replica it is empty.
* - The ingest contract (see `ingest()` doc) is: a handler that throws leaves
* its event unmarked, the throw bubbles to the producer, and the producer
* re-sends the WHOLE batch. Already-applied events are skipped "via the
* dedupe map" but that map is in-memory, so on a cold replica retry every
* event (including the `newStep`) is reprocessed.
* - Reprocessing `newStep` re-runs `openTurn`, which mints a SECOND assistant.
* The first one (created before the throw, already carrying the turn's usage
* but no flushed content) is orphaned as an empty shell content empty,
* tools 0, usage present. Exactly the "空壳条" in the reported triad.
*
* This test simulates a mid-batch DB failure on replica A, then a cold replica
* (`__resetOperationStatesForTesting()`) processing the producer's resend.
*/
interface FakeMessage {
agentId: string | null;
content: string;
id: string;
metadata?: any;
model?: string;
parentId?: string | null;
plugin?: any;
reasoning?: any;
role: 'user' | 'assistant' | 'tool' | 'task' | 'system';
threadId?: string | null;
tool_call_id?: string;
tools?: any[];
topicId: string | null;
}
const SEED = 'asst-seed';
const OP = 'op-1';
const TOPIC = 'topic-1';
const createHarness = () => {
let nextMsgIdSeq = 0;
const messages = new Map<string, FakeMessage>();
// Faithful topic-metadata store: the real TopicModel.updateMetadata DEEP-MERGES
// into the JSONB column. The main-chain cold-replica recovery reads
// `heteroCurrentMsgId` from here, so a no-op mock (as in the subagent test)
// would not exercise the path under test.
let topicMetadata: Record<string, any> = {
runningOperation: { assistantMessageId: SEED, operationId: OP },
};
// Trip a single mid-batch DB failure: the Nth `messageModel.update` throws once.
let updateCalls = 0;
let failUpdateAtCall = -1;
// Seed the run's first-turn assistant (already has content, like a real run
// where `newStep` opens the SECOND turn).
messages.set(SEED, {
agentId: null,
content: 'first turn answer',
id: SEED,
role: 'assistant',
topicId: TOPIC,
});
const messageModel = {
create: vi.fn(async (input: Partial<FakeMessage>, id?: string) => {
nextMsgIdSeq += 1;
const msgId = id ?? `msg_${nextMsgIdSeq}`;
const msg = {
agentId: input.agentId ?? null,
content: input.content ?? '',
id: msgId,
metadata: input.metadata,
model: input.model,
parentId: input.parentId ?? null,
plugin: input.plugin,
reasoning: input.reasoning,
role: input.role!,
threadId: input.threadId ?? null,
tool_call_id: input.tool_call_id,
tools: input.tools,
topicId: input.topicId ?? null,
} as FakeMessage;
messages.set(msgId, msg);
return msg;
}),
update: vi.fn(async (id: string, patch: Partial<FakeMessage>) => {
updateCalls += 1;
if (updateCalls === failUpdateAtCall) {
throw new Error('simulated mid-batch DB failure');
}
const existing = messages.get(id);
if (!existing) return { success: false };
const next = { ...existing, ...patch };
if (patch.metadata && existing.metadata) {
next.metadata = { ...existing.metadata, ...patch.metadata };
}
messages.set(id, next);
return { success: true };
}),
updateToolMessage: vi.fn(async (id: string, patch: any) => {
const existing = messages.get(id);
if (!existing) return { success: false };
messages.set(id, { ...existing, content: patch.content ?? existing.content });
return { success: true };
}),
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
query: vi.fn(async (params: { threadId?: string; topicId?: string }) => {
if (params?.threadId)
return [...messages.values()].filter((m) => m.threadId === params.threadId);
return [...messages.values()].filter((m) => !m.threadId && m.topicId === params?.topicId);
}),
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
const match = [...messages.values()].findLast(
(m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId,
);
return match?.id;
}),
listMessagePluginsByTopic: vi.fn(async () =>
[...messages.values()]
.filter((m) => m.role === 'tool' && m.tool_call_id)
.map((m) => ({ id: m.id, toolCallId: m.tool_call_id! })),
),
};
const topicModel = {
findById: vi.fn(async (id: string) => {
if (id !== TOPIC) return null;
return { agentId: null, id, metadata: topicMetadata };
}),
updateMetadata: vi.fn(async (_id: string, patch: Record<string, any>) => {
// Deep-merge top-level keys, matching the real model.
topicMetadata = { ...topicMetadata, ...patch };
}),
};
const threadModel = {
create: vi.fn(async () => {}),
findById: vi.fn(async () => null),
queryByTopicId: vi.fn(async () => []),
update: vi.fn(async () => {}),
};
const handler = new HeterogeneousPersistenceHandler({
messageModel: messageModel as any,
threadModel: threadModel as any,
topicModel: topicModel as any,
});
return {
handler,
messages,
setFailUpdateAtCall: (n: number) => {
failUpdateAtCall = n;
},
};
};
const buildEvent = (
type: AgentStreamEvent['type'],
stepIndex: number,
data: Record<string, unknown>,
): AgentStreamEvent => ({
data,
operationId: OP,
stepIndex,
timestamp: 1_700_000_000_000 + stepIndex,
type,
});
describe('HeterogeneousPersistenceHandler — main turn survives a cold-replica retry', () => {
beforeEach(() => __resetOperationStatesForTesting());
afterEach(() => __resetOperationStatesForTesting());
it('does NOT fork one main turn into a duplicate + empty shell when a batch is retried on a cold replica', async () => {
const h = createHarness();
// The producer's batch for a turn boundary: open a new turn, record its
// usage, then a tool batch. We trip the DB to fail on the tool-batch
// Phase-1 update, AFTER the turn's usage has already been written to the
// new assistant — so the orphan left behind is a true usage-bearing empty
// shell. `update` call order on replica A: #1 = openTurn flush of the seed's
// first-turn content, #2 = recordUsage on the new assistant, #3 = tools[]
// Phase 1 (← throws).
const batch = [
buildEvent('stream_start', 1, {
messageId: 'cc-msg-2',
newStep: true,
provider: 'claude-code',
}),
buildEvent('step_complete', 1, {
phase: 'turn_metadata',
usage: { totalInputTokens: 64_700, totalTokens: 64_700 },
}),
buildEvent('stream_chunk', 1, {
chunkType: 'tools_calling',
toolsCalling: [
{ apiName: 'Bash', arguments: '{}', id: 'tc-1', identifier: 'bash', type: 'default' },
],
}),
];
// ── Replica A: processes newStep (creates the turn assistant) + usage, then
// THROWS on the tool-batch write. The batch is left un-acked. ──
h.setFailUpdateAtCall(3);
await expect(
h.handler.ingest({
assistantMessageId: SEED,
events: batch,
operationId: OP,
topicId: TOPIC,
}),
).rejects.toThrow('simulated mid-batch DB failure');
// ── Cold replica: warm operation state (incl. processedKeys) is gone; the DB
// persists. The producer re-sends the SAME batch. ──
__resetOperationStatesForTesting();
// ── Replica B: full batch succeeds this time. ──
await h.handler.ingest({
assistantMessageId: SEED,
events: batch,
operationId: OP,
topicId: TOPIC,
});
// One `newStep` must yield exactly ONE new turn assistant (besides the seed).
const turnAssistants = [...h.messages.values()].filter(
(m) => m.role === 'assistant' && m.id !== SEED,
);
// Empty-shell detector: an assistant with usage but no content and no child tools.
const childToolsOf = (asstId: string) =>
[...h.messages.values()].filter((m) => m.role === 'tool' && m.parentId === asstId);
const emptyShells = turnAssistants.filter(
(m) => !m.content && childToolsOf(m.id).length === 0 && !!m.metadata?.usage,
);
expect(emptyShells).toHaveLength(0);
expect(turnAssistants).toHaveLength(1);
});
});
@@ -101,7 +101,13 @@ const createHarness = (params: {
update: vi.fn(async (id: string, patch: Partial<FakeMessage>) => {
const existing = messages.get(id);
if (!existing) return { success: false };
messages.set(id, { ...existing, ...patch });
// Mirror the real MessageModel.update: metadata is DEEP-MERGED, not
// replaced — so e.g. a usage write doesn't clobber subagentMessageId.
const next = { ...existing, ...patch };
if (patch.metadata && existing.metadata) {
next.metadata = { ...existing.metadata, ...patch.metadata };
}
messages.set(id, next);
return { success: true };
}),
updateToolMessage: vi.fn(async (id: string, patch: any) => {
@@ -268,6 +274,66 @@ describe('HeterogeneousPersistenceHandler — subagent run survives a cold repli
expect([...h.threads.values()].some((t) => t.title === 'Subagent')).toBe(false);
});
// The screenshot bug: a subagent that already FINISHED (its parent
// tool_result landed → thread flipped Active) has its FIRST event replayed on
// a cold replica (BatchIngester retry / re-delivery where the in-memory
// `processedKeys` dedupe is gone). Because finalized threads aren't rehydrated
// as live runs, the empty reducer used to hit `!existing` and fork a SECOND
// thread with the identical title ("一模一样的两个 thread"). The fix records
// the finalized parent's `sourceToolCallId` in `finalizedParents` from the DB
// `Active` thread, so the replayed first-event is a stale no-op.
it('does NOT re-create the thread when a FINISHED subagent replays its first event on a fresh replica', async () => {
const h = createHarness({
assistantMessageId: 'asst-1',
operationId: 'op-1',
topicId: 'topic-1',
});
const PARENT = 'tc-spawn-1';
const firstChunk = buildEvent('stream_chunk', 0, {
chunkType: 'tools_calling',
subagent: {
parentToolCallId: PARENT,
spawnMetadata: {
description: 'Map client runtime completion paths',
prompt: 'investigate',
subagentType: 'Explore',
},
subagentMessageId: 'sub-msg-1',
},
toolsCalling: [innerTool('inner-1')],
});
// ── Batch 1 (replica A): subagent runs, then its parent tool_result lands →
// the run finalizes and the thread is flipped Active. ──
await h.handler.ingest({
assistantMessageId: 'asst-1',
events: [firstChunk, buildEvent('tool_result', 1, { content: 'done', toolCallId: PARENT })],
operationId: 'op-1',
topicId: 'topic-1',
});
expect(h.threads.size).toBe(1);
const finishedThreadId = [...h.threads.keys()][0];
expect(h.threads.get(finishedThreadId)!.status).toBe('active');
// ── Cold replica: warm state gone, DB persists. ──
__resetOperationStatesForTesting();
// ── Replay of the SAME first event (processedKeys is empty on the fresh
// replica, so it is NOT deduped away — it really re-enters the reducer). ──
await h.handler.ingest({
assistantMessageId: 'asst-1',
events: [firstChunk],
operationId: 'op-1',
topicId: 'topic-1',
});
// Still exactly one thread — no duplicate, no second "Map client runtime…".
expect(h.threads.size).toBe(1);
expect([...h.threads.keys()]).toEqual([finishedThreadId]);
});
// P1: a tools_calling batch reprocessed on a cold replica (BatchIngester
// retry, or a turn split across a cold boundary so the cumulative array is
// re-seen) must NOT mint a second tool message for an inner tool the run
@@ -371,4 +437,69 @@ describe('HeterogeneousPersistenceHandler — subagent run survives a cold repli
expect(h.threads.get('thd-stale')!.status).toBe('processing');
expect(h.threadModel.update).not.toHaveBeenCalledWith('thd-stale', expect.anything());
});
// The in-thread analog of the cold-replica bug: one CC subagent turn continued
// on a fresh replica must NOT fork into a second in-thread assistant. The turn's
// CC message.id is persisted on the assistant's metadata and recovered into
// `currentSubagentMessageId`, so a continuation is recognized as the SAME turn.
it('does NOT fragment one CC subagent turn across a cold replica (no split / empty shell)', async () => {
const h = createHarness({
assistantMessageId: 'asst-1',
operationId: 'op-1',
topicId: 'topic-1',
});
const PARENT = 'tc-spawn-1';
// Batch 1: turn sub-1's first tool → lazy-create thread + user + in-thread
// assistant (stamped subagentMessageId=sub-1) + tool t1.
await h.handler.ingest({
assistantMessageId: 'asst-1',
events: [
buildEvent('stream_chunk', 0, {
chunkType: 'tools_calling',
subagent: {
parentToolCallId: PARENT,
spawnMetadata: { prompt: 'go', subagentType: 'Explore' },
subagentMessageId: 'sub-1',
},
toolsCalling: [innerTool('t1')],
}),
],
operationId: 'op-1',
topicId: 'topic-1',
});
const threadId = [...h.threads.keys()][0];
const assistantsOf = () =>
[...h.messages.values()].filter((m) => m.role === 'assistant' && m.threadId === threadId);
expect(assistantsOf()).toHaveLength(1);
// The turn id was persisted so a cold replica can recover it.
expect(assistantsOf()[0].metadata?.subagentMessageId).toBe('sub-1');
__resetOperationStatesForTesting(); // cold replica
// Batch 2 (fresh replica): SAME turn sub-1 continues (cumulative [t1, t2]).
await h.handler.ingest({
assistantMessageId: 'asst-1',
events: [
buildEvent('stream_chunk', 1, {
chunkType: 'tools_calling',
subagent: { parentToolCallId: PARENT, subagentMessageId: 'sub-1' },
toolsCalling: [innerTool('t1'), innerTool('t2')],
}),
],
operationId: 'op-1',
topicId: 'topic-1',
});
// Still exactly ONE in-thread assistant — no fork, no empty shell.
const assistants = assistantsOf();
expect(assistants).toHaveLength(1);
// Both tool rows hang off that same assistant (t1 not duplicated).
const toolRows = [...h.messages.values()].filter(
(m) => m.role === 'tool' && (m.tool_call_id === 't1' || m.tool_call_id === 't2'),
);
expect(toolRows).toHaveLength(2);
expect(new Set(toolRows.map((m) => m.parentId))).toEqual(new Set([assistants[0].id]));
});
});
@@ -1,68 +0,0 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { KlavisService } from './index';
const mocks = vi.hoisted(() => ({
PluginModel: vi.fn(),
pluginQuery: vi.fn(),
}));
vi.mock('@/database/models/plugin', () => ({
PluginModel: mocks.PluginModel,
}));
vi.mock('@/libs/klavis', () => ({
getKlavisClient: vi.fn(),
isKlavisClientAvailable: vi.fn(() => true),
}));
vi.mock('debug', () => ({
default: vi.fn(() => vi.fn()),
}));
describe('KlavisService', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.PluginModel.mockImplementation(() => ({
query: mocks.pluginQuery,
}));
});
describe('getKlavisManifests', () => {
it('filters deprecated Klavis providers from server manifests', async () => {
mocks.pluginQuery.mockResolvedValue([
{
customParams: { klavis: { isAuthenticated: true, serverName: 'Gmail' } },
identifier: 'gmail',
manifest: {
api: [{ name: 'sendEmail', parameters: { type: 'object' } }],
meta: { title: 'Gmail' },
},
},
{
customParams: { klavis: { isAuthenticated: true, serverName: 'Notion' } },
identifier: 'notion',
manifest: {
api: [{ name: 'notion-search', parameters: { type: 'object' } }],
meta: { title: 'Notion' },
},
},
{
customParams: { klavis: { isAuthenticated: false, serverName: 'Google Calendar' } },
identifier: 'google-calendar',
manifest: {
api: [{ name: 'listEvents', parameters: { type: 'object' } }],
meta: { title: 'Google Calendar' },
},
},
]);
const service = new KlavisService({ db: {} as any, userId: 'user-1' });
const manifests = await service.getKlavisManifests();
expect(manifests.map((manifest) => manifest.identifier)).toEqual(['gmail']);
});
});
});
-241
View File
@@ -1,241 +0,0 @@
import { KLAVIS_SERVER_TYPES } from '@lobechat/const';
import type { LobeToolManifest } from '@lobechat/context-engine';
import type { LobeChatDatabase } from '@lobechat/database';
import debug from 'debug';
import { PluginModel } from '@/database/models/plugin';
import { getKlavisClient, isKlavisClientAvailable } from '@/libs/klavis';
import { type ToolExecutionResult } from '@/server/services/toolExecution/types';
const log = debug('lobe-server:klavis-service');
const VALID_KLAVIS_IDENTIFIERS = new Set(KLAVIS_SERVER_TYPES.map((type) => type.identifier));
export interface KlavisToolExecuteParams {
args: Record<string, any>;
/** Tool identifier (same as Klavis server identifier, e.g., 'google-calendar') */
identifier: string;
toolName: string;
workspaceId?: string;
}
export interface KlavisServiceOptions {
db?: LobeChatDatabase;
userId?: string;
workspaceId?: string;
}
/**
* Klavis Service
*
* Provides a unified interface to Klavis Client with business logic encapsulation.
* This service wraps Klavis Client methods to execute tools and fetch manifests.
*
* Usage:
* ```typescript
* // With database and userId (for manifest fetching)
* const service = new KlavisService({ db, userId });
* await service.executeKlavisTool({ identifier, toolName, args });
*
* // Without database (for tool execution only if you have serverUrl)
* const service = new KlavisService();
* ```
*/
export class KlavisService {
private db?: LobeChatDatabase;
private userId?: string;
private pluginModel?: PluginModel;
private workspaceId?: string;
constructor(options: KlavisServiceOptions = {}) {
const { db, userId, workspaceId } = options;
this.db = db;
this.userId = userId;
this.workspaceId = workspaceId;
if (db && userId) {
this.pluginModel = new PluginModel(db, userId, workspaceId);
}
log(
'KlavisService initialized: hasDB=%s, hasUserId=%s, isClientAvailable=%s',
!!db,
!!userId,
isKlavisClientAvailable(),
);
}
/**
* Execute a Klavis tool
* @param params - Tool execution parameters
* @returns Tool execution result
*/
async executeKlavisTool(params: KlavisToolExecuteParams): Promise<ToolExecutionResult> {
const { identifier, toolName, args, workspaceId } = params;
log('executeKlavisTool: %s/%s with args: %O', identifier, toolName, args);
// Check if Klavis client is available
if (!isKlavisClientAvailable()) {
return {
content: 'Klavis service is not configured on server',
error: { code: 'KLAVIS_NOT_CONFIGURED', message: 'Klavis API key not found' },
success: false,
};
}
// Get serverUrl from plugin database
if (!this.pluginModel) {
return {
content: 'Klavis service is not properly initialized',
error: {
code: 'KLAVIS_NOT_INITIALIZED',
message: 'Database and userId are required for Klavis tool execution',
},
success: false,
};
}
try {
// Get plugin from database to retrieve serverUrl
const pluginModel =
workspaceId && this.db && this.userId
? new PluginModel(this.db, this.userId, workspaceId)
: this.pluginModel;
const plugin = await pluginModel.findById(identifier);
if (!plugin) {
return {
content: `Klavis server "${identifier}" not found in database`,
error: { code: 'KLAVIS_SERVER_NOT_FOUND', message: `Server ${identifier} not found` },
success: false,
};
}
const klavisParams = plugin.customParams?.klavis;
if (!klavisParams || !klavisParams.serverUrl) {
return {
content: `Klavis configuration not found for server "${identifier}"`,
error: {
code: 'KLAVIS_CONFIG_NOT_FOUND',
message: `Klavis configuration missing for ${identifier}`,
},
success: false,
};
}
const { serverUrl } = klavisParams;
log('executeKlavisTool: calling Klavis API with serverUrl=%s', serverUrl);
// Call Klavis client
const klavisClient = getKlavisClient();
const response = await klavisClient.mcpServer.callTools({
serverUrl,
toolArgs: args,
toolName,
});
log('executeKlavisTool: response: %O', response);
// Handle error case
if (!response.success || !response.result) {
return {
content: response.error || 'Unknown error',
error: { code: 'KLAVIS_EXECUTION_ERROR', message: response.error || 'Unknown error' },
success: false,
};
}
// Process the response
const content = response.result.content || [];
const isError = response.result.isError || false;
// Convert content array to string
let resultContent = '';
if (Array.isArray(content)) {
resultContent = content
.map((item: any) => {
if (typeof item === 'string') return item;
if (item.type === 'text' && item.text) return item.text;
return JSON.stringify(item);
})
.join('\n');
} else if (typeof content === 'string') {
resultContent = content;
} else {
resultContent = JSON.stringify(content);
}
return {
content: resultContent,
success: !isError,
};
} catch (error) {
const err = error as Error;
console.error('KlavisService.executeKlavisTool error %s/%s: %O', identifier, toolName, err);
return {
content: err.message,
error: { code: 'KLAVIS_ERROR', message: err.message },
success: false,
};
}
}
/**
* Fetch Klavis tool manifests from database
* Gets user's connected Klavis servers and builds tool manifests for agent execution
*
* @returns Array of tool manifests for connected Klavis servers
*/
async getKlavisManifests(): Promise<LobeToolManifest[]> {
if (!this.pluginModel) {
log('getKlavisManifests: pluginModel not available, returning empty array');
return [];
}
try {
// Get all plugins from database
const allPlugins = await this.pluginModel.query();
// Filter plugins that have klavis customParams, are still supported, and are authenticated.
const klavisPlugins = allPlugins.filter(
(plugin) =>
VALID_KLAVIS_IDENTIFIERS.has(plugin.identifier) &&
plugin.customParams?.klavis?.isAuthenticated === true,
);
log('getKlavisManifests: found %d authenticated Klavis plugins', klavisPlugins.length);
// Convert to LobeToolManifest format
const manifests: LobeToolManifest[] = klavisPlugins
.map((plugin) => {
if (!plugin.manifest) return null;
return {
api: plugin.manifest.api || [],
author: 'Klavis',
homepage: 'https://klavis.ai',
identifier: plugin.identifier,
meta: plugin.manifest.meta || {
avatar: '☁️',
description: `Klavis MCP Server: ${plugin.customParams?.klavis?.serverName}`,
tags: ['klavis', 'mcp'],
title: plugin.customParams?.klavis?.serverName || plugin.identifier,
},
type: 'builtin',
version: '1.0.0',
};
})
.filter(Boolean) as LobeToolManifest[];
log('getKlavisManifests: returning %d manifests', manifests.length);
return manifests;
} catch (error) {
console.error('KlavisService.getKlavisManifests error: %O', error);
return [];
}
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ export class MCPService {
/**
* Process MCP tool call result with content blocks processing
* This is a common utility method that can be used by both internal MCP calls and external services (e.g., Klavis)
* This is a common utility method that can be used by both internal MCP calls and external services (e.g., Composio)
*/
static async processToolCallResult(
result: MCPToolCallRawResult,
@@ -281,19 +281,19 @@ describe('isTemplateSkillSourceEligible', () => {
it('drops templates whose source is not in enabledSkillSources', () => {
const t = makeTemplate({ requiresSkills: [{ provider: 'notion', source: 'lobehub' }] });
expect(isTemplateSkillSourceEligible(t, new Set(['klavis']))).toBe(false);
expect(isTemplateSkillSourceEligible(t, new Set(['composio']))).toBe(false);
});
it('requires every source for multi-skill templates', () => {
const t = makeTemplate({
requiresSkills: [
{ provider: 'notion', source: 'lobehub' },
{ provider: 'google-calendar', source: 'klavis' },
{ provider: 'google-calendar', source: 'composio' },
],
});
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub']))).toBe(false);
expect(isTemplateSkillSourceEligible(t, new Set(['klavis']))).toBe(false);
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub', 'klavis']))).toBe(true);
expect(isTemplateSkillSourceEligible(t, new Set(['composio']))).toBe(false);
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub', 'composio']))).toBe(true);
});
it('treats empty requiresSkills array same as undefined (always eligible)', () => {
@@ -6,12 +6,12 @@ import {
taskTemplates,
} from '@lobechat/const';
import { klavisEnv } from '@/config/klavis';
import { composioEnv } from '@/config/composio';
import { appEnv } from '@/envs/app';
export const ENABLED_SKILL_SOURCES: ReadonlySet<TaskTemplateSkillSource> = (() => {
const sources = new Set<TaskTemplateSkillSource>();
if (klavisEnv.KLAVIS_API_KEY) sources.add('klavis');
if (composioEnv.COMPOSIO_API_KEY) sources.add('composio');
if (appEnv.MARKET_TRUSTED_CLIENT_ID && appEnv.MARKET_TRUSTED_CLIENT_SECRET) {
sources.add('lobehub');
}
@@ -11,8 +11,8 @@ vi.mock('../serverRuntimes', () => ({
getServerRuntime: vi.fn(async () => ({ createDocument: mockApiHandler })),
}));
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({})),
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({})),
}));
vi.mock('@/server/services/market', () => ({
MarketService: vi.fn().mockImplementation(() => ({})),
@@ -3,7 +3,7 @@ import { type ChatToolPayload } from '@lobechat/types';
import { detectTruncatedJSON, safeParseJSON } from '@lobechat/utils';
import debug from 'debug';
import { KlavisService } from '@/server/services/klavis';
import { ComposioService } from '@/server/services/composio';
import { MarketService } from '@/server/services/market';
import { getServerRuntime, hasServerRuntime } from './serverRuntimes';
@@ -13,11 +13,11 @@ const log = debug('lobe-server:builtin-tools-executor');
export class BuiltinToolsExecutor implements IToolExecutor {
private marketService: MarketService;
private klavisService: KlavisService;
private composioService: ComposioService;
constructor(db: LobeChatDatabase, userId: string) {
this.marketService = new MarketService({ userInfo: { userId } });
this.klavisService = new KlavisService({ db, userId });
this.composioService = new ComposioService({ db, userId });
}
async execute(
@@ -78,13 +78,12 @@ export class BuiltinToolsExecutor implements IToolExecutor {
});
}
// Route Klavis tools to KlavisService
if (source === 'klavis') {
return this.klavisService.executeKlavisTool({
// Route Composio tools to ComposioService
if (source === 'composio') {
return this.composioService.executeComposioTool({
args,
identifier,
toolName: apiName,
workspaceId: context.workspaceId,
toolSlug: apiName,
});
}
@@ -82,7 +82,7 @@ export class ToolExecutionService {
// ── Connector tool permission gate (covers ALL paths + qstash) ────────
// Check before any execution so that disabled tools are blocked universally:
// Lobehub market skills, Klavis, MCP connectors, and execAgent/qstash alike.
// Lobehub market skills, Composio, MCP connectors, and execAgent/qstash alike.
// needs_approval is handled via humanIntervention in the manifest; we only
// hard-block 'disabled' here (and needs_approval in headless/qstash context
// since the manifest's humanIntervention auto-rejects them there already).
@@ -74,6 +74,112 @@ describe('agentManagementRuntime', () => {
expect(PluginModel).toHaveBeenCalledWith(expect.anything(), 'user-1', 'workspace-1');
});
describe('callAgent', () => {
it('fails when the server sub-agent runner is unavailable', async () => {
const runtime = createRuntime();
const result = await runtime.callAgent(
{
agentId: 'agent-target',
instruction: 'Do delegated work',
runAsTask: true,
},
{ toolManifestMap: {} },
);
expect(result.success).toBe(false);
expect(result.error).toMatchObject({ code: 'AGENT_CALL_UNAVAILABLE' });
});
it('returns a deferred tool result and forks the target agent through the sub-agent runner', async () => {
const run = vi.fn().mockResolvedValue({
started: true,
subOperationId: 'op-child',
threadId: 'thread-child',
});
const runtime = createRuntime();
const result = await runtime.callAgent(
{
agentId: 'agent-target',
instruction: 'Do delegated work',
runAsTask: true,
taskTitle: 'Delegated task',
timeout: 1234,
},
{
subAgent: { run },
toolManifestMap: {},
},
);
expect(run).toHaveBeenCalledWith({
agentId: 'agent-target',
description: 'Delegated task',
instruction: 'Do delegated work',
timeout: 1234,
});
expect(result).toMatchObject({
content: '',
deferred: true,
success: true,
});
expect(result.state).toMatchObject({
status: 'pending',
subOperationId: 'op-child',
targetAgentId: 'agent-target',
threadId: 'thread-child',
});
});
it('returns a non-deferred failure when the target agent cannot start', async () => {
const run = vi.fn().mockResolvedValue({
started: false,
threadId: '',
});
const runtime = createRuntime();
const result = await runtime.callAgent(
{
agentId: 'agent-target',
instruction: 'Do delegated work',
runAsTask: true,
},
{
subAgent: { run },
toolManifestMap: {},
},
);
expect(result.success).toBe(false);
expect(result.error).toMatchObject({
code: 'AGENT_CALL_START_FAILED',
});
expect(result.deferred).toBeUndefined();
});
it('rejects nested server callAgent execution', async () => {
const run = vi.fn();
const runtime = createRuntime();
const result = await runtime.callAgent(
{
agentId: 'agent-target',
instruction: 'Do delegated work',
},
{
isSubAgent: true,
subAgent: { run },
toolManifestMap: {},
},
);
expect(result.success).toBe(false);
expect(result.error).toMatchObject({ code: 'NESTED_AGENT_CALL_NOT_ALLOWED' });
expect(run).not.toHaveBeenCalled();
});
});
describe('searchAgent', () => {
it('reports the real total and a pagination hint when more agents exist', async () => {
mockQueryAgents.mockResolvedValue(makeAgents(20));
@@ -0,0 +1,153 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ToolExecutionContext } from '../../types';
import { groupManagementRuntime } from '../groupManagement';
const run = vi.fn();
const makeCtx = (overrides?: Partial<ToolExecutionContext>): ToolExecutionContext =>
({
agentMember: { run },
toolManifestMap: {},
userId: 'user-1',
...overrides,
}) as ToolExecutionContext;
const runtime = () => groupManagementRuntime.factory(makeCtx()) as any;
describe('groupManagementRuntime', () => {
beforeEach(() => {
run.mockReset();
run.mockResolvedValue({ started: true, startedCount: 1 });
});
describe('speak', () => {
it('forks one in-group member and resumes the supervisor', async () => {
const result = await runtime().speak({ agentId: 'agent-a', instruction: 'hi' }, makeCtx());
expect(run).toHaveBeenCalledWith({
members: [{ agentId: 'agent-a', instruction: 'hi' }],
mode: 'in_group',
onComplete: 'resume',
});
expect(result).toMatchObject({ deferred: true, success: true });
expect(result.state).toMatchObject({ status: 'pending', type: 'speak' });
});
it('finishes the supervisor when skipCallSupervisor is set', async () => {
await runtime().speak({ agentId: 'agent-a', skipCallSupervisor: true }, makeCtx());
expect(run).toHaveBeenCalledWith(expect.objectContaining({ onComplete: 'finish' }));
});
it('errors without agentId', async () => {
const result = await runtime().speak({} as any, makeCtx());
expect(result.success).toBe(false);
expect(result.error?.code).toBe('INVALID_ARGUMENTS');
expect(run).not.toHaveBeenCalled();
});
it('errors when the agentMember runner is unavailable', async () => {
const result = await runtime().speak(
{ agentId: 'agent-a' },
makeCtx({ agentMember: undefined }),
);
expect(result.success).toBe(false);
expect(result.error?.code).toBe('AGENT_MEMBER_UNAVAILABLE');
});
it('surfaces an inline error when no member started', async () => {
run.mockResolvedValue({ started: false, startedCount: 0 });
const result = await runtime().speak({ agentId: 'agent-a' }, makeCtx());
expect(result.success).toBe(false);
expect(result.error?.code).toBe('AGENT_MEMBER_START_FAILED');
});
});
describe('broadcast', () => {
it('forks N in-group members with tools disabled', async () => {
run.mockResolvedValue({ started: true, startedCount: 2 });
const result = await runtime().broadcast(
{ agentIds: ['a', 'b'], instruction: 'go' },
makeCtx(),
);
expect(run).toHaveBeenCalledWith({
disableTools: true,
members: [
{ agentId: 'a', instruction: 'go' },
{ agentId: 'b', instruction: 'go' },
],
mode: 'in_group',
onComplete: 'resume',
});
expect(result).toMatchObject({ deferred: true, success: true });
});
it('errors without agentIds', async () => {
const result = await runtime().broadcast({ agentIds: [] } as any, makeCtx());
expect(result.error?.code).toBe('INVALID_ARGUMENTS');
});
});
describe('delegate', () => {
it('hands off to a member and finishes (no resume)', async () => {
await runtime().delegate({ agentId: 'agent-a', reason: 'you take it' }, makeCtx());
expect(run).toHaveBeenCalledWith({
members: [{ agentId: 'agent-a', instruction: 'you take it' }],
mode: 'in_group',
onComplete: 'finish',
});
});
});
describe('executeAgentTask', () => {
it('runs an isolated-thread member and resumes', async () => {
const result = await runtime().executeAgentTask(
{ agentId: 'agent-a', instruction: 'do work', timeout: 60_000, title: 'work' },
makeCtx(),
);
expect(run).toHaveBeenCalledWith({
members: [{ agentId: 'agent-a', instruction: 'do work' }],
mode: 'isolated',
onComplete: 'resume',
timeout: 60_000,
});
expect(result).toMatchObject({ deferred: true, success: true });
});
it('errors without instruction', async () => {
const result = await runtime().executeAgentTask({ agentId: 'agent-a' } as any, makeCtx());
expect(result.error?.code).toBe('INVALID_ARGUMENTS');
});
});
describe('executeAgentTasks', () => {
it('runs parallel isolated tasks and collapses timeout to the longest', async () => {
run.mockResolvedValue({ started: true, startedCount: 2 });
await runtime().executeAgentTasks(
{
tasks: [
{ agentId: 'a', instruction: 'ta', timeout: 1000, title: 'A' },
{ agentId: 'b', instruction: 'tb', timeout: 5000, title: 'B' },
],
},
makeCtx(),
);
expect(run).toHaveBeenCalledWith({
members: [
{ agentId: 'a', instruction: 'ta' },
{ agentId: 'b', instruction: 'tb' },
],
mode: 'isolated',
onComplete: 'resume',
timeout: 5000,
});
});
it('errors without tasks', async () => {
const result = await runtime().executeAgentTasks({ tasks: [] } as any, makeCtx());
expect(result.error?.code).toBe('INVALID_ARGUMENTS');
});
});
});
@@ -152,7 +152,7 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
params: UpdateAgentConfigParams,
ctx: ToolExecutionContext,
): Promise<ToolExecutionResult> => {
const agentId = ctx.agentId;
const agentId = ctx.editingAgentId ?? ctx.agentId;
if (!agentId) {
return {
@@ -240,7 +240,7 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
params: UpdatePromptParams,
ctx: ToolExecutionContext,
): Promise<ToolExecutionResult> => {
const agentId = ctx.agentId;
const agentId = ctx.editingAgentId ?? ctx.agentId;
if (!agentId) {
return {
@@ -272,7 +272,7 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
params: InstallPluginParams,
ctx: ToolExecutionContext,
): Promise<ToolExecutionResult> => {
const agentId = ctx.agentId;
const agentId = ctx.editingAgentId ?? ctx.agentId;
if (!agentId) {
return {
@@ -307,9 +307,9 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
}
}
// OAuth-based tools (Klavis, LobehubSkill) cannot be installed in background context
// OAuth-based tools (Composio, LobehubSkill) cannot be installed in background context
return {
content: `Installing official integrations that require OAuth (Klavis, LobehubSkill) is not supported in background execution. Please install "${identifier}" from the Agent Builder UI instead.`,
content: `Installing official integrations that require OAuth (Composio, LobehubSkill) is not supported in background execution. Please install "${identifier}" from the Agent Builder UI instead.`,
error: { message: 'OAuth not available in background context', type: 'NotSupported' },
success: false,
};
@@ -43,20 +43,60 @@ export const agentManagementRuntime: ServerRuntimeRegistration = {
): Promise<ToolExecutionResult> => {
const { agentId, instruction, taskTitle, timeout } = params;
// Server runtime always uses the legacy async invocation path because
// there is no client-side `registerAfterCompletion` callback available
// to execute synchronous agent calls.
return {
content: `🚀 Triggered async task to call agent "${agentId}"${taskTitle ? `: ${taskTitle}` : ''}`,
state: {
parentMessageId: ctx.messageId,
task: {
description: taskTitle || `Call agent ${agentId}`,
instruction,
targetAgentId: agentId,
timeout: timeout || 1_800_000,
if (ctx.isSubAgent) {
return {
content: 'Agent calls cannot be triggered from within another sub-agent.',
error: {
code: 'NESTED_AGENT_CALL_NOT_ALLOWED',
message: 'Agent calls cannot be triggered from within another sub-agent.',
},
type: 'execSubAgent',
success: false,
};
}
if (!ctx.subAgent) {
return {
content: 'Agent execution is not available in this runtime.',
error: { code: 'AGENT_CALL_UNAVAILABLE' },
success: false,
};
}
if (!instruction || typeof instruction !== 'string') {
return {
content: 'instruction is required.',
error: { code: 'INVALID_ARGUMENTS', message: 'instruction is required.' },
success: false,
};
}
const description = taskTitle || `Call agent ${agentId}`;
const { started, subOperationId, threadId } = await ctx.subAgent.run({
agentId,
description,
instruction,
timeout: timeout || 1_800_000,
});
if (!started) {
return {
content: `Agent "${agentId}" failed to start.`,
error: {
code: 'AGENT_CALL_START_FAILED',
message: `Agent "${agentId}" failed to start.`,
},
success: false,
};
}
return {
content: '',
deferred: true,
state: {
status: 'pending',
subOperationId,
targetAgentId: agentId,
threadId,
},
success: true,
};
@@ -0,0 +1,212 @@
/**
* Group Management Server Runtime server-side group orchestration.
*
* The supervisor agent runs as a normal durable QStash operation; its
* `lobe-group-management` tool calls execute here as deferred tools. Each action
* forks group member(s) via the injected `ctx.agentMember` runner and returns
* `deferred: true`: the agent runtime parks the supervisor (`waiting_for_async_tool`),
* and the group-action member completion bridge backfills + resumes/finishes it
* once the K=N member barrier passes.
*
* - speak one in-group member, resume (or finish on skipCallSupervisor)
* - broadcast N in-group members (tools disabled), resume/finish
* - delegate one in-group member, finish (supervisor hands off)
* - executeAgentTask(s) isolated thread member(s), resume/finish
*
* Mirrors the client GroupOrchestrationRuntime semantics, but the supervisor's
* own operation IS the orchestration loop no separate driver.
*/
import type {
BroadcastParams,
CreateWorkflowParams,
DelegateParams,
ExecuteTaskParams,
ExecuteTasksParams,
InterruptParams,
SpeakParams,
SummarizeParams,
VoteParams,
} from '@lobechat/builtin-tool-group-management';
import { GroupManagementIdentifier } from '@lobechat/builtin-tool-group-management';
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
import type { ToolExecutionContext } from '../types';
import type { ServerRuntimeRegistration } from './types';
const buildError = (content: string, code: string): BuiltinServerRuntimeOutput => ({
content,
error: { code, message: content },
success: false,
});
const AGENT_MEMBER_UNAVAILABLE = buildError(
'Group orchestration is not available in this runtime.',
'AGENT_MEMBER_UNAVAILABLE',
);
const START_FAILED = buildError('Agent member(s) failed to start.', 'AGENT_MEMBER_START_FAILED');
class GroupManagementExecutionRuntime {
// ==================== Communication Coordination ====================
/** Let a single member speak in the shared group session (non-isolated). */
speak = async (
params: SpeakParams,
ctx: ToolExecutionContext,
): Promise<BuiltinServerRuntimeOutput> => {
if (!ctx.agentMember) return AGENT_MEMBER_UNAVAILABLE;
if (!params.agentId) return buildError('agentId is required.', 'INVALID_ARGUMENTS');
const { started } = await ctx.agentMember.run({
members: [{ agentId: params.agentId, instruction: params.instruction }],
mode: 'in_group',
onComplete: params.skipCallSupervisor ? 'finish' : 'resume',
});
if (!started) return START_FAILED;
return {
content: '',
deferred: true,
state: { agentId: params.agentId, status: 'pending', type: 'speak' },
success: true,
};
};
/** Let multiple members respond in parallel (tools disabled — opinions only). */
broadcast = async (
params: BroadcastParams,
ctx: ToolExecutionContext,
): Promise<BuiltinServerRuntimeOutput> => {
if (!ctx.agentMember) return AGENT_MEMBER_UNAVAILABLE;
const agentIds = params.agentIds ?? [];
if (agentIds.length === 0) return buildError('agentIds is required.', 'INVALID_ARGUMENTS');
const { started } = await ctx.agentMember.run({
disableTools: true,
members: agentIds.map((agentId) => ({ agentId, instruction: params.instruction })),
mode: 'in_group',
onComplete: params.skipCallSupervisor ? 'finish' : 'resume',
});
if (!started) return START_FAILED;
return {
content: '',
deferred: true,
state: { agentIds, status: 'pending', type: 'broadcast' },
success: true,
};
};
/** Delegate the conversation to a member; the supervisor exits afterwards. */
delegate = async (
params: DelegateParams,
ctx: ToolExecutionContext,
): Promise<BuiltinServerRuntimeOutput> => {
if (!ctx.agentMember) return AGENT_MEMBER_UNAVAILABLE;
if (!params.agentId) return buildError('agentId is required.', 'INVALID_ARGUMENTS');
const { started } = await ctx.agentMember.run({
members: [{ agentId: params.agentId, instruction: params.reason }],
mode: 'in_group',
// Delegate hands control to the member — finish without another supervisor turn.
onComplete: 'finish',
});
if (!started) return START_FAILED;
return {
content: '',
deferred: true,
state: { agentId: params.agentId, status: 'pending', type: 'delegate' },
success: true,
};
};
// ==================== Task Execution (isolated threads) ====================
/**
* Run a member as an isolated-thread task. `runInClient` only takes effect on
* the desktop client (handled by the client orchestrator); on the cloud/web
* server there is no local FS/shell, so the task always runs server-side.
*/
executeAgentTask = async (
params: ExecuteTaskParams,
ctx: ToolExecutionContext,
): Promise<BuiltinServerRuntimeOutput> => {
if (!ctx.agentMember) return AGENT_MEMBER_UNAVAILABLE;
if (!params.agentId || !params.instruction) {
return buildError('agentId and instruction are required.', 'INVALID_ARGUMENTS');
}
const { started } = await ctx.agentMember.run({
members: [{ agentId: params.agentId, instruction: params.instruction }],
mode: 'isolated',
onComplete: params.skipCallSupervisor ? 'finish' : 'resume',
timeout: params.timeout,
});
if (!started) return START_FAILED;
return {
content: '',
deferred: true,
state: { agentId: params.agentId, status: 'pending', type: 'executeAgentTask' },
success: true,
};
};
/** Run multiple members as parallel isolated-thread tasks. */
executeAgentTasks = async (
params: ExecuteTasksParams,
ctx: ToolExecutionContext,
): Promise<BuiltinServerRuntimeOutput> => {
if (!ctx.agentMember) return AGENT_MEMBER_UNAVAILABLE;
const tasks = params.tasks ?? [];
if (tasks.length === 0) return buildError('tasks is required.', 'INVALID_ARGUMENTS');
const { started } = await ctx.agentMember.run({
members: tasks.map((task) => ({ agentId: task.agentId, instruction: task.instruction })),
mode: 'isolated',
onComplete: params.skipCallSupervisor ? 'finish' : 'resume',
// Per-task timeouts collapse to the longest; the barrier waits for all.
timeout: tasks.reduce((max, task) => Math.max(max, task.timeout ?? 0), 0) || undefined,
});
if (!started) return START_FAILED;
return {
content: '',
deferred: true,
state: { status: 'pending', tasks: tasks.map((t) => t.agentId), type: 'executeAgentTasks' },
success: true,
};
};
// ==================== Not yet implemented on the server ====================
// Mirror the client stubs: return inline (non-deferred) results so the
// supervisor LLM keeps orchestrating instead of parking.
interrupt = async (params: InterruptParams): Promise<BuiltinServerRuntimeOutput> => ({
content: `Interrupt is not yet supported in server orchestration (task ${params.taskId}).`,
success: true,
});
summarize = async (_params: SummarizeParams): Promise<BuiltinServerRuntimeOutput> => ({
content: 'Summarize is not yet implemented in server orchestration.',
success: true,
});
createWorkflow = async (params: CreateWorkflowParams): Promise<BuiltinServerRuntimeOutput> => ({
content: `Workflow creation is not yet implemented ("${params.name}").`,
success: true,
});
vote = async (params: VoteParams): Promise<BuiltinServerRuntimeOutput> => ({
content: `Voting is not yet implemented (question: "${params.question}").`,
success: true,
});
}
const runtime = new GroupManagementExecutionRuntime();
export const groupManagementRuntime: ServerRuntimeRegistration = {
factory: () => runtime,
identifier: GroupManagementIdentifier,
};
@@ -19,6 +19,7 @@ import { briefRuntime } from './brief';
import { calculatorRuntime } from './calculator';
import { cloudSandboxRuntime } from './cloudSandbox';
import { credsRuntime } from './creds';
import { groupManagementRuntime } from './groupManagement';
import { knowledgeBaseRuntime } from './knowledgeBase';
import { lobeAgentRuntime } from './lobeAgent';
import { lobeDeliveryCheckerRuntime } from './lobeDeliveryChecker';
@@ -76,6 +77,7 @@ registerRuntimes([
topicReferenceRuntime,
userInteractionRuntime,
credsRuntime,
groupManagementRuntime,
knowledgeBaseRuntime,
webOnboardingRuntime,
lobeAgentRuntime,
@@ -53,13 +53,80 @@ export interface ServerSubAgentRunner {
run: (params: ServerSubAgentRunParams) => Promise<ServerSubAgentRunResult>;
}
export interface ServerAgentMemberRunItem {
/** Target group member agent id. */
agentId: string;
/** Optional supervisor instruction to guide the member's response. */
instruction?: string;
}
export interface ServerAgentMemberRunParams {
/** Disable tools for the members (used by broadcast — members only voice opinions). */
disableTools?: boolean;
/** Members to run under the current group-management tool call. */
members: ServerAgentMemberRunItem[];
/**
* Execution mode:
* - `in_group`: member runs in the shared group session (non-isolated); its
* turns land directly in the group conversation. Used by speak/broadcast/delegate.
* - `isolated`: member runs in its own isolation thread. Used by
* executeAgentTask(s).
*/
mode: 'in_group' | 'isolated';
/**
* Whether, once all members complete, the parked supervisor op should
* `resume` (re-enter the supervisor LLM) or `finish` (end the orchestration
* without another supervisor turn for `skipCallSupervisor` / delegate).
*/
onComplete: 'resume' | 'finish';
/** Per-member execution timeout (ms), applied to isolated tasks. */
timeout?: number;
}
export interface ServerAgentMemberRunResult {
/**
* Whether at least one member op was forked. `false` means every member
* failed to start no completion bridge will fire, so the caller must
* surface an inline tool error instead of parking the parent.
*/
started: boolean;
/** Number of member ops successfully forked. */
startedCount: number;
}
/**
* Server-side "call agent member" runner injected per tool call by the agent
* runtime for group orchestration. Distinct from {@link ServerSubAgentRunner}:
* a sub-agent is an isolated child run, whereas a group member can run inside
* the shared group session. The runner creates the per-member anchor messages
* under the group tool call, forks the member op(s), and returns immediately;
* the K=N member barrier backfills the group tool message and resumes/finishes
* the parked supervisor once all members complete.
*/
export interface ServerAgentMemberRunner {
run: (params: ServerAgentMemberRunParams) => Promise<ServerAgentMemberRunResult>;
}
export interface ToolExecutionContext {
/** Target device ID for device proxy tool calls */
activeDeviceId?: string;
/** Agent ID executing the tool call */
agentId?: string;
/**
* Server-side "call agent member" runner, injected per tool call by the agent
* runtime for group orchestration. The `lobe-group-management` server tool
* calls `agentMember.run(...)` to fork member op(s) and returns a `deferred`
* result; the member barrier backfills + resumes/finishes the parked supervisor.
*/
agentMember?: ServerAgentMemberRunner;
/** Current page document ID for page-scoped conversations */
documentId?: string | null;
/**
* When scope is 'agent_builder', the ID of the agent being edited. Kept
* separate from agentId so message ownership and queryUiMessages remain
* bound to the builder builtin; only AgentBuilder tool methods read this.
*/
editingAgentId?: string;
/**
* Legacy agent invocation callback forwarded from RuntimeExecutorContext.
* Kept for tool runtimes that still dispatch through exec_sub_agent style
+2 -1
View File
@@ -474,5 +474,6 @@
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
"https://file.rene.wang/task.png": "/blog/assets4aa1732a45832afc780600e6e329860c.webp",
"https://file.rene.wang/Platform Agent.png": "/blog/assets10cadd434aeb36bd1beb3c7b3d371fbd.webp",
"https://file.rene.wang/clipboard-1780888016983-b47fcdab831b1.png": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp"
"https://file.rene.wang/clipboard-1780888016983-b47fcdab831b1.png": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp",
"https://file.rene.wang/device.png": "/blog/assets6aebb8b5341dc37202ffecb363b9b906.webp"
}
+8 -10
View File
@@ -11,18 +11,16 @@ tags:
# Image & Video Generation Redesign
This update refines the visual creation flow, so moving between image and video work feels quicker and more natural.
## Features
## Whats New
- **Redesigned image and video generation.** Switch between image and video creation directly, instead of moving between separate flows.
- **Clear all memory in one action.** When you need a clean context, clear every memory entry at once.
The generation interface has been redesigned for faster switching between image and video creation. Instead of jumping between separate flows, you can now move between both media modes more directly.
## Improvements
Memory reset is also simpler now. When you need a clean context, you can clear all memory entries in one action.
- Better mobile menu behavior.
Under the hood, the agent architecture was refactored to improve reliability and leave more room for future extensions.
## Fixes
## Experience Improvements
- Fixed visual glitches in compression view.
- Improved mobile menu behavior.
- Corrected message count display accuracy.
- Fixed visual glitches in the compression view.
- Corrected message count accuracy.
@@ -9,18 +9,16 @@ tags:
# 图片与视频生成重设计
这次更新聚焦于优化视觉创作流程,让图片与视频生成之间的切换更自然、操作更高效。
## 新功能
## 更新内容
这次首先重做了生成流程。图片与视频创作不再像两条割裂的路径,新界面让你在两种模式之间切换更直接。
记忆管理也变得更干净利落。需要重置上下文时,你现在可以一键清空全部记忆条目。
此外,Agent 内部架构也做了重构,重点是提升稳定性,并为后续能力扩展留出空间。
- **重新设计的图片与视频生成。** 图片与视频创作不再是两条割裂的路径,可以更直接地在两种模式之间切换。
- **一键清空全部记忆。** 需要重置上下文时,可一次性清空所有记忆条目。
## 体验优化
- 修复压缩视图显示异常
- 优化移动端菜单交互。
- 修正消息计数显示准确性。
- 移动端菜单交互更顺畅
## 问题修复
- 修复压缩视图的显示异常。
- 修正消息计数的准确性。
+12 -7
View File
@@ -12,15 +12,20 @@ tags:
# Agent Tasks and Agent Management
This update improves how teams adopt and run Agents day to day, especially when coordinating bots across multiple workflows.
## Features
LobeHub now includes in-app notifications, so important updates and alerts appear directly in the product. Agent management is also more flexible, with stronger rendering support and richer content handling in bot experiences.
- **In-app notifications.** Important updates and alerts now appear inside the product.
- **Guided onboarding.** A new onboarding path helps teams get started faster.
- **Slash command icons.** Skill-specific icons make slash commands easier to find.
Getting started is smoother as well. A new guided onboarding path helps teams ramp up faster, and slash command discoverability improves with skill-specific icons. GitHub Copilot compatibility is also improved, including better vision-related behavior.
## Improvements
## Experience Improvements
- Agent management is more flexible, with richer rendering and content handling in bots.
- Better GitHub Copilot compatibility, including vision.
- The Marketplace entry moved below Resources in the sidebar for a cleaner layout.
- A visual cue now shows when AI generation is interrupted.
- A more user-friendly fallback state when errors occur.
## Fixes
- Moved the Marketplace entry below Resources in the sidebar for a cleaner layout.
- Added a visual cue when AI generation is interrupted.
- Fixed display issues when switching between topics.
- Improved error handling with a more user-friendly fallback state.
@@ -10,15 +10,20 @@ tags:
# 智能体任务系统与 Agent 管理
这次更新重点优化了 Agent 的上手和日常运营体验,尤其适合在多场景协作中使用 Bot 的团队。
## 新功能
LobeHub 现在支持应用内通知重要更新和提醒可以直接在产品内收到。Agent 管理能力也进一步增强,在内容渲染和展示上更灵活,能覆盖更丰富的 Bot 使用场景
上手流程也更顺了。新版引导可以帮助团队更快进入 Agent 工作流,斜杠菜单加入技能专属图标后,常用命令更易定位。与此同时,GitHub Copilot 兼容性也进一步提升,包含视觉相关能力的改进。
- **应用内通知。** 重要更新和提醒现在会直接在产品内出现
- **引导式上手。** 新版引导帮助团队更快进入工作流。
- **斜杠命令图标。** 技能专属图标让斜杠命令更易定位。
## 体验优化
- 将市场入口移至侧边栏「资源」下方,整体布局更清晰
- 在 AI 生成被中断时增加可视化提示
- 修复话题切换时的显示异常
- 改进错误处理,提供更友好的降级界面
- Agent 管理更灵活,在 Bot 场景中的渲染与内容处理更丰富
- GitHub Copilot 兼容性提升,包含视觉能力
- 市场入口移至侧边栏「资源」下方,布局更清晰
- AI 生成被中断时会显示可视化提示
- 出错时提供更友好的降级界面。
## 问题修复
- 修复了话题切换时的显示异常。
+15 -11
View File
@@ -1,8 +1,8 @@
---
title: AI Auto-Completion & Real-Time Gateway
description: >-
Added AI-powered input auto-completion, WebSocket-based real-time messaging
gateway, expanded bot platform support, and improved context injection.
Added AI-powered input auto-completion, a real-time messaging gateway,
expanded bot platform support, and improved context injection.
tags:
- Auto-Completion
- WebSocket Gateway
@@ -12,17 +12,21 @@ tags:
# AI Auto-Completion & Real-Time Gateway
This release focuses on removing small points of friction in writing and real-time collaboration.
## Features
The editor now supports AI auto-completion while you type, so drafting messages is faster and requires less context switching. On the delivery side, the new WebSocket-based Agent Gateway streams responses with lower latency, making live conversations feel more immediate.
- **AI auto-completion.** The editor suggests completions as you type, so drafting messages is faster.
- **Real-time Agent Gateway.** The new Agent Gateway streams responses with lower latency.
- **More bot platforms.** Feishu/Lark, Slack, and QQ now support a more reliable connection mode.
- **Skills and tools via `@`.** Trigger skills and tools with `@` mentions and direct context injection, replacing the old slash-command flow.
- **Dedicated Skills tab.** The Skill Store now has its own Skills tab.
- **Auto-created topics.** New topics are created every four hours to keep conversations organized.
Cross-platform bot connectivity is also broader. Feishu/Lark, Slack, and QQ now support WebSocket connection mode for more reliable message delivery. Context invocation is also simpler: skills and tools can be triggered with `@` mentions and direct context injection, replacing the older slash-command-heavy flow.
## Improvements
To keep navigation cleaner over time, the Skill Store now has a dedicated Skills tab, and topics are automatically created every four hours to keep conversations organized.
- Agent documents load progressively, showing content as it becomes available instead of blocking the page.
- Pasting large clipboard content no longer freezes the chat input.
## Experience Improvements
## Fixes
- Agent documents now load progressively, showing content as it becomes available instead of blocking the full page
- Fixed the image generation button incorrectly defaulting to a wrong model
- Improved paste performance by preventing the chat input from freezing on large clipboard content
- Strengthened security by sanitizing HTML artifacts and removing an auth bypass vector
- The image generation button no longer defaults to the wrong model.
- Closed an authentication bypass and sanitized HTML artifacts.
@@ -1,6 +1,6 @@
---
title: AI 自动补全与实时消息网关
description: 新增 AI 输入自动补全、基于 WebSocket 的实时消息网关、扩展 Bot 平台支持,以及改进的上下文注入机制。
description: 新增 AI 输入自动补全、实时消息网关、扩展 Bot 平台支持,以及改进的上下文注入机制。
tags:
- 自动补全
- WebSocket 网关
@@ -10,17 +10,21 @@ tags:
# AI 自动补全与实时消息网关
这版更新聚焦在减少写作和实时协作中的细碎阻力,让整体体验更连贯。
## 新功能
编辑器现在支持 AI 自动补全,你在输入时就能收到建议,消息撰写更快、上下文切换更少。消息链路方面,全新的 WebSocket Agent 网关支持实时推送响应,整体对话延迟更低
Bot 连接能力也扩展到了更多平台。飞书、Slack 和 QQ 已支持 WebSocket 连接模式,消息传递更稳定。与此同时,上下文调用也更直接:通过 `@` 提及即可触发技能和工具,并完成直接上下文注入,逐步替代以斜杠命令为主的旧方式。
为了让长期使用时的导航更清晰,技能商店新增专属「技能」标签页,系统也会每 4 小时自动创建新话题,帮助你持续整理会话上下文
- **AI 自动补全。** 编辑器会在你输入时给出补全建议,撰写消息更快
- **实时 Agent 网关。** 全新的 Agent 网关以更低延迟流式返回响应。
- **更多 Bot 平台。** 飞书、Slack 和 QQ 已支持更稳定的连接模式。
- **通过 `@` 调用技能与工具。** 用 `@` 提及即可触发技能和工具并直接注入上下文,取代以斜杠命令为主的旧方式。
- **独立的「技能」标签页。** 技能商店新增专属「技能」标签页。
- **自动创建话题。** 系统每 4 小时自动创建新话题,帮助整理会话。
## 体验优化
- 助理文档现在支持渐进式加载,内容就绪时即时展示,不再阻塞整个页面
- 修复了图片生成按钮错误默认选择模型的问题
- 优化了粘贴性能,防止在粘贴大量剪贴板内容时聊天输入框卡顿
- 加强了安全性,清理了 HTML 工件并修复了一个认证绕过漏洞
- 助理文档支持渐进式加载,内容就绪展示,不再阻塞整个页面
- 粘贴大量剪贴板内容时,聊天输入框不再卡顿。
## 问题修复
- 图片生成按钮不再默认选到错误的模型。
- 修复了一个认证绕过漏洞,并清理了 HTML 工件。

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