Compare commits

..

203 Commits

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:41:59 +08:00
Rdmclin2 e5adb393b3 feat: workspace device support (#16134)
* feat: workspace device support

Squashed commits:
- chore: update device constraint
- chore: local system support remote device workspace
- fix: reconnect time
- fix: workspace connect daemon parameter
- fix: device list refresh
- chore: device admin
- feat: support workspace device
- feat: device list support workspace
- feat: add workspaceId to device gateway

* chore: update i18n rc

* 🐛 fix(workspace): narrow wsOwnerProcedure ctx.workspaceId to string

The OSS stub left `wsOwnerProcedure = authedProcedure`, so consumers
saw `ctx.workspaceId: string | null | undefined` and broke type-check
in `lambda/device.ts` (signWorkspaceDeviceToken / DeviceModel).
Add a guard middleware that throws + narrows the type, matching the
cloud override semantics.

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

* chore: update i18n files

* fix: remote device test case

* fix: device workspaceId request

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-21 00:41:06 +08:00
Arvin Xu 434c16c49d 🔖 chore(cli): bump @lobehub/cli to 0.0.34 (#16139) 2026-06-21 00:35:51 +08:00
Arvin Xu 52d2306793 feat(verify): ingest path for standalone verify run (#16132)
*  feat(verify): ingest path for standalone sessions (createRun / ingestResult / uploadEvidence / upsertReport + CLI)

Add the server + CLI surface to persist a verification session that isn't a live
Agent Run — e.g. the agent-testing harness pushing its local report into the
verify tables so it can be reviewed in-app.

- model: VerifyCheckResultModel.upsertByCheckItem (insert-or-update on the stable
  (verifyRunId, checkItemId) key, for direct verdict ingest); VerifyRunModel.query
  (recent sessions).
- router (verify): createRun / getRun / listRuns; ingestResult (verdict +
  toulmin/suggestion, derives status); uploadEvidence + listEvidence (anchored on
  checkResultId, accepts an already-uploaded fileId or inline content);
  upsertReport / getReport. All keyed by verifyRunId, no operation required.
- CLI: `lh verify ingest-report <dir>` reads result.json + report.md + assets,
  creates a session, ingests each case as a check result, uploads its evidence
  files, and writes the report (`--open` prints the in-app URL). Plus
  `lh verify upload-evidence` (LOBE-10618) for one-off artifact attach.
- tests: upsertByCheckItem insert-then-overwrite.

Stacked on the verify_runs schema branch.

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

*  feat(verify): standalone report viewer page (/verify-report?id=<verifyRunId>)

Make an ingested verification session viewable in-app without an Agent Run / chat
context — the missing half of the agent-testing ingest flow.

- server: verify.getReportBundle({verifyRunId}) returns { run, report, results }
  in one call, with each result's evidence and a resolved (signed) file URL per
  file-backed artifact (via FileService.getFullFileUrl).
- client: verifyService.getReportBundle + useVerifyReportBundle SWR hook + key.
- ui: Verify/ReportViewer renders the verdict/stats header, each check result with
  its inline evidence (image / text), and the full markdown report.
- route: /verify-report registered in desktop + desktop.desktop + mobile router
  configs (sync test green).
- cli: `lh verify ingest-report --open` now prints /verify-report?id=… (the prior
  /verify-im hint was wrong — that route is the messenger account-linking page).

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

* ♻️ refactor(verify): use path param /verify/:runId for report viewer

Switch the standalone verification-report route from the query-based
/verify-report?id=<id> to a path param /verify/:runId, so the URL is
cleaner and shareable. ReportViewer now reads runId via useParams, the
CLI --open output prints the new path, and the route file moves to
src/routes/verify/[runId]/index.tsx across all router configs.

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

* 🐛 fix(verify): use path-param route /verify/:runId + allowlist it in the SPA matcher

Verified the ingest→viewer flow end-to-end against a local full-stack env and
fixed what that surfaced:

- route: /verify-report?id= → /verify/:runId (path param); ReportViewer reads
  useParams; registered in all three router configs.
- proxy: add `/verify/(.*)` to the SPA route matcher (src/proxy.ts) — without it
  the server 307-redirects unknown paths to `/`, so the viewer never loaded.
- cli: ingest-report no longer double-writes the case observation (was set on both
  `suggestion` and `toulmin.evidence`, rendering twice); observation → toulmin,
  `suggestion` reserved for an actual remediation hint. --open prints /verify/<id>.

E2E confirmed: lh verify ingest-report created a standalone session
(operation_id=NULL) + 3 results + report; /verify/:runId renders it in-app.

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

*  feat(verify): atomize CLI into per-entity commands (run / result / evidence / report)

Per review: break the verify CLI into atomic, per-entity capabilities so each can
be called directly, with the report ingest kept as an aggregate convenience.

- cli: new command groups —
  - `verify run create | list | get`
  - `verify result ingest | list (--run | --operation)`
  - `verify evidence upload (--file | --content) | list`
  - `verify report upsert | get`
  Renamed the agent-path executor `verify run <op>` → `verify execute <op>` to free
  the `run` noun for the session entity; `verify ingest-report` stays as the
  aggregate (it just composes the atomic procedures).
- server: add verify.listResultsByRun (run-keyed result list) backing `result list --run`.
- model: VerifyRunModel guards operation_id reservation with assertOperationOwned
  so a run can't hijack another owner's (globally-unique) operation link; test added.

Verified end-to-end against a local full-stack env with real R2 storage: both the
aggregate `ingest-report` and a fully atomic create→ingest→upload(file)→report
chain produce a standalone session that renders at /verify/:runId, with
file-backed evidence served from R2.

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

* 🐛 fix: validate verify ingestion ownership

* 🗃️ chore: remove verify migration backfill

*  test: cover verify ingest run ownership

*  allow public verify report viewing

* 🐛 fix verify report evidence rendering

* 🐛 tolerate missing verify evidence file URLs

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:29:40 +08:00
Arvin Xu 330694fe49 🐛 fix(conversation): surface terminal errors on assistant turns that already streamed content (#16086)
* 🐛 fix(conversation): surface terminal errors on assistant turns that already streamed content

A turn that streamed content + a successful tool call before hitting a
terminal error (e.g. upstream 529 overload) had its `error` silently
dropped on the read side: both the AssistantGroup `ContentBlock` and the
normal `Assistant` message gated the error UI behind empty content, so
the message rendered as if it had completed cleanly — no error banner, no
retry.

Now the error is shown below the content when a turn errors after
producing output, while keeping the existing "error replaces the whole
block" behavior when nothing was streamed.

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

* 🐛 fix(conversation): keep streamed content when dismissing an error alert

The fallback ErrorContent alert is closable and its afterClose unconditionally
deleted the whole message. Now that errors render below already-streamed
content, dismissing the alert would wipe the content it was meant to preserve.

Dismiss now clears only the error (updateMessageError(id, null)) when the
message still has content, and keeps the existing delete behavior for
messages that are nothing but an error.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:26:03 +08:00
Arvin Xu 0230cd0e7f feat(chat): add Manage button to execution device switcher (#16126)
* 💄 feat(linear): render builtin Linear tool cards and drop the Result label

Register the shared LinearRender for the `linear` builtin identifier so
native LobeHub Linear tool calls render the same entity card as the
heterogeneous CC/codex path (previously only inspectors were wired up).

Also remove the redundant "Result" label above the self-explanatory
entity cards / result text in the Linear render.

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

*  feat(chat): add Manage button to execution device switcher header

Add a "Manage" button in the empty right slot of the execution-device
dropdown header that navigates to the device settings page
(/settings/devices). Shown on desktop and on web when no devices exist
(where the small download link isn't rendered).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:39:52 +08:00
YuTengjing e1ff69bfbe feat(runtime): map provider model IDs (#16128) 2026-06-20 23:16:44 +08:00
Arvin Xu 3432707dc9 🐛 fix(hetero): split post-tool answer into its own step when CC reuses the tool message.id (#16127)
* 🐛 fix(hetero): split post-tool answer into its own step when CC reuses the tool message.id

On device/batch (`lh hetero exec`) Claude Code runs, the model's post-tool answer
can arrive under the SAME `message.id` as the preceding `tool_use`. `openMainMessage`
short-circuited on the matching id and emitted no `newStep`, so the answer text
coalesced onto the tool-issuing assistant: text + `tool_use` ended up on one message
and the renderer dropped the tool block below the answer (leaving a trailing empty
assistant shell as the post-tool turn).

Track whether the in-flight turn already emitted a `tool_use`; a later text-only
event that reuses the same id now forces a step boundary so the answer anchors to
its own assistant, chained after the tool results. A normal preamble-text+tool_use
event stays on one step.

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

* 🐛 fix(hetero): distinct replay-stable key + clear user-input flag on forced post-tool split

Addresses review of the same-id post-tool split:

- P1: reusing the tool turn's message.id as the newStep id made the main-agent
  reducer drop it as a REPLAY (id === currentMainMessageId) for any tool turn
  opened by a prior newStep — the split was dropped and the text coalesced
  anyway (the seed-turn case only escaped because the seed has no mainMessageId).
  Stamp a DISTINCT `${id}:s${stepIndex}` key — unique per split, deterministic
  across cold-replica reprocessing — so a fresh assistant is actually opened.

- P2: the forced branch never cleared `hasUnhandledUserInput` (armed by the
  preceding tool_result). In a Monitor flow whose post-tool confirmation reuses
  the tool id, the stale flag survived, so the next stdout callback opened while
  the task was active failed the `!hasUnhandledUserInput` signal check and went
  untagged. Clear it exactly like the normal turn boundary does.

Adds regressions for both (verified RED before the fix).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:03:17 +08:00
Arvin Xu 2cf7b0a824 🗃️ refactor(verify): decouple verify chain from agent_operations via verify_runs (#16129)
The verify chain hung off agent_operations: the plan lived on
`agent_operations.verify_plan` and results/reports keyed on `operation_id`. That
forced every verification — including standalone ones (e.g. the agent-testing
harness ingesting results) — to mint a fake Agent Run, polluting the operation
analytics with rows that carry no real execution trace.

Introduce `verify_runs` as the verification-session entity and make it the
grouping key, via a fully ADDITIVE migration:

- schema: new `verify_runs` (plan snapshot + rollup status + source) with an
  OPTIONAL `operation_id` link to an Agent Run (null for standalone sessions, set
  null on run delete). verify_check_results / verify_reports gain a nullable
  `verifyRunId` (FK → verify_runs, cascade) as the new grouping key; the existing
  `operation_id` is KEPT (relaxed to nullable + ON DELETE set null) as a
  denormalized direct link to the run — no column dropped, no data moved.
  agent_operations.verify_plan / verify_status / verify_plan_confirmed_at marked
  deprecated (kept physically; no ALTER on the analytics table).
- models: new VerifyRunModel owns the plan/status + the operation link
  (ensureForOperation / findByOperation / getStateByOperation keep the legacy
  {verifyPlan,verifyPlanConfirmedAt,verifyStatus} shape). Repointed
  VerifyCheckResultModel (listByRun / updateByCheckItem / backfillTracingId) and
  VerifyReportModel (upsertByRun / findByRun / markReviewed); dropped the dead
  verify methods from AgentOperationModel.
- services + router + runtime resolve operationId→run at the boundary; all
  client-facing contracts (router inputs, getVerifyState response shape) are
  unchanged, so the UI / CLI / client service need no changes. The executor also
  keeps populating the denormalized operation_id on agent-path result rows.
- migration 0113: additive + idempotent — create verify_runs, add nullable
  verify_run_id, relax operation_id (drop NOT NULL + FK → set null). No drop, no
  backfill, no NOT NULL enforcement.
- tests: repointed the three db model tests; deleting an Agent Run now KEEPS the
  report alive (only nulls the links) — asserted.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:42:00 +08:00
Arvin Xu d389e4fe9d ️ perf: dedupe message:list switch-back revalidate with a 30s window (#16131)
The Conversation store is recreated on every topic/session switch and remounts
`useFetchMessages`. At the `useClientDataSWR` default (`dedupingInterval: 0`)
that fires a network revalidate on every single switch — the "正在获取最新消息…"
flash on switch-back.

Now that message mutations write through to the `message:list` cache (#15927),
a switch-back within the dedupe window hydrates from a FRESH cache, so the
refetch is pure redundancy. Set a 30s window: it covers the typical
switch-away-and-back loop while keeping cross-device / server-agent updates
within an acceptable staleness bound (`revalidateOnFocus`'s 5min throttle and
`revalidateOnReconnect` remain the longer-tail backstop; a running conversation
keeps its live gateway stream regardless of this window).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:38:31 +08:00
Arvin Xu 02d1bb307c ️ perf: write message mutations through to the message:list SWR cache (#15927)
* ️ 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>

* 🐛 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>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:07:44 +08:00
Arvin Xu b7579279ab 💄 feat(linear): render builtin Linear tool cards and drop the Result label (#16125)
Register the shared LinearRender for the `linear` builtin identifier so
native LobeHub Linear tool calls render the same entity card as the
heterogeneous CC/codex path (previously only inspectors were wired up).

Also remove the redundant "Result" label above the self-explanatory
entity cards / result text in the Linear render.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:50:08 +08:00
Arvin Xu 3464bba25e feat(chat): auto-generate topic title for gateway & hetero via shared lifecycle (#16119)
 feat(chat): generate topic title for gateway & hetero via shared lifecycle

Fold topic-title auto-generation into the shared `afterUserMessagePersisted`
hook so all three runtimes title new topics consistently (LOBE-10379 "补齐缺列
title"). Previously only the client summarized titles inline in sendMessage;
gateway topics stayed untitled and hetero showed only a sliced placeholder.

- buildRunLifecycle: implement afterUserMessagePersisted (was NOOP) — top_level
  only (sub_agent / compact excluded). For a brand-new topic it awaits
  refreshTopic when the topic isn't in the store yet, because the gateway path
  inserts it via a fire-and-forget refreshTopic and summaryTopicTitle bails on a
  missing topic.
- conversationLifecycle: call the hook from all three branches after the user
  message is persisted — client passes its freshly-created `data.messages`
  (preserving behavior), gateway/hetero let the hook read from the store.
- types: UserMessagePersistedEvent carries optional messages / assistantMessageId.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:59:53 +08:00
Arvin Xu 6c0649e8a0 🐛 fix(bot): keep command-disconnect copy and use provider-neutral system fallback (#16122)
* 🐛 fix(bot): keep command-disconnect copy and use provider-neutral system fallback

Follow-up to the IM agent-failure error rendering change. Now that the lifecycle
event always populates errorType, two regressions surfaced for state-store
(Upstash/ioredis) failures:

- `Command aborted due to connection close` is pattern-refined to
  StateStorePersistError / StateStoreReadError, so renderAgentError's
  command-disconnect special case (which only accepted undefined/`500`) was
  skipped and users got the generic internal-error copy. Widen the type gate to
  accept the refined state-store codes while keeping the message as the signal.
- The `system` attribution fallback rendered errorTransientNetwork, whose copy
  blames the model provider and suggests switching models — misleading for
  state-store reads where no provider/model is involved. Add a provider-neutral
  `errorSystemInfra` string and point the `system` fallback at it; ProviderNetworkError
  (the one provider-related system code) keeps its precise network copy.

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

* 💄 style(bot): collapse hetero device guard into a thin single-line notice

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:13:46 +08:00
Arvin Xu 1ed0ca305b 🗃️ feat(verify): add verify_evidence + verify_reports tables, probe verifier type (#16114)
🗃️ feat(verify): add verify_evidence + verify_reports tables

Phase-1 data layer for delivery verification (LOBE-10615). Pure data layer —
no business/service code changes; existing `verifierTypes` and `ToulminVerdict`
are untouched.

- schema `verify_evidence` (flat, no recursion): payload is either inline
  `content` (small text: dom snapshot / console / transcript) or `file_id`
  (FK → `files`, which already owns mime / size / hash / url — so no
  mime/size/checksum columns here). Both nullable; file_id set null on file
  delete. `description` sits right under `id`.
- schema `verify_reports`: loosely coupled — `operation_id` is nullable (not a
  hard binding), as are `verdict` / `reviewed_by_user` / the stats counts. A
  unique index still keeps at most one report per operation (regenerate
  overwrites in place).
- types: new `VerifyEvidence` / `VerifyReport` domain types + evidence enums;
  schema files export no drizzle-inferred row types — the models consume the
  `@lobechat/types` domain types directly.
- models `VerifyEvidenceModel` (CRUD + flat `listByCheckResult`) and
  `VerifyReportModel` (`upsertByOperation`, `findByOperation`, `markReviewed`),
  with unit tests. Migration 0112 (idempotent).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:49:24 +08:00
Arvin Xu aff3760ec4 refactor(verify): make the verify agent selectable per task (#16121)
*  feat(verify): make the verify agent selectable per task

Replace the hardcoded builtin verify agent with a task-pinned, selectable
agent (TaskVerifyConfig.verifierAgentId). A pinned agent runs by agentId under
its own agency config (executionTarget / device / provider) — so picking a
heterogeneous agent (e.g. Codex) gives the verifier device + browser access —
and is never overridden by the parent run's model/provider. Falls back to the
builtin verify agent (inheriting the parent run's model/provider) when unset or
when the pinned agent no longer exists.

The pinned id is sourced at completion from the operation's task verify config
(resolveVerifyConfig, with subtask inheritance); non-task runs keep the builtin
fallback.

LOBE-10617

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

* 🐛 fix(verify): inject the verify writeback tool for pinned agents

A pinned verify agent runs with only its own configured plugins, so it lacks
the lobe-verify (submitVerifyResult) tool that the builtin verify agent
declares. Without it the agent can't write its verdict back and the check
result is stuck `running`. Inject VerifyToolIdentifier via additionalPluginIds
for the pinned branch only (the builtin already has it, so it isn't re-added).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:42:47 +08:00
Arvin Xu d378f525a5 🐛 fix(bot): surface perceivable agent-failure causes in IM instead of a bare Operation ID (#16120)
When an agent run failed in an IM/bot channel, users only saw an opaque
"Agent Execution Failed / Operation ID: op_..." line — never the root cause.

Two gaps caused this:
- buildLifecycleEvent never populated errorType on the lifecycle event, so the
  bot renderer always hit the opaque fallback (the existing friendly-error map
  was effectively dead code on the bot path).
- The friendly map only covered 8 user-fixable config codes; the highest-volume
  transient failures (network, rate limit, provider outage, timeout) had no copy.

Fix:
- buildLifecycleEvent normalizes state.error via formatErrorForState and emits
  errorType + errorAttribution on the event (AgentHookEvent gains errorAttribution).
- renderAgentError renders in three tiers: precise code copy → attribution-based
  fallback (network / provider / harness / user) → legacy Operation ID. Adds copy
  for ProviderNetworkError, ProviderServiceUnavailable / NoAvailableChannel,
  RateLimitExceeded, ModelEmptyCompletion, ContentModeration, and
  OperationInactivityTimeout (en-US + zh-CN). Operation ID is demoted to a footer.
- Both consumers (AgentBridgeService in-process hook, BotCallbackService webhook)
  forward errorAttribution to the renderer.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:15:45 +08:00
Arvin Xu fd39bbdc1b 🐛 fix(agent): stop hiding agent-documents tool when an agent has zero documents (#16107)
The server tool engine gated `lobe-agent-documents` behind a
`hasAgentDocuments` system rule that sat AFTER the plugin spread in
`agentModeRules`, so it overrode the agent's own plugin selection down to
`false` whenever the agent had no existing (non-deleted) documents.

This created a dead state: once a user deleted every document on an agent,
`hasByAgent` returned false, the tool was dropped from the LLM payload, and
the agent could never create its first document again — it silently fell
back to dumping content as plain chat text. A meeting-memo agent hit exactly
this after the user bulk-deleted its 16 documents.

Drop the override so the tool is governed by the normal plugin-enable layer
(the inbox agent injects it into default plugins, and users keep it in
`agentConfig.plugins`). Also remove the now-unused `hasAgentDocuments` param
from `ServerCreateAgentToolsEngineParams` and its call site; the
`hasDocuments()` DB lookup stays for operation telemetry only.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:11:52 +08:00
Arvin Xu 45d75dbaad feat(task): add task-level verify config storage layer (#16113)
*  feat(task): add task-level verify config storage layer

Introduce the Phase 1 storage layer for task-bound delivery-acceptance
(verify) config (LOBE-10616 / LOBE-10614 §2 §6):

- types: add `TaskVerifyConfig`, expose `verify` on `TaskDetailData`, mark
  the legacy `review` field deprecated
- model: `getVerifyConfig` (with migration-period fallback to the legacy
  `review` key), `resolveVerifyConfig` (subtask inheritance with whole-config
  override), `updateVerifyConfig`
- router: `task.getVerifyConfig` / `task.updateVerifyConfig` procedures

No DB migration — config lands in the `tasks.config` JSONB. Legacy
`getReview`/`updateReview` are kept until taskReview is retired (Phase 2).

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

* 🐛 fix(task): let updateVerifyConfig clear saved verify ids

Deep-merge + JSON's inability to send `undefined` meant a saved
`verifyRubricId` / `verifierAgentId` could never be removed — omitting a
field preserved the stale value, blocking a switch back to the default or a
criteria-only config.

`updateVerifyConfig` now does a controlled per-key patch over `config.verify`:
`null` clears the key, omission leaves it untouched, a value sets it (arrays
replace wholesale). Router input accepts `.nullish()` so callers can send
`null` to clear.

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

* ♻️ refactor(task): surface verify in task detail and drop legacy review field

Address PR review:

- getTaskDetail now populates `TaskDetailData.verify` via getVerifyConfig, so
  clients see saved verify config after updateVerifyConfig (was left undefined,
  unlike the old review field).
- Remove the deprecated `TaskDetailData.review` field and its readers: the
  dead `activeTaskReview` selector, the `updateReview` store action's stale
  optimistic dispatch, and the review block in the viewTask / task-run prompt
  formatters (review is retired; verify is the converged gate). The separate
  TaskRunPromptInput.review surface is untouched.

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-20 18:43:00 +08:00
Arvin Xu 3ed3ac02e5 feat(agent-tasks): add agent-scoped task list and refine task UI (#16111)
*  feat(agent-tasks): add agent-scoped task list and refine task UI

- add /agent/:aid/tasks route showing one agent's tasks (both router configs)
- breadcrumb: show "All tasks › agent › task" with agent crumb linking to its task list
- sidebar tasks group: surface "view all" action + show for heterogeneous agents
- hide model select for heterogeneous-agent tasks (external runtime owns the model)
- lock assignee to the scoped agent in inline/modal/empty-state create entries
- reset shared list state on scope change so the all-tasks view no longer bleeds a single agent's tasks

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

*  feat(agent-management): describe hetero agent runtime in getAgentDetail

getAgentDetail now surfaces a runtime descriptor for heterogeneous agents
(Claude Code, Codex, etc.) so an orchestrator can judge their real
capabilities. Such agents bring their own toolset and ignore the chat
model/plugins, so reporting only model/provider was misleading.

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

*  feat(agent-tasks): show running-op status + collapse toggle in topic drawer reply

Mirror Fleet's ReplyArea in the task topic drawer FeedbackInput:
- surface the live OpStatusTray (seamless) above the reply affordance so the
  running agent is visible without expanding the composer
- add a collapse toggle (taskDetail.collapseReply) when the composer is expanded

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

*  feat(agent-tasks): render task detail in chat Portal + fix createTask state

- Add PortalViewType.TaskDetail with openTaskDetail/closeTaskDetail + selectors
- New Portal/TaskDetail view (Title/Body) reusing TaskDetailSections
- Extract TaskDetailSections + shared useActiveTaskDetail hook (page + Portal)
- CreateTask card: auto-open Portal on creation + click to open/toggle detail
- Fix server createTask runtime dropping the task identifier from tool state,
  so the inline card and Portal can link to the created task
- Front-load the assignee agent config as a hard loading gate; TaskModelConfig
  now resolves model / heterogeneous runtime from the assignee, not whatever
  agent is active in the surrounding chat
- Fix first-paint 404 flash by using the fetch error as the not-found signal

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

*  feat(agent-tasks): show heterogeneous runtime tag on task assignee

- Extract reusable HeterogeneousTag (single source for the runtime pill)
- Render it on the assignee picker rows and the task-detail assignee chip,
  matching the home agent list
- Refactor home AgentItem to use the shared tag (no behavior change)

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

* 🐛 fix(agent-tasks): propagate agent scope into kanban + sync locked inline assignee

- KanbanBoard takes an agentId prop: scoped group-list fetch + scoped/locked create modal, so /agent/:aid/tasks kanban shows only that agent (and stops listAgentId flip-flopping between the agent id and __all__)
- CreateTaskInlineEntry syncs its hidden assignee to agentId when locked, so reusing the route subtree across /agent/A/tasks -> /agent/B/tasks no longer submits to A

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

*  feat(agent-tasks): remember sidebar task/topic section expand state per agent

Make the agent sidebar's Tasks/Topic accordion controlled and persist its
expanded state per agentId in systemStatus (localStorage), so switching agents
keeps each one's own collapse/expand layout instead of sharing a single
accordion. Stored as nested booleans (not a key array) so updateSystemStatus's
lodash merge replaces scalars cleanly instead of index-merging arrays.

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

*  test(agent-tasks): fix Breadcrumb react-router mock after refactor

Breadcrumb now uses useParams + WorkspaceLink + useAgentDisplayMeta; add the
missing react-router useParams export and mock WorkspaceLink / useAgentDisplayMeta
so the breadcrumb-link assertions render predictable hrefs.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:42:41 +08:00
Arvin Xu de51d13d58 ♻️ refactor(chat): wire gateway and hetero into the shared run lifecycle (#16102)
* ♻️ refactor(chat): wire gateway and hetero into the shared run lifecycle

Route the gateway and hetero sendMessage runtimes through the shared
`buildRunLifecycle` so all three runtimes settle terminal side effects in a
consistent order (LOBE-10379 [4b]/[4c]).

- gateway: agent_runtime_end → completeRun({status}); error → fail; add the
  queue drain + desktop notification; drop the duplicate completeOperation in
  onSessionComplete (now a terminal-missing fallback via terminalReceived).
- hetero: completion notification moves to afterRunComplete; heteroSessionId
  save is best-effort (no longer blocks the queue drain); the reused gateway
  handler runs as runtimeType:'hetero' (reconciliation only).
- lifecycle: cancelled completes the op for gateway/hetero; afterRunComplete is
  the single notification home for all runtimes; drop the chat-store value
  import so gateway can statically import buildRunLifecycle without a cycle.

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

* 🐛 fix(chat): gate sub-agent gateway terminals from top-level run effects

A nested Agent/Task sub-agent dispatched via gateway runs with
context.scope: 'sub_agent', but completeRun/afterRunComplete did not inspect
runScope — so a sub-agent completion could drain the parent's input queue or
fire a desktop completion notification before the parent run finished.

Honor the RunScope contract centrally (fixes client + gateway uniformly): skip
the input-queue drain in completeRun and the notification in afterRunComplete
when runScope === 'sub_agent'. The op still completes so the sub-agent loading
clears.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:57:47 +08:00
Arvin Xu 88a7e82ab8 ⬆️ fix(model-runtime): upgrade openai SDK to v6 to drop node-fetch@2 (#16090)
OpenAI SDK v4 bundles node-fetch@2, whose gzip response-stream handling
breaks under the Node.js June 2026 security release and throws
ERR_STREAM_PREMATURE_CLOSE (surfaced as "LLM stream error: Premature
close" across all providers). SDK v6 uses native fetch, removing the
node-fetch@2 dependency from the LLM call path.

Migration touch-ups for v6's type changes:
- narrow `apiKey` back to `string` (v6 widened it to `string | ApiKeySetter`)
- access `.function` via the function-tool variant (`ChatCompletionTool` /
  `ChatCompletionMessageToolCall` are now function|custom unions)
- pass real `Headers` instead of plain objects to `APIError` in tests
  (v6 calls `headers.get()`)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:57:56 +08:00
Sui_Feng 2731937feb 🔨 chore(docker-compose): Explicitly enable pg_search in the Paradedb container (#16096)
Enable pg_search in Postgres containers

Start Postgres with shared_preload_libraries=pg_search in three docker-compose files (deploy, dev, production/grafana). This adds a command to the Postgres service so the pg_search library is preloaded on startup, ensuring the pg_search extension is available for full-text search features.
Related Links: https://github.com/paradedb/paradedb/pull/4914
2026-06-20 14:44:14 +08:00
AmAzing- 25d582827c feat(analytics): track home footer menu clicks and exposure (#16088)
*  feat(analytics): track home footer menu clicks and exposure

* 🐛 fix(analytics): align footer menu-open keys with click-tracked items

Billboard menu items were included in home_footer_menu_opened keys but
never emit home_footer_menu_clicked, producing per-key CTR denominators
that can never have matching clicks. Report exposure only for the items
wrapped by injectMenuTracking (own items); billboard entries keep their
own billboard_* events.
2026-06-20 12:50:39 +08:00
Arvin Xu bd034c3aef 🐛 fix(bot): warn when a WeChat file attachment can't be retrieved instead of silently dropping it (#16087)
WeChat (iLink/ClawBot) relays oversized files as metadata only — the inbound
FILE item carries no downloadable CDN media descriptor (no encrypt_query_param),
so `downloadMediaFromRawMessage` silently breaks and produces no attachment.
`extractFiles` then returned nothing, no file was ingested, and the only thing
the model saw was the bare `[file: name]` text placeholder that the adapter's
`extractText` always emits. The model then hallucinated around the filename —
e.g. for a 132 MB .mp3 it replied that it "couldn't hear" the audio it never
actually received. Nothing surfaced this: the package helper's own warn is a
no-op here (no logger passed), so there was no log and no user-facing signal.

extractFiles now walks the raw item_list and, for any FILE item that wasn't
downloaded, returns a warning (with size hint) via the existing
ExtractFilesResult.warnings channel — already injected into the context engine
and surfaced to the user (same mechanism Telegram uses for its 20 MB cap).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 12:27:31 +08:00
lobehubbot 80a06e047c Merge remote-tracking branch 'origin/main' into canary 2026-06-20 04:23:58 +00:00
Arvin Xu 0eeab3368a 🚀 release: 20260620 (#16080)
# 🚀 LobeHub Release (20260620)

**Release Date:** June 20, 2026
**Since v2.2.6:** 88 merged PRs · 7 contributors · 1 database migration

> This week pairs a workspace-scoped database migration with broad
runtime and UX work — native ASR transcription, audio attachments for
audio-capable models, a smarter input-completion prompt, and a long list
of reliability fixes.

---

## 🗄️ Database Migration (Action for Self-Hosted)

This release ships migration
**`0111_workspace_device_and_ai_infra_surrogate_pk`** (#16065). It is
the headline change for operators — read this before upgrading.

### What changes

- **`ai_providers` / `ai_models`** — adopt a surrogate `_id` primary key
and workspace-scoped partial unique indexes, so provider/model identity
is unique per workspace rather than per user.
- **`devices`** — the old `(user_id, device_id)` unique is replaced by
two workspace-scoped partial uniques, letting personal and
workspace-enrolled devices live in independent identity spaces.
- **`workspaces`** — adds the freeze trio `frozen` (boolean, default
`false`), `frozen_reason` (text), `frozen_at` (timestamptz) backing
workspace freeze risk control.

### Operator impact

- **Backwards-compatible and additive** — every statement is guarded
(`IF EXISTS` / `IF NOT EXISTS` / catalog checks). It is a **NO-OP** on
cloud production (where the `ai_infra` side was already applied online)
and a **full rebuild** on fresh / self-hosted databases.
- **No manual SQL required** — the migration runs automatically on
application startup.
- Schedule the rollout in a low-traffic window and **take a backup
snapshot before deploying**.
- If the migration fails, do not retry repeatedly — inspect migration
logs and lock state first.

### Rollback

Migration is additive only (new columns / indexes / surrogate PK). If
your environment requires strict reversibility, follow your standard DB
restore or migration-rollback policy from the pre-deploy snapshot.

---

##  Highlights

- **Native ASR transcription** — new ASR (transcribe) runtime with
OpenAI + Gemini-native backends, exposed via tRPC and the CLI (incl.
remote audio URLs). (#15992, #15987)
- **Audio in chat & agent mode** — pass audio (mp3) attachments to
audio-capable models like Gemini, with upload allowed in agent mode and
a waveform player on render. (#15988, #16022)
- **Input completion v1.2** — reworked completion prompt that predicts
intent, including long-range continuations. (#15961)
- **Market recommendations** — task-template recommendations from
Market, plus skill recommendations during agent creation. (#15558,
#16016)
- **Model intelligence** — inject model knowledge cutoff into context
and support GLM-5.2 reasoning effort. (#16048, #15972)
- **Topic lifecycle** — persist topic unread as a backend status and
show a `[Draft]` hint on topics holding unsent input. (#16056, #15996)
- **Workspace-scoped devices** — distinguish local vs. gateway device
targets, add a recent-directory button, and auto-activate a device for
bot triggers on a local target. (#15921, #16040, #16032)

---

## 🏗️ Core Agent & Model Runtime

- Rework input completion prompt to v1.2 — predict intent, including
long-range. (#15961)
- Inject model knowledge cutoff into context. (#16048)
- Support GLM-5.2 reasoning effort. (#15972)
- Derive OpenAI model routing from IDs. (#16046)
- Rename model type `stt` to standard `asr` (runtime migration,
non-breaking). (#16002)
- Persist model config cache for faster startup. (#16047)
- Hetero-agent: assistant-anchored message-chain write spine. (#15930)
- Stop chat mode from auto-injecting local-system tools. (#15981)
- Centralize inbox agent meta fallback across read paths. (#15948)
- Expose missing usage diagnostics. (#15973)

---

## 🎙️ Audio & ASR

- Add ASR (transcribe) with OpenAI + Gemini-native backends, exposed via
tRPC. (#15992)
- Pass audio (mp3) attachments to audio-capable models like Gemini.
(#15988)
- Allow audio upload in agent mode and render audio with a waveform
player. (#16022)
- CLI: support remote audio URLs in `generate asr`. (#15987)
- Remove the deprecated `/webapi/stt` route and frontend mic entry.
(#15999)
- **bot:** recover MIME type for QQ c2c file attachments so audio
reaches the model. (#16063)

---

## 📱 Devices, CLI & Integrations

- Distinguish local and gateway device targets. (#15921)
- Inject device-bound working directory into the server local-system
tool chain. (#15887)
- Add a recent-directory button to the device detail panel. (#16040)
- Auto-activate a device for bot triggers on a local target. (#16032)
- Tolerate CRLF line endings when editing files & surface real edit
errors. (#16061)
- CLI: stop connect daemon on logout. (#16038)
- Subscription: add contextual plans modal header i18n keys. (#16025)
- Linear: render `get_issue` result as a card instead of raw JSON.
(#15953)

---

## 🖥️ Chat & User Experience

- Collapse FloatingChatPanel into a slim strip by default. (#15991)
- Recommend Market skills during agent creation. (#16016)
- Show a `[Draft]` hint on topics holding unsent input; persist topic
unread as a backend status. (#15996, #16056)
- Polish chat-input attachment menu, agent mode & status hints; polish
skill suggestion modal UX. (#16010, #16027)
- Lead collapsed tool-call summary with total call count. (#16003)
- Clarify skill installation flow. (#16034)
- Agent-documents: present documents as clickable links; harden explorer
tree, titles, empty state & default tab. (#15989, #15998, #16005)
- Make working-directory clear button actually clear; hide Codex cloud
config tab. (#16019, #16037)
- SPA: bootstrap app initialization; add use-switch-workspace hook.
(#15937, #15979)
- Desktop: require re-auth for expired sessions; close DevTools with
window shortcut. (#16014, #16023)
- Add a product glossary page. (#16001)

---

## 🔒 Reliability & Security

- **Security:** skip Market trusted token for synthetic eval userIds.
(#15959)
- Treat parked runs as non-terminal in the client run-lifecycle.
(#16072)
- Classify upstream-message fallbacks out of `UpstreamHttpError`;
converge 2nd residue wave + unmap payload-too-large from ECW. (#16024,
#16053)
- Preserve gateway error semantics and event body. (#16058, #16069)
- Hetero-agent: gate CC usage-limit guide on rejected status; don't
treat an inline tool's `task_notification` as a post-task summary.
(#16042, #16050)
- Load snapshot store via static import, not dynamic require. (#15982)
- Reject duplicate custom model ids; allow same-user generic edit-lock
updates. (#15975, #15969)
- Avoid flashing unauthorized state in the tool auth alert; avoid
task-template locale key flash. (#16068, #16071)
- Prevent blank screen when switching language; resolve resource header
i18n namespace fallback. (#15977, #16066)
- Provide Market auth to SPA modals; suppress agent-mode notice for CLI
agents. (#16045, #16044)
- Handle generated asset metadata dedup; cache & gate task-template
recommendations. (#16057, #15993, #16013)
- userMemories: delete persona document when clearing all memories.
(#15997)

---

## 🔧 Tooling & Maintenance

- Upgrade `react-router` to v8. (#16029)
- Use server config for business features; remove Fable campaign paths
and client-side community publish/unpublish entries. (#16015, #15960,
#15986)
- Drop dead `markUserValidAction` business slot. (#16018)
- Expose `memory-user-memory` prompts subpath exports; fix skill audit
symlink inventory. (#15985, #16039)
- Translate non-English comments to English in scripts; clean up
LOBE-XXX markers. (#16000, #15943, #16031)

---

## 👥 Contributors

Huge thanks to **7 contributors** who shipped **88 merged PRs** this
cycle.

@hardy-one · @arvinxx · @tjx666 · @AmAzing129 · @rivertwilight · @Innei
· @Rdmclin2

Plus @lobehubbot and renovate[bot] for maintenance.

---

**Full Changelog**: v2.2.6...release/weekly-20260620-recut
2026-06-20 12:18:16 +08:00
Arvin Xu bb3e689053 🐛 fix(conversation-flow): fold toolless turn-head into the tool chain group (#16084)
* 🐛 fix(conversation-flow): fold toolless turn-head into the tool chain group

A hetero-agent turn often opens with a toolless narration step streamed in
reply to the user before the first tool call, with the tool-using step as its
direct child. The flat-list dispatcher only opened an AssistantGroup when the
message itself carried tools, so the toolless head fell through to the regular
message path and rendered as its own standalone bubble while the tool step
opened a separate group — the UI showed two disconnected assistant cards (a
visually broken chain).

Add MessageCollector.isToolChainHead to detect a toolless turn-head (parent is
a user message, single same-agent continuation that calls tools) and let it
seed the AssistantGroup. Scoped narrowly to turn heads so mid-chain toolless
steps and branch handling are untouched; walks via childrenMap to stay
O(chain).

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

* 🐛 fix(conversation-flow): only fold a toolless head whose next step calls tools

A multi-step toolless prelude (>1 prose step before the first tool call) was
mis-classified as a tool-chain head: the walk advanced through the intermediate
prose and returned true, but collectAssistantChain stops at the first toolless
continuation (it only recurses through tool-using steps), yielding a tools-less
assistantGroup with the tool step still split off.

Restrict isToolChainHead to heads whose IMMEDIATE same-agent continuation
already carries tools; a multi-prose prelude now falls back to standalone
bubbles instead of a malformed group. Add a regression asserting no tools-less
assistantGroup is produced for that shape.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 12:16:14 +08:00
Arvin Xu 54ea8f62a2 chore: clean up LOBE-XXX code markers (2026-06-20) (#16079)
chore: clean up LOBE-XXX markers from source code

- packages/database/src/schemas/aiInfra.ts: replace LOBE-10056 comment
  markers with descriptive migration context (0110 surrogate PK)
- packages/builtin-tools/src/codex/mcpToolUtils.test.ts: replace
  LOBE-10205 test fixture IDs with generic TEST-0000
- src/features/DevPanel/RenderGallery/fixtures/claude-code.ts: same
- src/features/DevPanel/RenderGallery/fixtures/codex.ts: same

Co-authored-by: Arvin Xu <arvinxx@lobehub.com>
2026-06-20 11:52:28 +08:00
Arvin Xu d37ebb2922 ♻️ refactor(chat): make run-lifecycle completeRun transport-driven (#16083)
Generalize the shared `buildRunLifecycle.completeRun` so gateway/hetero can drive
it, without changing client behavior — the enabling step before wiring the other
two transports onto the shared lifecycle.

- Add `resolveTerminalDisposition`: derive the effective terminal disposition
  (`success` / `failed` / `cancelled`) from EITHER the client's raw
  `runtimeStatus` (`AgentState['status']`) OR the normalized cross-runtime
  `status` ('completed' / 'failed' / 'cancelled') that gateway/hetero supply.
  `completeRun` now branches on the disposition, so the same side effects
  (afterCompletion → queue drain → completeOperation/failOperation → markUnread)
  fire regardless of which transport reached the boundary.
- Gate the client-only `client.runtime.complete` source event to
  `runtimeType === 'client'` — gateway/hetero emit their own `client.gateway.*`
  events at their transport boundaries and must not double-emit here.

Client path is byte-equivalent (done→success, error→failed, interrupted→cancelled
map to the previous branches). No caller change yet; gateway/hetero are wired in
the follow-up commits. Part of LOBE-10379.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Supersedes #15533 and #16004.

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

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

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

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

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

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

This reverts commit 45a253b8f8.

---------

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

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

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

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

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

Address two review findings on the persisted unread status:

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Pure rename + doc clarification, no behavior change.

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

LOBE-10440

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

* 🐛 fix: restore gateway device option

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

Two review fixes for the agent-document panel:

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* 🔥 refactor(context-engine): remove ActiveTopicDocumentContextInjector

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

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

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

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

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

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

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

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

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

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

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

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

This reverts commit 9419ee7de4.

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

Prompt version stays v1.2 (still unreleased).

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- add unit test for the oversized-inline rejection

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

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

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

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

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

---------

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

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

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

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

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

Address two review findings on the audio attachment path:

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

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

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

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

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

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

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

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

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

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

Two gaps surfaced after enabling the `audio` ability:

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

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

---------

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

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

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

* Update package.json

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: add translation

---------

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

* 🧪 fix(e2e): mock community marketplace APIs

* 💬 docs(e2e): explain community marketplace mocks

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

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

* ♻️ refactor: trim bootstrap registration diff

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

* update

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

---

##  Highlights

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

---

## 🏗️ Core Agent & Architecture

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

---

## 📱 Devices & Platforms

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

---

## 🖥️ User Experience

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

---

## 🔒 Reliability

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

---

## 👥 Contributors

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

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

---

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

*  add agent document share URLs

*  add standalone agent document page

*  open agent documents as pages

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

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

* 🐛 fix: restore agent document portal opening

* 💄 style: adjust agent document header actions

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

* feat: add page agent aquire lock

* feat: add hook self locker

* feat: add page copilot lock

* fix: session cope lock

* feat: add page draft and editor state fresh

* fix: multiple same person edit lock and recover

* fix: edit same tab

* chore: update i18n files

* fix: lint error

* fix: edit lock owner check

* chore: reuse owner id

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Make the running board a live, durable overview:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

* 🍱 docs: add cover image for June 15 changelog

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

---------

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

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

Two root causes fixed:

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

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

Closes LOBE-10441

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Two root causes fixed:

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

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

Closes LOBE-10441

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

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

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

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

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

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

---------

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* 🐛 fix(fleet): align topic creation params

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs LOBE-10402.

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

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

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

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

Refs LOBE-10402.

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

---

##  Highlights

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

---

## 🏗️ Core Agent & Architecture

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

---

## 🔌 Integrations & Skills

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

---

## 🖥️ Chat & User Experience

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

---

## 🔒 Reliability

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

---

## 🔧 Tooling & Internal

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

---

## 👥 Contributors

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

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

Plus @lobehubbot and renovate[bot] for maintenance.

---

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

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

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

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

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

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

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

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

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

Addresses Codex review feedback on #15872.

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Part of LOBE-10376
Closes LOBE-10378

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Changelog history left untouched. Pure rename — no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(composio): remove duplicate composio key in CustomPluginParams

The klavis→composio rename collapsed the deprecated klavis param block onto
the live composio one, producing a duplicate `composio` property. The klavis
shape (instanceId/serverName/serverUrl/isAuthenticated) is dead — no code reads
it — so drop it and keep the live composio shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(composio): let pending/errored connections re-authorize or be deleted

A Composio connection link (lk_...) expires after a while. Previously a
pending/errored row only offered to reopen the stored — now expired —
redirectUrl, and the delete action existed only for ACTIVE connections, so an
expired link left the tool permanently stuck: unauthenticatable and
unremovable.

- Add reauthorizeComposioConnection store action: best-effort delete the stale
  connection, then mint a fresh link (replaces the record in place)
- Settings skill item + chat toolbar item: PENDING/ERROR now render a ··· menu
  with Re-authorize (fresh link) and Delete
- Onboarding: pending/errored row click re-mints a fresh link instead of
  reopening the stale one
- i18n: add tools.composio.reauthorize (en-US + zh-CN)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(composio): return auth link instead of opening popup from agent

connectComposioService runs from the agent's response, which carries no user
gesture, so window.open was blocked by the browser and the flow always failed
with "Authorization was cancelled or timed out". Instead of opening the popup
ourselves, return the authorization redirectUrl in the tool result so the agent
can surface a clickable link — the user's click is a real gesture and completes
the OAuth normally. Drops the now-unused popup/poll helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 💄 fix(composio): match pending toolbar item to sibling authorize affordance

The ··· dropdown I added to the chat-toolbar Composio item was a bare icon
(inconsistent color/size with the app's standard menus), its popup was
mis-anchored/offset, and replacing the visible "authorize" cue with a ···
made an un-authorized (pending) row look connected.

Match the sibling LobehubSkillServerItem instead: render a clickable
"Re-authorize" text + external-link icon for PENDING/ERROR. Clicking re-mints a
fresh link (the prior one may have expired) and opens it. No dropdown, so no
offset; the explicit affordance makes it clear the row still needs auth. Delete
stays on the settings page (siblings have no inline delete here either).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:53:48 +08:00
Arvin Xu f362dcb5db 🐛 fix(agent-runtime): harden async sub-agent suspend/resume against missed wakeups (#15855)
* 🐛 fix(agent-runtime): harden async sub-agent suspend/resume against missed wakeups

The server callSubAgent async park/resume chain (#15481) had a one-shot,
no-retry recovery: a single transient miss left the parent stuck in
waiting_for_async_tool forever. Harden the resume barrier and watchdog
(LOBE-10385 parts 1-3, 5; the park-side deadline fallback follows separately):

- Read-your-writes barrier: completeSubAgentBridge passes the just-backfilled
  toolMessageId to the barrier, which trusts that local write instead of
  re-reading message_plugins from a possibly-stale read replica.
- Bounded backoff watchdog: verifyAsyncToolBarrier now re-arms with exponential
  backoff (15s→30s→60s→120s→240s, 5 attempts) until the barrier passes or the
  op is terminal, replacing the single 15s shot that never re-armed.
- Plug silent bails: !state and pending.length===0 now warn + emit a metric;
  the empty-pending case also arms a fallback verify for snapshot-persist lag.
- Observability: new agent_runtime_async_tool_resume_total counter keyed by
  outcome (resumed/barrier_held/no_pending/no_state/lost_cas/verify_exhausted)
  so missed wakeups surface instead of accumulating silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(hetero): reconstruct queued upload files from filesPreview on run continuation

When continuing a heterogeneous agent run with remaining queued messages, rebuild
the upload file items from filesPreview metadata instead of passing bare { id }
stubs, so file context (name/type/preview) survives the continuation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:50:05 +08:00
Arvin Xu 3c43d55c69 🐛 fix: render mention name from serialized attribute instead of falling back to unknown (#15831)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:46:19 +08:00
Arvin Xu fa17f00d56 ♻️ refactor(swr): converge remaining store-layer SWR keys (discover/tool/global/userMemory) (#15853)
♻️ refactor(swr): converge remaining discover/tool/global/userMemory store keys

Completes the store-layer SWR key convergence into the central registry
(batch3 only partially covered discover). Migrates the remaining ~39 ad-hoc
keys:

- discover: model/plugin/provider/skill/mcp/groupAgent list+detail+categories
  and user profile (the `.join('-')` string keys → registry array factories).
- tool: agentSkills, installedPlugins, builtin uninstalled-tools, lobehubSkill
  store, mcpPluginList, klavis store. (The dynamic `plugins`-array key is left
  as-is — it's data-derived, not a named key.)
- global: latest/server version, system status.
- userMemory: retrieve / memoryDetail / activities / contexts / experiences /
  identityList / preferences. The `purgeAllMemories` invalidation was rewritten
  from `startsWith('useFetch…')` string matchers to array `key[0] === *.root`
  matchers, in lockstep with the fetch keys.

No tiering/caching change: all new prefixes (discover/tool/global/userMemory)
are kept out of CACHE_TIERS, so everything stays memory-only as before.
Behavior preserved (key identity, mutate match sets, personal-vs-workspace).
UI-layer keys + the cross-layer `cronTopicsWithJobInfo` remain for the next PR.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:12:35 +08:00
Arvin Xu c9c57bb7ba 🐛 fix(hetero): dedupe subagent thread create on cold replica after finalize (#15849)
The subagent run coordinator keys thread creation purely on the in-memory
`runs` map. On a cold serverless replica / BatchIngester retry the map is
empty, and `refreshSubagentRunsFromDb` only rehydrates `Processing` isolation
threads — a spawn that already finalized (thread flipped `Active`) is excluded.
So a replayed first-event for a finished subagent hits the `!existing` branch of
`ensureRun` and forks a SECOND thread with the identical title ("一模一样的两个
thread"). Sibling of #15838 (main-turn) / #15808 (subagent-turn), but for the
thread-create step.

Fix: give thread creation a DB-homed, status-independent idempotency guard keyed
by `sourceToolCallId`.
- `SubagentRunsState` gains `finalizedParents: Set<string>`; `finalizeRun`
  records the parent there (instead of just deleting the run), so `ensureRun`
  returns a no-op for a replayed finished spawn — no duplicate thread or message.
- `refreshSubagentRunsFromDb` seeds `finalizedParents` from this operation's
  `Active` isolation threads (without resurrecting them as live runs, which would
  mint empty assistants / re-finalize churn).

Regression: subagent reducer unit test (finalize → replay first event → 0
intents) + handler cold-replica test (finished subagent replay → still 1 thread).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:02:21 +08:00
Arvin Xu d6ca168199 ♻️ refactor(swr): converge remaining store-layer SWR keys into swrKeys registry (#15850)
♻️ refactor(swr): converge remaining store-layer keys into swrKeys registry

Migrate all ad-hoc SWR keys still living in the store/service layer onto the
central registry (src/libs/swr/keys.ts), under the uniform `domain:resource`
naming. New domains: discover, eval (agent eval), ragEval, knowledgeBase,
device (incl. git), userMemory, agentKnowledge, agentBot, file, chatTool.

- Pure key convergence: no tiering/caching change. The new prefixes are kept
  deliberately OUT of CACHE_TIERS, so every migrated key stays memory-only
  exactly as before (agentKnowledge:/agentBot: avoid the cached `agent:` tier).
- Behavior preserved: key array shapes, mutate matchers (key[0] === *.root),
  and personal-vs-workspace match semantics are unchanged; string-join keys
  (discover assistant/social) become arrays with equivalent identity.
- UI-embedded SWR keys (features/routes/components/packages) intentionally left
  for a later pass.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:55:24 +08:00
Arvin Xu ae88d7535f ♻️ refactor(swr): centralize session/thread/recent/group keys into swrKeys registry (#15848)
* ♻️ refactor(swr): migrate session/thread/recent/group-list keys into swrKeys registry

Batch 1 of the SWR key centralization: add session/thread/recent keys and
group:list to the registry under the domain:resource convention, migrate call
sites + mutate matchers, update the localStorage tier patterns (recent:list,
group:list), and update tests. Removes the ALL_RECENTS_DRAWER_SWR_PREFIX export
in favor of recentKeys.allDrawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(swr): version+unify message key, drop isLogin from keys, migrate agent/aiModel/image/video/serverConfig

- message: drop `listLegacy`; both stores use the accurate `message:list` key,
  now carrying MESSAGE_CACHE_VERSION; fix the chat store `refreshMessages` to
  invalidate the real key via a context matcher (was a dead key, never matched).
- keys: remove the redundant `isLogin` arg from all list factories (the app is
  always authenticated); drop the now-unused isLogin param from useFetchSessions.
- migrate agent config/available/search, aiModel, image+video generation, and
  serverConfig keys into the registry; update call sites, mutate matchers, tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(swr): restore isLogin arg in list keys

Re-introduce the isLogin argument across the session/agent/group/recent/brief
list key factories and their call sites (incl. useFetchSessions). The key must
vary with auth state so login/logout transitions invalidate the cached list
instead of serving another user's snapshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(swr): harden tiered cache flush + scope re-hydration

- localStorageProvider: flush both tiers on visibilitychange→hidden (and
  pagehide) instead of beforeunload. IndexedDB writes are async and can't be
  awaited on teardown; flushing while the page is still alive (hidden) gives
  them time to land before unload.
- Query: reset the new scope's hydration readiness before reloadScope() (in a
  layout effect), so the boot gate keeps blocking through the async IDB re-load
  instead of rendering stale data from a previously-visited scope.
- CacheHydrationGate: render the brand logo while gating instead of returning
  null, keeping the hand-off from the static loading screen seamless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 03:04:29 +08:00
Arvin Xu 66370675ab test(chat): characterize parked states + post-persist title wiring (#15847)
Fills the three refactor-critical holes left in the characterization net
(LOBE-10377) — exactly the invariants LOBE-10378/10379/10382 will rewrite.

- client (streamingExecutor): waiting_for_async_tool leaves the op UNcompleted
  (no switch case) and emits an undefined complete-signal status (normalize
  falls through); waiting_for_human completes-for-UI but does NOT drain queue
  or mark unread (parked != terminal).
- gateway (gatewayEventHandler): waiting_for_async_tool park is currently
  treated as a completed + unread terminal (no pause short-circuit), and shares
  the `interrupted` reconciliation branch (preserve streamed content vs DB
  refetch, uiMessages SoT takes precedence).
- lifecycle (conversationLifecycle): post-persist summaryTopicTitle fires on the
  CLIENT path (new-topic OR empty-title gate) and is NOT invoked on the GATEWAY
  path (early return; title handled server-side).

Tests-only; characterization (locks current behavior, incl. suspected gaps with
comments). 135 tests pass across the 3 files.

Part of LOBE-10376
2026-06-15 02:33:57 +08:00
Arvin Xu 457b4638c1 🐛 fix(home): hide agent-mode notice while config is loading (#15846)
Home InputArea computed isAgentConfigLoading but never passed it to
DesktopChatInput, so AgentModeNotice flashed the "model unsupported"
warning during hydration. Forward isConfigLoading like every other
call site so the notice only appears after config loads.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 02:16:24 +08:00
Arvin Xu edf058e325 feat(swr): unified tiered cache provider (localStorage + IndexedDB) with scope isolation (#15844)
*  feat(swr): unified tiered cache provider (localStorage + IndexedDB) with scope isolation

Route SWR persistence to a tier chosen centrally by key — IndexedDB for large
business entities (messages, topics, tasks, documents, agents), localStorage for
small list shells (recents) — instead of stuffing everything into one ~5MB
localStorage blob. Partition every tier by identity scope (`${userId}:${workspaceId}`)
so users/workspaces sharing an origin never collide, and add a boot hydration gate
so local-first data is present before the routed app mounts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(swr): centralize IndexedDB-tier keys into swrKeys registry with domain:resource naming

Introduce src/libs/swr/keys.ts as the single source of truth for SWR cache keys,
named uniformly as `<domain>:<resource>` (e.g. message:list, topic:list,
task:detail). Migrate the IndexedDB-tier domains (message, topic, agent, group,
task, document/page/notebook, brief) off scattered local consts/inline literals
onto registry factories, updating call sites, mutate matchers, and tests. The
tiered cache provider now routes by `domain:` prefix instead of ad-hoc
substrings, and matchDomain() enables refreshing a whole domain at once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 02:02:09 +08:00
Innei c740b13021 🐛 fix(provider): correct delete confirm z-index by switching to base-ui modal (#15845)
* 🐛 fix(provider): use base-ui modal so delete confirm stacks above the config dialog

Closes #15836

* 💄 style(provider): split delete confirm into short title and description

* 🌐 chore(i18n): sync delete confirm title/description across all locales
2026-06-15 01:53:27 +08:00
Arvin Xu f9e7ca5b68 test(chat): characterization net for agent runtime run-lifecycle (#15843)
*  test(chat): characterization net for agent runtime run-lifecycle

Lock the CURRENT client / gateway / heterogeneous run-completion behavior
across all terminal branches BEFORE the unified run-lifecycle refactor
(LOBE-10376), so any behavioral drift is caught by tests.

- client (streamingExecutor): afterCompletion fires on error terminal;
  complete-signal status=failed on error; queue-drain + markUnread skipped
  on error (negative); desktop-notification gating (content && !tools)
- gateway (gatewayEventHandler): error event completes op WITHOUT markUnread
  (asymmetry vs agent_runtime_end); completeOperation double-call idempotency
- hetero (heterogeneousAgentExecutor): notification + dock badge on success;
  updateTopicMetadata-rejection behavior; queue-drain gating
  (success / !aborted / !error); error & abort paths fire no notification/drain
- entry points: regenerate-hetero (imageList + parentOperationId +
  onRegenerateComplete), continue-hetero early-return, rejectAndContinue
  client dual-op, submitHeteroIntervention IPC submit + GC fallback

Tests-only; no implementation changes. 255 tests pass across the affected files.

Part of LOBE-10376
Closes LOBE-10377
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(chat): surface executor rejections in hetero completion helpers

The clean-completion `runToComplete` helper (and its sibling `runToError`)
awaited the executor with `.catch(() => {})`, swallowing any rejection. Both
paths resolve today, so this only masked future regressions: a happy/error
run that starts rejecting after some side effects would still pass — the
isDesktop=false "no notification" negative assertion is especially vulnerable
since an early rejection before the notification step trivially satisfies it.

Await the executor promise directly so a rejection fails the characterization
test instead of passing silently. 70/70 still green (both paths resolve today).

Part of LOBE-10376

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:49:48 +08:00
Arvin Xu 542197d8ab 🐛 fix(hetero): correct cold-replica main-turn idempotency and mark topic failed on terminal errors (#15838)
* 🐛 fix(server): dedupe replayed main-turn newStep on a cold replica

The main-agent coordinator cuts a turn purely on the adapter's `newStep` signal and minted a fresh random assistant id each time, with no DB-homed idempotency key for the turn (unlike the subagent path after #15808). On a cold serverless replica the in-memory `processedKeys` dedupe is empty, so a BatchIngester retry reprocesses the `newStep` and `openTurn` forks a second assistant — orphaning the first as a usage-only empty shell (the remote-CC "空壳" bubble).

Mirror #15808 onto the main chain: the adapter emits the turn's CC `message.id` on `stream_start{newStep}`; the reducer records it as `currentMainMessageId` and treats a same-id `newStep` as a replay (no-op); the server stamps it on `metadata.mainMessageId` and recovers it on a cold replica. Backward-compatible: a `newStep` without a message id opens a turn as before.

Regression: HeterogeneousPersistenceHandler.mainTurnRehydration.test.ts (cold-replica retry: 2 assistants + empty shell -> 1) plus 4 mainAgentCoordinator reducer unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(cli): mark topic failed when remote CC relays a terminal error on a clean exit

Claude Code relays API/rate-limit errors as an in-stream terminal `error`
event but still exits 0. The CLI derived the heteroFinish result from the
process exit code alone, so such runs reported `result: 'success'` →
`reason: 'done'` and the topic/task was wrongly marked completed instead of
failed (the error was only persisted on the message).

Track whether a terminal `error` event was pushed to the ingester and force
`result: 'error'` even on a clean exit, mirroring the desktop executor where
the stream error drives both the message error and the topic status. Also
surface the terminal error message as the finish error detail (CC relays these
on stdout, so stderr is empty in this case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:00:19 +08:00
Arvin Xu 507f251ac5 🔨 chore(agent-runtime): enable S3 tracing by default in production (#15841)
 feat(agent-runtime): enable S3 tracing by default in production
2026-06-15 00:56:20 +08:00
Rdmclin2 d3cc667c97 fix: workspace preifx (#15837)
chore: remove workspace prefix
2026-06-14 22:17:19 +08:00
LiJian 346d5be27c feat(connectors): add edit/uninstall buttons for connectors in SkillDetail (#15829)
* 🐛 fix: clear credentials on URL change; gate Edit button to http connectors

P1 (AddConnectorModal): when handleEdit detects a URL change, pass
credentials: null so the server drops the old OAuth token — a stale token
from the previous server must not be sent to the new one. The server-side
update mutation now also clears tokenExpiresAt in the same round-trip
whenever credentials are set to null.

P2 (ConnectorDetail): narrow the Edit button (and the modal mount) from
isMcpConnector to isMcpConnector && connector.mcpConnectionType === 'http'.
stdio connectors have no mcpServerUrl, so the URL-edit dialog would open
with an empty field and mislead the user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

*  feat(connectors): add edit/uninstall buttons for SkillDetail connectors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix: re-enable OAuth in edit mode + pre-fill bearer/header credentials

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix: resolve TypeScript errors in CustomConnectorModal edit mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix: add clientId/clientSecret to mcp.auth type to resolve TS error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix: correct description field location in editValue

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:55:49 +08:00
Arvin Xu 43c91caf6a 📝 docs: add capability-gated feature checklist to ux skill (#15832)
* 📝 docs: add capability-gated feature checklist to ux skill

Guide designers to fulfil the reminder obligation when a selected model
or its still-loading config can't deliver a feature's required capability
(e.g. agentic tool calling): surface a soft, reactive, load-gated warning
with the remedy, rather than failing silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs: broaden ux skill trigger to any UI work

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs: simplify ux skill description

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:38:31 +08:00
Arvin Xu 602e768419 🐛 fix(page-editor): isolate page copilot context from global agent/document state (#15826)
* 🐛 fix(page-editor): isolate page copilot context from global agent/document state

Two independent bugs both rooted in the page conversation context leaning on
process-global singletons that can't express multiple tabs/documents:

- Heterogeneous agents (Claude Code / Codex) leaked into the page copilot:
  `selectedAgentId` only excluded empty and chat-group ids, so navigating from
  a heterogeneous agent tab made the page right panel run that external agent.
  Also fall back to the page agent when the active agent is heterogeneous.

- `documentId` was lost in multi-tab scenarios because the conversation context
  carried no documentId and relied on the `pageAgentRuntime` singleton, which
  represents only one open document and is cleared on tab switch — causing
  "PageAgent server runtime received a tool call without documentId". Inject the
  editor's `pageId` straight into `context.documentId` so the send-time guard
  uses a deterministic value instead of the singleton.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(page-editor): include documentId in the page conversation key

The previous fix injected `documentId` into the conversation context, but all
state isolation (messages, operations, input-loading/runtime selectors,
replaceMessages) is keyed through `messageMapKey(context)`, which dropped
`documentId` entirely for page scope. Two documents sharing the page agent thus
collapsed into one `page_<agent>_new` bucket — document B could inherit A's
copilot history or be queued behind A's running operation while tool calls now
target B.

Carry `documentId` into the page-scoped key (as subTopicId) so each open
document gets its own isolated bucket; topicless page keys avoid emitting a
literal `null` segment, and the no-document case still falls back to
`page_<agent>_new` without colliding with document-specific keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:29:42 +08:00
Arvin Xu 87966afec8 🐛 fix(chat): warn when agent mode is on but the model lacks tool calling (#15828)
 feat(chat): warn when agent mode is on but the model lacks tool calling

Show a warning above the desktop chat input when Agent mode is enabled
but the selected model does not support function/tool calling, suggesting
switching to a model with agent capability.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:17:58 +08:00
lobehubbot 8992df3187 🔖 chore(release): release version v2.2.4 [skip ci] 2026-06-14 07:13:33 +00:00
1833 changed files with 125809 additions and 23511 deletions
+10 -6
View File
@@ -111,7 +111,7 @@ First check the repo root for `.env`:
Do not start the standalone e2e server as the product under test.
Use `scripts/init-dev-env.sh`. It follows the e2e setup pattern — Postgres,
migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
Redis, migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
skill and starts the repo's dev server (`pnpm run dev:next` / `bun run dev`),
not `e2e/scripts/setup.ts --start`. The script hard-blocks when root `.env`
exists, so it cannot accidentally override a user's local config. When `.env`
@@ -132,19 +132,19 @@ fi
Bootstrap flow when no `.env` exists:
```bash
# From repo root. Managed DB flow requires Docker Desktop.
# From repo root. Managed Postgres/Redis flow requires Docker Desktop.
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
If using an existing Postgres instead of the managed Docker DB, set
`DATABASE_URL` and skip `setup-db`:
`DATABASE_URL` and `REDIS_URL`, then skip `setup-db`:
```bash
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
For backend-only checks, `dev-next` is available, but Web smoke needs the
@@ -170,6 +170,9 @@ Default script env:
- `APP_URL=http://localhost:3010`
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres`
- `DATABASE_DRIVER=node`
- `AGENT_RUNTIME_MODE=queue` so backend-only agent runtime checks use the
same queued execution path as production
- `REDIS_URL=redis://localhost:6380` for queue-mode agent runtime state
- `FEATURE_FLAGS=-agent_self_iteration` so local smoke does not require QStash
- Local QStash defaults (`QSTASH_URL`, `QSTASH_TOKEN`, signing keys) are exported;
run `init-dev-env.sh qstash` in a separate terminal when the path under test
@@ -177,6 +180,7 @@ Default script env:
- `KEY_VAULTS_SECRET`, `AUTH_SECRET`, auth verification off
- S3 mock vars
- Managed DB container: `lobehub-agent-testing-postgres`
- Managed Redis container: `lobehub-agent-testing-redis`
`seed-user` creates `agent-testing@lobehub.com` / `TestPassword123!` with
onboarding already completed, plus a local API key in
@@ -112,9 +112,14 @@ secret: don't paste it into shared logs, PRs, or commit it anywhere.
1. `$SCRIPT status --surface web` — green? Start testing. Do not ask for a Cookie header.
2. Not green and using the seeded local env → `$SCRIPT web-seed`.
3. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
4. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:`**Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
5. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
3. If repo-root `.env` exists and `web-seed` fails, do **not** seed or modify the current DB; treat it as an existing local environment and use Cookie injection.
4. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
5. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:`**Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
6. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
`ENABLE_MOCK_DEV_USER` is not Web auth. It only affects server-side API context
and does not satisfy Better Auth or stop the SPA from redirecting to `/signin`.
Do not use it as a substitute for `status --surface web` or Cookie injection.
### Using the authenticated session
@@ -48,14 +48,15 @@ curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
```bash
# Start backend only.
# With root .env: use the existing local config.
pnpm run dev:next
# Agent runtime queue mode is required to mirror production async execution.
AGENT_RUNTIME_MODE=queue pnpm run dev:next
# Without root .env: use the self-contained agent-testing env.
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
# Full-stack SPA + backend. Required for Web smoke.
# With root .env:
bun run dev
AGENT_RUNTIME_MODE=queue bun run dev
# Without root .env:
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
@@ -91,6 +92,8 @@ in doubt.
| `ECONNREFUSED` | Server not running — start it |
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
| Stale data / old behavior | Server needs a restart to pick up code changes |
| Agent call runs inline | Set `AGENT_RUNTIME_MODE=queue`, make sure `REDIS_URL` is configured, then restart the server |
| Queue mode needs Redis | Run `init-dev-env.sh setup-db`, or provide `REDIS_URL=redis://...` for an existing Redis |
| QStash workflow failures | Start `init-dev-env.sh qstash` and make sure dev server inherited the script's `QSTASH_*` env |
Marketplace/community endpoints are not part of the local agent-testing auth
@@ -12,16 +12,16 @@
# Usage:
# init-dev-env.sh env # print shell exports
# init-dev-env.sh write [file] # write a source-able env file
# init-dev-env.sh setup-db # start local Postgres and run migrations
# init-dev-env.sh setup-db # start local Postgres/Redis and run migrations
# init-dev-env.sh migrate # run DB migrations against the configured DB
# init-dev-env.sh seed-user # seed the baseline test user + CLI API key
# init-dev-env.sh qstash # run local Upstash QStash dev server
# init-dev-env.sh dev-next # exec `pnpm run dev:next` with this env
# init-dev-env.sh dev # exec `bun run dev` with this env
# init-dev-env.sh clean-db # remove the managed Postgres container
# init-dev-env.sh clean-db # remove the managed Postgres/Redis containers
#
# Overrides:
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres QSTASH_DEV_PORT=8080
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres REDIS_PORT=6380 REDIS_CONTAINER=lobehub-agent-testing-redis QSTASH_DEV_PORT=8080
set -euo pipefail
@@ -32,6 +32,9 @@ SERVER_PORT="${SERVER_PORT:-3010}"
DB_PORT="${DB_PORT:-5433}"
DB_CONTAINER="${DB_CONTAINER:-lobehub-agent-testing-postgres}"
DATABASE_URL="${DATABASE_URL:-postgresql://postgres:postgres@localhost:${DB_PORT}/postgres}"
REDIS_PORT="${REDIS_PORT:-6380}"
REDIS_CONTAINER="${REDIS_CONTAINER:-lobehub-agent-testing-redis}"
REDIS_URL="${REDIS_URL:-redis://localhost:${REDIS_PORT}}"
ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-dev.env"
CLI_ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-cli.env"
AGENT_TESTING_API_KEY="${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}"
@@ -54,6 +57,7 @@ guard_no_root_env() {
}
apply_env() {
export AGENT_RUNTIME_MODE="${AGENT_RUNTIME_MODE:-queue}"
export APP_URL="${APP_URL:-http://localhost:${SERVER_PORT}}"
export AUTH_EMAIL_VERIFICATION="${AUTH_EMAIL_VERIFICATION:-0}"
export AUTH_SECRET="${AUTH_SECRET:-agent-testing-local-auth-secret-32chars}"
@@ -69,6 +73,7 @@ apply_env() {
export QSTASH_NEXT_SIGNING_KEY="${QSTASH_NEXT_SIGNING_KEY:-$QSTASH_LOCAL_NEXT_SIGNING_KEY}"
export QSTASH_TOKEN="${QSTASH_TOKEN:-$QSTASH_LOCAL_TOKEN}"
export QSTASH_URL="${QSTASH_URL:-http://127.0.0.1:${QSTASH_DEV_PORT}}"
export REDIS_URL
export S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-agent-testing-access-key}"
export S3_BUCKET="${S3_BUCKET:-agent-testing-bucket}"
export S3_ENDPOINT="${S3_ENDPOINT:-https://agent-testing-s3.localhost}"
@@ -78,6 +83,7 @@ apply_env() {
env_keys() {
printf '%s\n' \
APP_URL \
AGENT_RUNTIME_MODE \
AUTH_EMAIL_VERIFICATION \
AUTH_SECRET \
DATABASE_DRIVER \
@@ -92,6 +98,7 @@ env_keys() {
QSTASH_NEXT_SIGNING_KEY \
QSTASH_TOKEN \
QSTASH_URL \
REDIS_URL \
S3_ACCESS_KEY_ID \
S3_BUCKET \
S3_ENDPOINT \
@@ -137,6 +144,15 @@ wait_for_db() {
printf '\n'
}
wait_for_redis() {
printf ' waiting for Redis'
until docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; do
printf '.'
sleep 1
done
printf '\n'
}
start_db() {
require_docker
@@ -157,6 +173,25 @@ start_db() {
wait_for_db
}
start_redis() {
require_docker
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
ok "Redis container already running: $REDIS_CONTAINER"
elif docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker start "$REDIS_CONTAINER" > /dev/null
ok "started existing Redis container: $REDIS_CONTAINER"
else
docker run -d \
--name "$REDIS_CONTAINER" \
-p "${REDIS_PORT}:6379" \
redis:7-alpine > /dev/null
ok "created Redis container: $REDIS_CONTAINER"
fi
wait_for_redis
}
migrate_db() {
apply_env
cd "$REPO_ROOT"
@@ -327,9 +362,11 @@ cmd_status() {
apply_env
echo "agent-testing local dev env:"
note "APP_URL=$APP_URL"
note "AGENT_RUNTIME_MODE=$AGENT_RUNTIME_MODE"
note "DATABASE_URL=$DATABASE_URL"
note "PORT=$PORT"
note "QSTASH_URL=$QSTASH_URL"
note "REDIS_URL=$REDIS_URL"
if command -v docker > /dev/null 2>&1; then
ok "docker CLI available"
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
@@ -337,6 +374,11 @@ cmd_status() {
else
note "managed Postgres is not running: $DB_CONTAINER"
fi
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
ok "managed Redis running: $REDIS_CONTAINER"
else
note "managed Redis is not running: $REDIS_CONTAINER"
fi
else
bad "docker CLI is not available"
fi
@@ -373,6 +415,15 @@ cmd_clean_db() {
else
note "Postgres container not found: $DB_CONTAINER"
fi
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker stop "$REDIS_CONTAINER" > /dev/null
fi
if docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker rm "$REDIS_CONTAINER" > /dev/null
ok "removed Redis container: $REDIS_CONTAINER"
else
note "Redis container not found: $REDIS_CONTAINER"
fi
}
usage() {
@@ -391,6 +442,7 @@ case "$COMMAND" in
write) shift; write_env "${1:-}" ;;
setup-db)
start_db
start_redis
migrate_db
;;
migrate) migrate_db ;;
@@ -81,6 +81,7 @@ SERVER_URL="${SERVER_URL:-$(default_server_url)}"
SESSION="${SESSION:-lobehub-dev}"
AUTH_DIR="${AUTH_DIR:-$HOME/.lobehub-agent-testing}"
STATE_FILE="$AUTH_DIR/web-state.json"
ROOT_ENV_FILE="$REPO_ROOT/.env"
CLI_HOME_NAME="${LOBEHUB_CLI_HOME:-.lobehub-dev}"
CLI_HOME="$HOME/${CLI_HOME_NAME#/}"
CLI_CREDENTIALS_FILE="$CLI_HOME/credentials.json"
@@ -481,8 +482,13 @@ PY
if [[ ! "$code" =~ ^[23] ]]; then
bad "seed user sign-in failed at $SERVER_URL/api/auth/sign-in/email (http_code='$code')"
note "make sure the seed user exists:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
if [[ -f "$ROOT_ENV_FILE" ]]; then
note "root .env exists; do not seed or modify this DB for Web auth."
note "Use Chrome Cookie injection instead: $0 open-chrome, then pbpaste | $0 web"
else
note "make sure the seed user exists:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
fi
return 1
fi
@@ -517,6 +523,7 @@ cmd_web_verify() {
bad "failed to open $SERVER_URL in agent-browser session '$SESSION'"
return 1
fi
agent-browser --session "$SESSION" wait --load networkidle > /dev/null 2>&1 || true
local url
url=$(agent-browser --session "$SESSION" get url 2> /dev/null || true)
if [[ -z "$url" ]]; then
+5 -1
View File
@@ -38,7 +38,7 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
## Default Debug Order
1. Prove whether the raw CLI output is correct before touching UI code.
1. Prove whether the raw CLI output is correct before touching UI code. The app records every real session — read the most recent one via `cat .heerogeneous-tracing/.last-live-trace` rather than hand-rolling a `claude -p` repro (see references/debug-workflow\.md §2).
2. If raw output is correct, compare it with adapter output. In dev, `executeHeterogeneousAgent` exposes `window.__HETERO_AGENT_TRACE`.
3. If adapted events look correct, inspect `persistToolBatch`, `persistToolResult`, step transitions, and subagent routing.
4. Turn the repro into a focused test before fixing.
@@ -77,6 +77,10 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
look for `tool_result for unknown toolCallId` and missing `result_msg_id` backfill.
- Subagent tools show up in the main bubble:
check for subagent chunks reaching the main gateway handler.
- Wrong terminal-error guide (e.g. "usage limit reached" shown for a network drop):
a classifier is branching on a structured field whose mere presence isn't its meaning.
Grep the field across all event states in a real trace before trusting it — see
references/debug-workflow\.md §8 (CC `rate_limit_info` rides on `status: "allowed"` too).
## References
@@ -3,12 +3,13 @@
## Contents
1. Pipeline map
2. Capture raw CLI traces first
2. Capture raw CLI traces first (incl. in-app live traces)
3. Compare raw and adapted events
4. Check step boundaries before persistence
5. Check tool persistence invariants
6. Focused tests
7. Repro-to-fix workflow
8. Verify a structured-field classifier against a real trace
## 1. Pipeline Map
@@ -27,6 +28,54 @@ Start at the leftmost broken layer. Do not jump straight to UI rendering unless
## 2. Capture Raw CLI Traces First
### In-app live traces (the faithful capture — prefer this)
The running app already records every CLI session it spawns. This is the most
faithful trace you can get, because it captures the **exact** spawn args, env
keys, cwd, `--resume`/`--mcp-config` flags, model, and stdin that the app used —
things a hand-rolled `claude -p` / `codex exec` repro will not reproduce. Reach
for this before reproducing manually. The recorder lives in
`apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
(`createCliTraceSession`, `shouldTraceCliOutput`, `resolveTraceRootDir`).
When it records:
- **Dev build** (`!app.isPackaged`): always.
- **Packaged build**: only when the user flips the Help-menu developer toggle
(`heteroTracingEnabled`). Off by default so normal runs aren't polluted.
- Never under `NODE_ENV=test`.
Where it writes:
- Toggle **off** (plain dev run): `<cwd>/.heerogeneous-tracing/` — i.e. inside
the repo you're running against. (Yes, the dir name is misspelled
`heerogeneous`; it is the real path.)
- Toggle **on**: `<appStoragePath>/heteroAgent/tracing/` — keeps traces out of
the user's project. This is the only path packaged builds ever use.
Layout per session — `.../<agentType>/<YYYYMMDD-HHMMSS>-<sessionId>/`:
- `meta.json` — spawn `args`, `command`, `cwd`, `envKeys`, `model`,
`resumeSessionId`/`agentSessionId`, attachment summaries. **Read this first**
to know exactly how the CLI was invoked.
- `stdin.txt` — the stream-json request fed to the CLI.
- `stdout.jsonl` — the raw provider NDJSON (the trace you actually read).
- `stderr.log` — CLI stderr.
- `exit.json``{ code, signal, finishedAt }`.
`.heerogeneous-tracing/.last-live-trace` always points at the most recent
session dir, so the fast path to "what just happened" is:
```bash
dir=$(cat .heerogeneous-tracing/.last-live-trace)
cat "$dir/meta.json" # how the CLI was spawned
wc -l "$dir/stdout.jsonl" # raw event count
```
Reproduce the same session yourself by reusing the recorded `meta.json` `args`
together with `stdin.txt` (the args already include `--resume <sessionId>`),
instead of guessing flags.
### Codex raw JSONL
Use a read-only prompt and save traces under the repo-local scratch directory `.heerogeneous-tracing/`.
@@ -244,3 +293,55 @@ When the bug comes from a real trace, distill it into the closest existing test
6. Only then do an Electron smoke test with the `agent-testing` skill if UI confirmation is still needed.
Do not start with a broad Electron repro if a raw trace or adapter test can prove the fault zone faster.
## 8. Verify A Structured-Field Classifier Against A Real Trace
Whenever the adapter **branches on a structured field** from the raw stream —
`status`, `usage`, `rateLimitType`, `stop_reason`, `parent_tool_use_id`,
`subtype`, etc. — do not trust your mental model of the wire format. The field
you key on almost always also appears on **benign / non-target** events, and a
classifier that ignores the surrounding state will misfire on those.
The procedure (recurring — run it every time):
1. Pull the most recent real session: `dir=$(cat .heerogeneous-tracing/.last-live-trace)`.
2. Grep the field across **every** event state, not just the failing one, and
count by co-occurring state. Example:
```bash
# Which event statuses carry a rate_limit_info block?
grep -o '"status":"[a-z]*"' "$dir/stdout.jsonl" | sort | uniq -c
grep -c 'rate_limit_info' "$dir/stdout.jsonl"
```
3. If the field rides on states you did not account for, the classifier needs an
extra gate. Add the trace as a fixture/assertion to the adapter test so the
regression can't come back.
### Worked example: CC usage-limit vs. transient throttle (`fix/cc-rate-limit-quota-misclassify`)
- **Symptom:** an unrelated terminal failure (e.g. an `ECONNRESET` network drop)
rendered a bogus "usage limit reached, resets at X" guide.
- **What the trace showed:** Anthropic stamps a `rate_limit_info` block —
carrying `resetsAt` and `rateLimitType` (e.g. `seven_day`) — onto events even
when the request **goes through** (`status: "allowed"`). In real traces those
reset-window fields appear on \~all `rate_limit_info` blocks, the vast majority
of which are `allowed`, not `rejected`. So the window is rolling-window
_metadata for an allowed call_, NOT evidence the limit was hit.
- **The bug:** `isUserQuotaRateLimit` keyed only on the presence of a reset
window (`info.resetsAt != null || info.rateLimitType != null`). A later
terminal error inherited the last allowed event's window → false positive.
- **The fix:** require `status === 'rejected'` **and** a concrete reset window.
A bare `rejected` with no window is the transient server throttle → leave it
to the overloaded (retry) classifier. Status codes (429 / 529) and message
text are deliberately not consulted — only this structured signal decides the
guide.
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts` →
`isUserQuotaRateLimit`
- regression assertions in
`packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
The general lesson: a field's **presence** is not its **meaning**. Confirm which
event states a discriminator field co-occurs with in a real recorded trace
before branching on it.
+2 -2
View File
@@ -18,8 +18,8 @@ Periodic review of the project-local skill set under `.agents/skills/`. The goal
Build a fresh census of all SKILL.md files. Do NOT trust any prior cached list.
```bash
find .agents/skills -name SKILL.md | wc -l # total count
find .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length
find -L .agents/skills -name SKILL.md | wc -l # total count, including symlinked skills
find -L .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length, including symlinked skills
```
Group by domain in a mental table (DB / state / UI / agent / testing / workflow / docs / etc.). Note new arrivals since last audit (`git log --since="1 week ago" -- .agents/skills/`).
+231 -64
View File
@@ -1,6 +1,6 @@
---
name: ux
description: 'LobeHub product design values (自然 Natural / 意义感 Meaningful / 确定性 Certainty / 生长性 Growth) and per-aspect UX execution checklists. Use when designing or reviewing any user-facing flow — empty/loading/error states, confirmations, async feedback, button hierarchy, action parity, lists at scale, pickers, discoverability, and loading visuals.'
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
---
@@ -10,35 +10,32 @@ How LobeHub products should feel, and concrete rules to get there. Use this when
**building or reviewing** any user-facing flow. For component/styling choices see
**react**, for wording see **microcopy**, for imperative modal wiring see **modal**.
## Design values (设计价值观)
## Design values
LobeHub follows four product design values — **自然 Natural・意义感 Meaningful・
确定性 Certainty・生长性 Growth**. Read them before designing:
LobeHub follows four product design values — **Natural・Meaningful・Certainty・
Growth**. Read them before designing:
**[references/design-values.md](references/design-values.md)** (definitions +
conflict priority).
> The checklists below are the execution layer. Each item is tagged with the
> value(s) it serves; for what those values mean, see the file above.
## 1. Flow & momentum (操作链路)・自然・意义感
## How this is organized
Every action chain must **push the user forward**, never dead-end or block the flow.
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.
- [ ] **Forward momentum** — after any operation, lead the user to the next step,
don't just stop. _(意义感)_
- [ ] **Success state = primary "go to result", secondary "dismiss"** — the strong
button is the forward action (take me to the result); "Done" is the weak/
secondary button. ✅ After moving topics: primary = "Go to «target»", secondary
\= "Done". _(意义感・自然)_
- [ ] **Bulk ⇄ single-item parity** — an action on a multi-select toolbar must also
be reachable on a single item (its context menu), and vice versa. _(确定性)_
- [ ] **Confirm → in-progress → done, in one surface** — bulk/irreversible/async
ops use a modal state machine: a confirm step stating exactly what happens →
an in-progress view with **dismissal locked** → a done (or error) view in the
same modal. Never fire-and-forget with only a toast; never leave a dead
spinner. _(确定性・意义感)_
---
## 2. States: empty /loading/error (状态设计)・意义感・确定性
## 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".
@@ -46,64 +43,152 @@ Every data surface has **four** states — design all of them, not just "has dat
this is, why it's empty, and gives a clear next action (CTA + value props).
✅ Devices: an empty "Connect your first device" page with primary/secondary
connect paths and "what you can do once connected" cards — ❌ not a bare title
over skeleton rows or a blank body. _(意义感)_
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. _(确定性)_
"no match for filters" (clear-filters affordance) are different screens. _(Certainty)_
- [ ] **Always-rendered chrome still needs a body empty state.** When a surface
keeps its toolbar / header mounted even with no data (so a create / `+`
affordance stays reachable), the **body** below it must still render an empty
placeholder — persistent chrome is not an excuse to leave the content area
blank. ✅ The agent **Documents** tab keeps its new-folder / new-doc toolbar
and renders an `Empty` below it when there are no documents — ❌ not a toolbar
over dead space. _(Meaningful)_
- [ ] **Loading state** designed (skeleton / NeuralNetworkLoading), not a flash of
blank or layout shift. _(自然)_
- [ ] **Error state** designed — surface the reason and a retry/back path. _(意义感)_
blank or layout shift. _(Natural)_
- [ ] **Error state** designed — surface the reason and a retry/back path. _(Meaningful)_
## 3. Buttons & focus (按钮与焦点)・确定性
- [ ] **One primary button per surface.** The single primary CTA tells the user the
core action; everything else is secondary/tertiary. Never a pile of primary
buttons competing for attention. _(确定性)_
## 4. Lists at scale (列表与规模)・确定性・自然
### 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. _(确定性)_
- [ ] **Co-design empty / loading / error with the data state** (see §2). A list
isn't done until all four render well. _(自然)_
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)_
## 5. Option visibility (选项可见性)・确定性・意义感
### 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". _(意义感)_
"genuinely none", never "we filtered out the only option". _(Meaningful)_
## 6. Loading visuals (Loading 视觉)・自然
### 1.5 Default view reflects entry intent & data state・Certainty・Meaningful
**Never use antd `Spin`** — it doesn't match the product's loading visual. Use a
project loader:
A surface with multiple tabs / views / panels has a **landing** selection. Don't
hardcode it to "the first tab" — derive it from **(a) how the user got here** (the
intent their navigation carried) and **(b) which views actually have data**. A
static default that lands the user on an empty tab while a sibling holds exactly
what they came for reads as broken. This pairs with §1.1: the empty state is the
fallback _within_ a view; this rule is about not landing on that empty view in the
first place when a better one exists.
| 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`) |
- [ ] **Open on the tab the entry implies.** When navigation carries intent — the
user clicked a Skill, a file, a record of a specific type — land on the view
that shows it, not the static first tab. ✅ Opening a document page by clicking
a **skill** lands the right panel on the **Skills** tab; opening a plain
document lands on **Documents**. _(Meaningful)_
- [ ] **Fall back to a populated view when the default would be empty.** If the
default tab has no data but a sibling does, default to the populated one so
the surface opens on content. ✅ An agent with only skills (no documents)
opens the panel on **Skills** instead of an empty **Documents** tab. _(Certainty)_
- [ ] **Decide from resolved state, not mid-load.** Compute the default once the
data has loaded — choosing off an empty _in-flight_ list flips the tab as data
arrives. Hold the static default while loading, switch on resolved-empty. _(Certainty)_
- [ ] **A manual choice wins and sticks.** Once the user picks a tab, stop
auto-selecting — track "user-picked" separately (e.g. a nullable `pickedTab`
that overrides the derived default) so later data changes don't yank them off
their choice. _(Natural)_
When in doubt, reach for `NeuralNetworkLoading` — it's the default in-flight
indicator (e.g. modal "in progress" states).
---
## 7. Discoverability & growth (可发现性与生长)・生长性
## 2. Edit — entering & changing content
The product should grow with the user — deeper power shows up as needs deepen.
Any surface where the user **types or edits**. Input is expensive effort; the
overriding rule is **never lose it**.
- [ ] **Progressive disclosure** — keep the novice path clean; reveal advanced
capabilities as the user gets there, don't dump everything at once. _(生长性・自然)_
- [ ] **Surface related actions at the moment of need** — make the next capability
discoverable in context (e.g. after the first item exists, offer what to do
with it), not buried in a far-off menu. _(生长性・意义感)_
### 2.1 Protect in-progress edits・Certainty・Meaningful
## 8. Entity lifecycle completeness (实体生命周期完整性)・意义感・确定性
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
@@ -122,28 +207,110 @@ it explicitly _before_ building. Worked example, the tools/connectors list:
| User-custom (custom connector) | create | edit | delete |
- [ ] **No display-only features.** For every listed entity, enumerate CRUD +
lifecycle ops and build the ones that apply. _(意义感)_
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. _(确定性)_
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. _(自然)_
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). _(确定性)_
(e.g. official tools can't be deleted — by design). _(Certainty)_
---
## 4. Feedback — loading & system response
How the product **answers back** while and after the user acts — loading visuals
and proactive guardrails.
### 4.1 Loading visuals・Natural
**Never use antd `Spin`** — it doesn't match the product's loading visual. Use a
project loader:
| Need | Component |
| --------------------------- | ----------------------------------------------------------------------------- |
| Default loading (in-flight) | `NeuralNetworkLoading` from `@/components/NeuralNetworkLoading` (`size` prop) |
| Inline dots | `DotsLoading` / `BubblesLoading` from `@/components` |
| Branded full-page | `Loading` from `@/components/Loading/BrandTextLoading` |
| List / card placeholder | a skeleton (e.g. `SkeletonList`) |
When in doubt, reach for `NeuralNetworkLoading` — it's the default in-flight
indicator (e.g. modal "in progress" states).
### 4.2 Capability-gated features・Certainty・Meaningful
A feature can be fully built and still produce a broken result when the selected
model — or its still-loading config — **can't deliver the capability the feature
depends on** (for example, an agentic run on a model without tool calling). This
is usually the user's configuration choice, not a defect; but if the product stays
silent the user reads it as the product being broken. When a feature's success
depends on a capability the current config may lack, the product owes a
**proactive, non-blocking reminder** — a guardrail, not a gate.
- [ ] **Surface the mismatch, don't fail silently.** When a feature needs a model
capability (tool calling, vision, reasoning, long context) the current model
lacks, show a soft inline warning at the point of action — never a hard block
or a modal that stops the user. _(Meaningful)_
- [ ] **Stay reactive.** The reminder clears the moment the user switches to a
capable model — derive it from live state, not a one-shot check. _(Natural)_
- [ ] **Don't warn while config is loading.** A capability that hasn't resolved yet
looks "unsupported"; warning then is a false alarm — exactly the glitch users
mistake for a product bug. Warn only on a _resolved_ unsupported state. _(Certainty)_
- [ ] **Scope to the mode that needs it.** Show only when the capability-dependent
mode is on; one reminder per root cause, never a pile of overlapping notices. _(Natural・Certainty)_
- [ ] **State the problem and the remedy.** The copy says what's wrong _and_ what
the user should do about it. _(Meaningful)_
---
## 5. Grow — discoverability & progressive disclosure
How the product **deepens** as the user's needs deepen.
### 5.1 Progressive disclosure・Growth
The product should grow with the user — deeper power shows up as needs deepen.
- [ ] **Progressive disclosure** — keep the novice path clean; reveal advanced
capabilities as the user gets there, don't dump everything at once. _(Growth・Natural)_
- [ ] **Surface related actions at the moment of need** — make the next capability
discoverable in context (e.g. after the first item exists, offer what to do
with it), not buried in a far-off menu. _(Growth・Meaningful)_
---
## Quick review checklist
**Read — viewing data & lists**
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA. Always-rendered chrome (toolbar/header) still gets a body empty state.
- [ ] List designed across 1 → 10k rows (virtual scroll / pagination / batch as needed).
- [ ] Capped/scrollable/virtualized list scrolls the restored active item into view on mount (`block: 'nearest'`, re-run after async rows mount).
- [ ] Pickers show all valid targets (default/inbox included); empty = truly none.
- [ ] Multi-tab/view surface lands on the tab the entry intent implies (and falls back to a populated view, decided from resolved state); a manual pick sticks.
**Edit — entering & changing content**
- [ ] Editors back up in-progress input locally and recover it after refresh/crash/failed-save; destructive exits warn, never silently discard.
**Act — operations, flows & buttons**
- [ ] Action leads the user forward; success offers a primary "go to result".
- [ ] Bulk action has a single-item entry (and vice versa).
- [ ] Async/bulk/irreversible action: confirm → in-progress (locked) → done/error.
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA.
- [ ] Exactly one primary button per surface.
- [ ] List designed across 1 → 10k rows (virtual scroll / pagination / batch as needed).
- [ ] Pickers show all valid targets (default/inbox included); empty = truly none.
- [ ] No antd `Spin`; use `NeuralNetworkLoading` / project loaders.
- [ ] Advanced capability is progressively disclosed / discoverable at the moment of need.
- [ ] Listed entities have their full lifecycle (not display-only); ops match source (built-in / installed / custom).
**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.
+5 -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) ##
+1 -1
View File
@@ -6,7 +6,7 @@ const prComment = async ({ github, context, releaseUrl, artifactsUrl, version, t
const COMMENT_IDENTIFIER = '<!-- DESKTOP-BUILD-COMMENT -->';
/**
* 生成评论内容
* Generate comment body content
*/
const generateCommentBody = async () => {
try {
+1 -1
View File
@@ -34,7 +34,7 @@ module.exports = defineConfig({
markdown: {
reference:
'You need to maintain the component format of the mdx file; the output text does not need to be wrapped in any code block syntax on the outermost layer.\n' +
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf8'),
fs.readFileSync(path.join(__dirname, 'docs/glossary.mdx'), 'utf8'),
entry: ['./README.md', './docs/**/*.md', './docs/**/*.mdx'],
entryLocale: 'en-US',
outputLocales: ['zh-CN'],
+25
View File
@@ -2,6 +2,31 @@
# Changelog
## [Version 2.2.6](https://github.com/lobehub/lobe-chat/compare/v2.2.6-canary.8...v2.2.6)
<sup>Released on **2026-06-17**</sup>
#### ✨ Features
- **agent**: improve connector, document, and fleet workflows.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **agent**: improve connector, document, and fleet workflows, closes [#15936](https://github.com/lobehub/lobe-chat/issues/15936) ([3f82033](https://github.com/lobehub/lobe-chat/commit/3f82033))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.2.1](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr15228.13999...v2.2.1)
<sup>Released on **2026-05-29**</sup>
+37
View File
@@ -1,4 +1,7 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
@@ -77,6 +80,40 @@ describe('lh file - E2E', () => {
});
});
// ── upload (local file) ───────────────────────────────
describe('upload', () => {
it('should upload a local file passed as a positional argument', () => {
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-${Date.now()}.txt`);
fs.writeFileSync(tmpFile, 'hello from lh e2e upload');
try {
const result = runJson<{ id: string }>(`file upload ${tmpFile} --json id`);
expect(result).toHaveProperty('id');
if (result.id) run(`file delete ${result.id} --yes`);
} finally {
fs.rmSync(tmpFile, { force: true });
}
});
it('should upload a local file passed via --file', () => {
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-f-${Date.now()}.txt`);
fs.writeFileSync(tmpFile, 'hello from lh e2e --file upload');
try {
const result = runJson<{ id: string }>(`file upload --file ${tmpFile} --json id`);
expect(result).toHaveProperty('id');
if (result.id) run(`file delete ${result.id} --yes`);
} finally {
fs.rmSync(tmpFile, { force: true });
}
});
it('should error when the local file does not exist', () => {
expect(() => run('file upload -f /no/such/lh-file.txt')).toThrow();
});
});
// ── recent ────────────────────────────────────────────
describe('recent', () => {
+7 -1
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.29" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.34" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -41,6 +41,9 @@ Show a manual page for the CLI or a subcommand
.B connect
Connect to the device gateway and listen for tool calls
.TP
.B disconnect
Disconnect from the device gateway (alias for `connect stop`)
.TP
.B device
Manage connected devices
.TP
@@ -127,6 +130,9 @@ Manage evaluation workflows
.TP
.B migrate
Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)
.TP
.B update
Update the LobeHub CLI to the latest published version
.SH OPTIONS
.TP
.B \-V, \-\-version
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.29",
"version": "0.0.34",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -37,6 +37,7 @@
"@lobechat/tool-runtime": "workspace:*",
"@trpc/client": "^11.8.1",
"@types/node": "^24.13.2",
"@types/semver": "^7.7.1",
"@types/ws": "^8.18.1",
"commander": "^13.1.0",
"dayjs": "^1.11.19",
@@ -45,6 +46,7 @@
"fast-glob": "^3.3.3",
"ignore": "^7.0.5",
"picocolors": "^1.1.1",
"semver": "^7.7.3",
"superjson": "^2.2.6",
"tsdown": "^0.21.4",
"typescript": "^6.0.3",
+39 -13
View File
@@ -12,7 +12,8 @@ import { log } from '../utils/logger';
export type TrpcClient = ReturnType<typeof createTRPCClient<LambdaRouter>>;
export type ToolsTrpcClient = ReturnType<typeof createTRPCClient<ToolsRouter>>;
let _client: TrpcClient | undefined;
const PERSONAL_KEY = '__personal__';
const _clients = new Map<string, TrpcClient>();
let _toolsClient: ToolsTrpcClient | undefined;
async function getAuthAndServer() {
@@ -53,21 +54,40 @@ async function getAuthAndServer() {
};
}
export async function getTrpcClient(): Promise<TrpcClient> {
if (_client) return _client;
/**
* Resolve the workspace scope for outbound tRPC calls.
*
* Precedence: explicit caller arg → `LOBEHUB_WORKSPACE_ID` env (inherited
* from a workspace-dispatched parent process, e.g. openclaw spawned by the
* device's `runHeteroTask`) → personal mode. Without this, agentNotify
* callbacks on workspace topics would resolve through personal-mode
* TopicModel and 404.
*/
function resolveWorkspaceId(explicit?: string): string | undefined {
if (explicit) return explicit;
const fromEnv = process.env.LOBEHUB_WORKSPACE_ID;
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
}
export async function getTrpcClient(workspaceId?: string): Promise<TrpcClient> {
const wsId = resolveWorkspaceId(workspaceId);
const cacheKey = wsId ?? PERSONAL_KEY;
const cached = _clients.get(cacheKey);
if (cached) return cached;
const { headers, serverUrl } = await getAuthAndServer();
_client = createTRPCClient<LambdaRouter>({
const client = createTRPCClient<LambdaRouter>({
links: [
httpLink({
headers,
headers: wsId ? { ...headers, 'X-Workspace-Id': wsId } : headers,
transformer: superjson,
url: `${serverUrl}/trpc/lambda`,
}),
],
});
_clients.set(cacheKey, client);
return _client;
return client;
}
/**
@@ -77,13 +97,19 @@ export async function getTrpcClient(): Promise<TrpcClient> {
* via env/stored creds and `process.exit(1)` when none exist, which would
* abort an otherwise-valid explicit-token session.
*/
export function createLambdaClient(auth: {
serverUrl: string;
token: string;
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
}): TrpcClient {
const headers =
auth.tokenType === 'apiKey' ? { 'X-API-Key': auth.token } : { 'Oidc-Auth': auth.token };
export function createLambdaClient(
auth: {
serverUrl: string;
token: string;
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
},
/** When set, scopes the request to a workspace (e.g. workspace-device enrollment). */
workspaceId?: string,
): TrpcClient {
const headers: Record<string, string> = {
...(auth.tokenType === 'apiKey' ? { 'X-API-Key': auth.token } : { 'Oidc-Auth': auth.token }),
...(workspaceId ? { 'X-Workspace-Id': workspaceId } : {}),
};
return createTRPCClient<LambdaRouter>({
links: [httpLink({ headers, transformer: superjson, url: `${auth.serverUrl}/trpc/lambda` })],
+29 -20
View File
@@ -21,15 +21,6 @@ vi.mock('../settings', () => ({
saveSettings: vi.fn(),
}));
vi.mock('../device/register', () => ({
registerDevice: vi.fn().mockResolvedValue(undefined),
resolveDeviceIdentity: vi.fn((userId?: string, explicitDeviceId?: string) =>
userId || explicitDeviceId
? { deviceId: explicitDeviceId ?? 'mock-device-id', identitySource: 'machine-id' }
: undefined,
),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
@@ -235,7 +226,7 @@ describe('connect command', () => {
type: 'tool_call_request',
});
expect(executeToolCall).toHaveBeenCalledWith('readLocalFile', '{"path":"/test"}', undefined);
expect(executeToolCall).toHaveBeenCalledWith('readLocalFile', '{"path":"/test"}');
expect(lastSentToolResponse).toEqual({
requestId: 'req-1',
result: { content: 'tool result', error: undefined, success: true },
@@ -270,8 +261,6 @@ describe('connect command', () => {
});
it('should retry auth_failed with token refresh when new token available', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
vi.mocked(resolveToken).mockResolvedValueOnce({
serverUrl: 'https://app.lobehub.com',
token: 'refreshed-token',
@@ -279,6 +268,9 @@ describe('connect command', () => {
userId: 'test-user',
});
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
const mockClient = vi.mocked(GatewayClient).mock.results[0].value;
await clientEventHandlers['auth_failed']?.('token expired');
@@ -288,9 +280,7 @@ describe('connect command', () => {
expect(exitSpy).not.toHaveBeenCalled();
});
it('should refresh and reconnect on auth_expired', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
it('should handle auth_expired', async () => {
vi.mocked(resolveToken).mockResolvedValueOnce({
serverUrl: 'https://app.lobehub.com',
token: 'new-tok',
@@ -298,14 +288,14 @@ describe('connect command', () => {
userId: 'user',
});
const mockClient = vi.mocked(GatewayClient).mock.results[0].value;
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
await clientEventHandlers['auth_expired']?.();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Token refreshed'));
expect(mockClient.updateToken).toHaveBeenCalledWith('new-tok');
expect(mockClient.reconnect).toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('expired'));
expect(cleanupAllProcesses).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should ignore auth_expired for api key auth', async () => {
@@ -450,6 +440,25 @@ describe('connect command', () => {
});
});
describe('disconnect (alias for connect stop)', () => {
it('should stop running daemon', async () => {
mockRunningPid = 12345;
const program = createProgram();
await program.parseAsync(['node', 'test', 'disconnect']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
});
it('should warn if no daemon is running', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'disconnect']);
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('No daemon'));
});
});
describe('connect status', () => {
it('should show no daemon running', async () => {
const program = createProgram();
+116 -59
View File
@@ -18,7 +18,6 @@ import type {
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { Command } from 'commander';
import { getValidToken } from '../auth/refresh';
import { resolveToken } from '../auth/resolveToken';
import { CLI_API_KEY_ENV } from '../constants/auth';
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
@@ -34,7 +33,13 @@ import {
writeStatus,
} from '../daemon/manager';
import { spawnHeteroAgentRun } from '../device/agentRun';
import { registerDevice, resolveDeviceIdentity } from '../device/register';
import {
mintWorkspaceConnectToken,
registerDevice,
registerWorkspaceDevice,
resolveDeviceIdentity,
resolveWorkspaceDeviceIdentity,
} from '../device/register';
import { loadOrCreateConnectionId, loadSettings, normalizeUrl, saveSettings } from '../settings';
import { executeToolCall } from '../tools';
import { cleanupAllProcesses } from '../tools/shell';
@@ -47,6 +52,8 @@ interface ConnectOptions {
gateway?: string;
token?: string;
verbose?: boolean;
/** Enroll this machine as a device of the given workspace (admin only). */
workspace?: string;
}
export function registerConnectCommand(program: Command) {
@@ -56,6 +63,7 @@ export function registerConnectCommand(program: Command) {
.option('--token <jwt>', 'JWT access token')
.option('--gateway <url>', 'Device gateway URL')
.option('--device-id <id>', 'Device ID (auto-generated if not provided)')
.option('--workspace <id>', 'Enroll as a device of this workspace (admin only)')
.option('-v, --verbose', 'Enable verbose logging')
.option('-d, --daemon', 'Run as a background daemon process')
.option('--daemon-child', 'Internal: runs as the daemon child process')
@@ -74,17 +82,7 @@ export function registerConnectCommand(program: Command) {
});
// Subcommands
connectCmd
.command('stop')
.description('Stop the background daemon process')
.action(() => {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
});
connectCmd.command('stop').description('Stop the background daemon process').action(handleStop);
connectCmd
.command('status')
@@ -148,10 +146,27 @@ export function registerConnectCommand(program: Command) {
}
handleDaemonStart({ ...options, daemon: true });
});
// Top-level alias for `connect stop`. Users who run `lh connect` naturally
// reach for `lh disconnect` to undo it; the nested `connect stop` is not
// discoverable enough on its own.
program
.command('disconnect')
.description('Disconnect from the device gateway (alias for `connect stop`)')
.action(handleStop);
}
// --- Internal helpers ---
function handleStop() {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
}
function handleDaemonStart(options: ConnectOptions) {
const existingPid = getRunningDaemonPid();
if (existingPid !== null) {
@@ -178,6 +193,7 @@ function buildDaemonArgs(options: ConnectOptions): string[] {
if (options.token) args.push('--token', options.token);
if (options.gateway) args.push('--gateway', options.gateway);
if (options.deviceId) args.push('--device-id', options.deviceId);
if (options.workspace) args.push('--workspace', options.workspace);
if (options.verbose) args.push('--verbose');
return args;
@@ -202,10 +218,43 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
const resolvedGatewayUrl = gatewayUrl || OFFICIAL_GATEWAY_URL;
// Workspace enrollment: the device joins a workspace pool (reachable by all
// members) instead of the personal pool. It authenticates with a minted
// workspace-device token (carrying the `workspace_id` claim) and uses a
// workspace-derived deviceId. `auth` stays the admin's identity — used only to
// (re-)mint the connect token and register the row.
const workspaceId = options.workspace;
// Resolve a stable device identity. An explicit `--device-id` wins (lets a
// user pin a VM to a fixed identity); otherwise derive from the machine id so
// the same machine + user maps to one device across reconnects.
const identity = resolveDeviceIdentity(auth.userId, options.deviceId);
// the same machine maps to one device across reconnects.
const identity = workspaceId
? resolveWorkspaceDeviceIdentity(workspaceId, options.deviceId)
: resolveDeviceIdentity(auth.userId, options.deviceId);
// The token the gateway socket authenticates with. Re-minted on refresh for
// workspace devices (see `refreshConnectToken`).
let connectToken = auth.token;
let connectTokenType: 'apiKey' | 'jwt' | 'serviceToken' = auth.tokenType;
if (workspaceId) {
const minted = await mintWorkspaceConnectToken(auth, workspaceId);
connectToken = minted.token;
connectTokenType = 'jwt';
}
// Re-resolve the admin auth and, for workspace mode, re-mint the connect token.
const refreshConnectToken = async (): Promise<string | undefined> => {
const refreshed = await resolveToken({});
if (!refreshed) return undefined;
auth = refreshed;
if (workspaceId) {
const minted = await mintWorkspaceConnectToken(auth, workspaceId);
connectToken = minted.token;
return connectToken;
}
connectToken = refreshed.token;
return connectToken;
};
// Freeform channel label (`cli` by default); `LOBEHUB_CLI_CHANNEL` lets a
// dev build tag itself `cli-dev` so the gateway can prioritise / display it.
@@ -218,9 +267,10 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
gatewayUrl: resolvedGatewayUrl,
logger: isDaemonChild ? createDaemonLogger() : log,
serverUrl: auth.serverUrl,
token: auth.token,
tokenType: auth.tokenType,
userId: auth.userId,
token: connectToken,
tokenType: connectTokenType,
userId: workspaceId ? undefined : auth.userId,
workspaceId,
});
const info = (msg: string) => {
@@ -337,7 +387,6 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
const ack = await spawnHeteroAgentRun(
{
agentType: request.agentType,
command: request.command,
cwd: request.cwd,
imageList: request.imageList,
jwt: request.jwt,
@@ -370,15 +419,21 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
updateStatus('reconnecting');
});
// Proactive token refresh — schedule before JWT expires
const startProactiveRefresh = () =>
// Proactive token refresh — schedule before the connect token expires. For a
// workspace device `refreshConnectToken` re-mints the workspace token; for a
// personal device it refreshes the user token. Scheduling watches the actual
// connect token, so the workspace token's shorter life is respected.
const startProactiveRefresh = (): (() => void) | null =>
scheduleProactiveRefresh(
auth,
(refreshed) => {
client.updateToken(refreshed.token);
auth = refreshed;
// Schedule next refresh based on the new token
cancelRefreshTimer = startProactiveRefresh();
connectToken,
connectTokenType,
async () => {
const newToken = await refreshConnectToken();
if (newToken) {
client.updateToken(newToken);
cancelRefreshTimer = startProactiveRefresh();
}
return newToken;
},
info,
error,
@@ -389,15 +444,15 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// (e.g., auto-reconnect may send an expired JWT before proactive refresh fires)
let authFailedRefreshAttempted = false;
client.on('auth_failed', async (reason) => {
if (auth.tokenType === 'jwt' && !authFailedRefreshAttempted) {
if (connectTokenType === 'jwt' && !authFailedRefreshAttempted) {
authFailedRefreshAttempted = true;
info(`Authentication failed (${reason}). Attempting token refresh...`);
try {
const refreshed = await resolveToken({});
if (refreshed && refreshed.token !== auth.token) {
const prev = connectToken;
const newToken = await refreshConnectToken();
if (newToken && newToken !== prev) {
info('Token refreshed successfully. Reconnecting...');
client.updateToken(refreshed.token);
auth = refreshed;
client.updateToken(newToken);
authFailedRefreshAttempted = false;
cancelRefreshTimer = startProactiveRefresh();
await client.reconnect();
@@ -418,7 +473,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// Handle auth expired — refresh token and reconnect automatically
client.on('auth_expired', async () => {
if (auth.tokenType === 'apiKey') {
if (connectTokenType === 'apiKey') {
// API keys don't expire; ignore stale auth_expired signals
return;
}
@@ -426,11 +481,10 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
info('Authentication expired. Attempting to refresh token...');
try {
const refreshed = await resolveToken({});
if (refreshed) {
const newToken = await refreshConnectToken();
if (newToken) {
info('Token refreshed successfully. Reconnecting...');
client.updateToken(refreshed.token);
auth = refreshed;
client.updateToken(newToken);
cancelRefreshTimer = startProactiveRefresh();
await client.reconnect();
return;
@@ -480,7 +534,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
try {
// Reuse the already-resolved auth (respects `--token` mode) so we don't
// re-discover creds and exit when none are found.
await registerDevice(auth, identity);
if (workspaceId) await registerWorkspaceDevice(auth, identity, workspaceId);
else await registerDevice(auth, identity);
} catch (err) {
error(`Device registration failed (non-fatal): ${(err as Error).message}`);
}
@@ -528,47 +583,49 @@ function parseJwtExp(token: string): number | undefined {
}
/**
* Schedule a proactive token refresh before the JWT expires.
* Returns a cleanup function that cancels the scheduled timer.
* Schedule a proactive token refresh before the (connect) token expires.
* `refresh` performs the actual refresh — re-minting a workspace token or
* refreshing the user token — and returns the new token. Returns a cleanup
* function that cancels the scheduled timer.
*/
function scheduleProactiveRefresh(
auth: { token: string; tokenType: string },
onRefreshed: (newAuth: Awaited<ReturnType<typeof resolveToken>>) => void,
token: string,
tokenType: string,
refresh: () => Promise<string | undefined>,
info: (msg: string) => void,
error: (msg: string) => void,
): (() => void) | null {
if (auth.tokenType !== 'jwt') return null;
if (tokenType !== 'jwt') return null;
const exp = parseJwtExp(auth.token);
const exp = parseJwtExp(token);
if (!exp) return null;
const refreshAt = (exp - PROACTIVE_REFRESH_BUFFER) * 1000;
const delay = refreshAt - Date.now();
if (delay < 0) {
// Already past the refresh window — refresh immediately on next tick
const lifetimeMs = exp * 1000 - Date.now();
if (lifetimeMs <= 0) {
// Token already expired — refresh once on next tick.
void doRefresh();
return null;
}
// Refresh ahead of expiry, but never let the buffer meet or exceed the token's
// remaining lifetime: a buffer >= lifetime collapses the refresh window to <=0
// and busy-loops re-minting (e.g. a 1h token with a 1h buffer). Cap the buffer
// at half the remaining lifetime so a short-lived token refreshes about once per
// half-life instead of spinning.
const bufferMs = Math.min(PROACTIVE_REFRESH_BUFFER * 1000, lifetimeMs / 2);
const delay = lifetimeMs - bufferMs;
const timer = setTimeout(() => void doRefresh(), delay);
return () => clearTimeout(timer);
async function doRefresh() {
try {
// Use the same buffer so getValidToken actually triggers a refresh
const result = await getValidToken(PROACTIVE_REFRESH_BUFFER);
if (!result) {
const newToken = await refresh();
if (!newToken) {
error('Proactive token refresh failed — no valid credentials.');
return;
}
const refreshed = await resolveToken({});
// Only notify if the token actually changed to avoid reschedule loops
if (refreshed.token !== auth.token) {
info('Proactively refreshed token.');
onRefreshed(refreshed);
}
if (newToken !== token) info('Proactively refreshed token.');
} catch {
error('Proactive token refresh failed.');
}
+117 -3
View File
@@ -1,3 +1,7 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -17,6 +21,9 @@ const { mockTrpcClient } = vi.hoisted(() => ({
removeFiles: { mutate: vi.fn() },
updateFile: { mutate: vi.fn() },
},
upload: {
createS3PreSignedUrl: { mutate: vi.fn() },
},
},
}));
@@ -38,9 +45,11 @@ describe('file command', () => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
for (const method of Object.values(mockTrpcClient.file)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
for (const group of [mockTrpcClient.file, mockTrpcClient.upload]) {
for (const method of Object.values(group)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
}
});
@@ -205,6 +214,111 @@ describe('file command', () => {
expect(mockTrpcClient.file.createFile.mutate).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
});
it('should upload a local file passed as a positional argument', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-${process.pid}.txt`);
fs.writeFileSync(tmpFile, 'hello world');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
mockTrpcClient.file.createFile.mutate.mockResolvedValue({
id: 'f-local',
url: 'files/x.txt',
});
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledWith(
'https://s3/presigned',
expect.objectContaining({ method: 'PUT' }),
);
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({
fileType: 'text/plain',
name: path.basename(tmpFile),
url: expect.stringContaining('.txt'),
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('File created'));
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should upload a local file passed via --file', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-f-${process.pid}.json`);
fs.writeFileSync(tmpFile, '{}');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-json' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', '--file', tmpFile]);
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileType: 'application/json' }),
);
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should skip the S3 upload when the local file hash already exists', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-dedup-${process.pid}.txt`);
fs.writeFileSync(tmpFile, 'dedup me');
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({
isExist: true,
url: 'files/2024-01-01/existing.txt',
});
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-dedup' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
// No pre-sign and no S3 PUT should happen
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
// The record reuses the existing url
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({ url: 'files/2024-01-01/existing.txt' }),
);
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should error when local file does not exist', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', '-f', '/no/such/file.txt']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('File not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should error when no source is provided', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Provide a local file path'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('edit', () => {
+49 -7
View File
@@ -4,6 +4,7 @@ import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
import { uploadLocalFile } from '../utils/uploadLocalFile';
export function registerFileCommand(program: Command) {
const file = program.command('file').description('Manage files');
@@ -113,18 +114,20 @@ export function registerFileCommand(program: Command) {
// ── upload ───────────────────────────────────────────
file
.command('upload <url>')
.description('Upload a file by URL (checks hash first)')
.option('--hash <hash>', 'File hash for deduplication check')
.option('--name <name>', 'File name')
.option('--type <type>', 'File MIME type')
.option('--size <size>', 'File size in bytes')
.command('upload [source]')
.description('Upload a file from a local path or a URL')
.option('-f, --file <path>', 'Local file path to upload')
.option('--hash <hash>', 'File hash for deduplication check (URL mode)')
.option('--name <name>', 'File name (URL mode)')
.option('--type <type>', 'File MIME type (URL mode)')
.option('--size <size>', 'File size in bytes (URL mode)')
.option('--parent-id <id>', 'Parent folder ID')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (
url: string,
source: string | undefined,
options: {
file?: string;
hash?: string;
json?: string | boolean;
name?: string;
@@ -133,8 +136,47 @@ export function registerFileCommand(program: Command) {
type?: string;
},
) => {
const isUrl = (value: string) =>
value.startsWith('http://') || value.startsWith('https://');
// Resolve the local file path: explicit --file, or a positional that is
// not a URL (e.g. `lh file upload ./games_list.txt`).
const localPath = options.file ?? (source && !isUrl(source) ? source : undefined);
const client = await getTrpcClient();
// ── Local file upload ──
if (localPath) {
let result;
try {
result = await uploadLocalFile(client, localPath, { parentId: options.parentId });
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return;
}
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
return;
}
const r = result as any;
console.log(`${pc.green('✓')} File created: ${pc.bold(r.id || '')}`);
if (r.url) console.log(` URL: ${pc.dim(r.url)}`);
return;
}
// ── URL upload ──
if (!source) {
log.error('Provide a local file path, --file <path>, or a URL to upload.');
process.exit(1);
return;
}
const url = source;
// Check hash first if provided
if (options.hash) {
const check = await client.file.checkFileHash.mutate({ hash: options.hash });
+140
View File
@@ -1,3 +1,7 @@
import { rm as fsRm, writeFile as fsWriteFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -6,6 +10,9 @@ import { registerGenerateCommand } from './generate';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
asr: {
transcribe: { mutate: vi.fn() },
},
generation: {
deleteGeneration: { mutate: vi.fn() },
getGenerationStatus: { query: vi.fn() },
@@ -35,6 +42,15 @@ const { writeFileSync: mockWriteFileSync } = vi.hoisted(() => ({
writeFileSync: vi.fn(),
}));
const { uploadLocalFile: mockUploadLocalFile } = vi.hoisted(() => ({
uploadLocalFile: vi.fn(),
}));
vi.mock('../utils/uploadLocalFile', async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, uploadLocalFile: mockUploadLocalFile };
});
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
vi.mock('node:fs', async (importOriginal) => {
@@ -369,6 +385,130 @@ describe('generate command', () => {
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should upload large local audio and transcribe by fileId', async () => {
// Real >3MB temp file so existsSync/statSync (unmocked) see it as large.
const bigPath = path.join(os.tmpdir(), `lh-asr-test-${process.pid}-${Date.now()}.mp3`);
await fsWriteFile(bigPath, Buffer.alloc(4 * 1024 * 1024));
mockUploadLocalFile.mockResolvedValue({ id: 'file_999' });
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'big result' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'asr', bigPath]);
expect(mockUploadLocalFile).toHaveBeenCalledWith(expect.anything(), bigPath);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileId: 'file_999', model: 'whisper-1', provider: 'openai' }),
);
// never inlines bytes for the large file
expect(mockTrpcClient.asr.transcribe.mutate.mock.calls[0][0]).not.toHaveProperty(
'audioBase64',
);
expect(stdoutSpy).toHaveBeenCalledWith('big result');
} finally {
await fsRm(bigPath, { force: true });
}
});
it('should download and transcribe an audio URL', async () => {
const fetchMock = vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers(),
ok: true,
});
vi.stubGlobal('fetch', fetchMock);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'hello world' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/audio/sample.mp3',
]);
expect(fetchMock).toHaveBeenCalledWith('https://example.com/audio/sample.mp3');
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({
audioBase64: Buffer.from('audio-bytes').toString('base64'),
fileName: 'sample.mp3',
model: 'whisper-1',
provider: 'openai',
}),
);
expect(stdoutSpy).toHaveBeenCalledWith('hello world');
expect(exitSpy).not.toHaveBeenCalled();
});
it('should derive an extension and mime type from Content-Type when the URL has none', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers({ 'content-type': 'audio/mpeg; charset=binary' }),
ok: true,
}),
);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'asr', 'https://example.com/download']);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({
fileName: 'download.mp3',
mimeType: 'audio/mpeg',
}),
);
});
it('should prefer the filename from Content-Disposition', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers({
'content-disposition': 'attachment; filename="recording.wav"',
}),
ok: true,
}),
);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/files/abc123?sig=xyz',
]);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileName: 'recording.wav' }),
);
});
it('should exit when audio URL download fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }),
);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/missing.mp3',
]);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to download audio'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('delete', () => {
+167 -39
View File
@@ -1,16 +1,27 @@
import { createReadStream, existsSync } from 'node:fs';
import { existsSync, statSync } from 'node:fs';
import { readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { Command } from 'commander';
import { getAuthInfo } from '../../api/http';
import { getTrpcClient } from '../../api/client';
import { log } from '../../utils/logger';
import { uploadLocalFile } from '../../utils/uploadLocalFile';
// Audio at or below this size is sent inline as base64; anything larger is
// uploaded first and transcribed by `fileId`. Kept in sync with the server-side
// inline cap in `apps/server/src/routers/lambda/asr.ts`.
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
export function registerAsrCommand(parent: Command) {
parent
.command('asr <audio-file>')
.description('Convert speech to text (automatic speech recognition)')
.description(
'Convert speech to text (automatic speech recognition). Accepts a local path or a URL',
)
.option('--model <model>', 'STT model', 'whisper-1')
.option('--provider <provider>', 'AI provider', 'openai')
.option('--language <lang>', 'Language code (e.g. en, zh)')
.option('--json', 'Output raw JSON')
.action(
@@ -20,58 +31,175 @@ export function registerAsrCommand(parent: Command) {
json?: boolean;
language?: string;
model: string;
provider: string;
},
) => {
if (!existsSync(audioFile)) {
const isUrl = audioFile.startsWith('http://') || audioFile.startsWith('https://');
if (!isUrl && !existsSync(audioFile)) {
log.error(`File not found: ${audioFile}`);
process.exit(1);
return;
}
const { serverUrl, headers } = await getAuthInfo();
const sttOptions: Record<string, any> = { model: options.model };
if (options.language) sttOptions.language = options.language;
const formData = new FormData();
const fileBuffer = await readFileAsBlob(audioFile);
formData.append('speech', fileBuffer, path.basename(audioFile));
formData.append('options', JSON.stringify(sttOptions));
// Remove Content-Type for multipart/form-data (let fetch set it with boundary)
const { 'Content-Type': _, ...formHeaders } = headers;
const res = await fetch(`${serverUrl}/webapi/stt/openai`, {
body: formData,
headers: formHeaders,
method: 'POST',
});
if (!res.ok) {
const errText = await res.text();
log.error(`ASR failed: ${res.status} ${errText}`);
// Resolve the input to a local file path (downloading URLs to a temp
// file) so large audio can reuse the shared upload flow.
let localPath: string;
let fileName: string;
let mimeType: string | undefined;
let size: number;
let tempPath: string | undefined;
try {
if (isUrl) {
const downloaded = await fetchAudioFromUrl(audioFile);
fileName = downloaded.name;
mimeType = downloaded.mimeType;
size = downloaded.bytes.byteLength;
tempPath = path.join(os.tmpdir(), `lh-asr-${process.pid}-${Date.now()}-${fileName}`);
await writeFile(tempPath, downloaded.bytes);
localPath = tempPath;
} else {
localPath = audioFile;
fileName = path.basename(audioFile);
size = statSync(audioFile).size;
}
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return;
}
const result = await res.json();
try {
const client = await getTrpcClient();
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
const text = (result as any).text || JSON.stringify(result);
process.stdout.write(text);
process.stdout.write('\n');
let result: { text: string };
if (size > MAX_INLINE_AUDIO_BYTES) {
// Large audio: upload to storage, then transcribe by fileId so the
// bytes never travel inline through tRPC.
process.stderr.write(
`Audio is ${(size / 1024 / 1024).toFixed(1)}MB — uploading before transcription…\n`,
);
const record = (await uploadLocalFile(client, localPath)) as { id: string };
result = await client.asr.transcribe.mutate({
fileId: record.id,
language: options.language,
model: options.model,
provider: options.provider,
});
} else {
const bytes = await readFile(localPath);
result = await client.asr.transcribe.mutate({
audioBase64: Buffer.from(bytes).toString('base64'),
fileName,
language: options.language,
mimeType,
model: options.model,
provider: options.provider,
});
}
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
process.stdout.write(result.text);
process.stdout.write('\n');
}
} catch (error) {
log.error(`ASR failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
} finally {
if (tempPath) {
await rm(tempPath, { force: true }).catch(() => {});
}
}
},
);
}
async function readFileAsBlob(filePath: string): Promise<Blob> {
const chunks: Uint8Array[] = [];
const stream = createReadStream(filePath);
for await (const chunk of stream) {
chunks.push(chunk as Uint8Array);
// Common audio MIME types mapped to a file extension the transcription
// provider can recognize. Keep the extensions within the set OpenAI's
// /audio/transcriptions endpoint accepts.
const AUDIO_MIME_TO_EXT: Record<string, string> = {
'audio/aac': 'aac',
'audio/flac': 'flac',
'audio/m4a': 'm4a',
'audio/mp3': 'mp3',
'audio/mp4': 'm4a',
'audio/mpeg': 'mp3',
'audio/mpga': 'mp3',
'audio/ogg': 'ogg',
'audio/opus': 'ogg',
'audio/wav': 'wav',
'audio/wave': 'wav',
'audio/webm': 'webm',
'audio/x-m4a': 'm4a',
'audio/x-wav': 'wav',
};
async function fetchAudioFromUrl(
url: string,
): Promise<{ bytes: Uint8Array; mimeType?: string; name: string }> {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to download audio: ${res.status} ${res.statusText}`);
}
const bytes = new Uint8Array(await res.arrayBuffer());
// Strip any parameters from the Content-Type (e.g. `audio/mpeg; charset=...`).
const contentType = res.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
const mimeType = contentType?.startsWith('audio/') ? contentType : undefined;
// Prefer the name the server advertises, then the URL path, then a fallback.
const name =
fileNameFromContentDisposition(res.headers.get('content-disposition')) ||
basenameFromUrl(url) ||
'audio';
// Transcription providers infer the audio format from the file extension, so
// make sure the name carries one. Signed URLs and /download endpoints often
// have no extension in the path — in that case borrow it from the
// Content-Type when we recognize it.
const ext = contentType ? AUDIO_MIME_TO_EXT[contentType] : undefined;
const finalName = path.extname(name) || !ext ? name : `${name}.${ext}`;
return { bytes, mimeType, name: finalName };
}
// Extract a file name from a Content-Disposition header, handling both the
// plain `filename="x"` form and the RFC 5987 extended `filename*=UTF-8''x` form.
function fileNameFromContentDisposition(header: string | null): string | undefined {
if (!header) return undefined;
// Extended form takes precedence and may be percent-encoded.
const extended = /filename\*=\s*(?:UTF-8|ISO-8859-1)?''([^;]+)/i.exec(header);
if (extended?.[1]) {
try {
return path.basename(decodeURIComponent(extended[1].trim()));
} catch {
// Malformed encoding — fall through to the plain form.
}
}
const plain = /filename=\s*"?([^";]+)"?/i.exec(header);
const value = plain?.[1]?.trim();
return value ? path.basename(value) : undefined;
}
// Derive the (URL-decoded) last path segment of a URL, if any.
function basenameFromUrl(url: string): string | undefined {
let pathname: string;
try {
pathname = new URL(url).pathname;
} catch {
return undefined;
}
const base = path.basename(pathname);
if (!base) return undefined;
try {
return decodeURIComponent(base);
} catch {
return base;
}
return new Blob(chunks);
}
+47
View File
@@ -649,6 +649,53 @@ describe('hetero exec command', () => {
]);
});
it('finishes with result "error" when a terminal error event is pushed despite a clean exit', async () => {
// CC relays an API/rate-limit error as an in-stream `error` event but still
// exits 0. The finish result must NOT be derived from the exit code alone,
// otherwise the topic/task is wrongly marked completed.
mockSpawnAgent.mockReturnValue(
createFakeHandle({
events: [
{
data: {
error: 'API Error: Server is temporarily limiting requests · Rate limited',
message: 'API Error: Server is temporarily limiting requests · Rate limited',
},
operationId: 'op-err',
stepIndex: 0,
timestamp: 1,
type: 'error',
},
],
exitCode: 0,
}),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--topic',
'topic-1',
'--operation-id',
'op-err',
'--render',
'none',
]);
expect(mockHeteroFinishMutate).toHaveBeenCalledTimes(1);
expect(mockHeteroFinishMutate.mock.calls[0][0]).toMatchObject({
error: {
message: 'API Error: Server is temporarily limiting requests · Rate limited',
type: 'AgentRuntimeError',
},
result: 'error',
});
});
it('resets the per-message text accumulator at message boundaries (no cross-message duplication)', async () => {
// The `replace` snapshot accumulator must not span
// message boundaries. Two assistant messages separated by a
+35 -7
View File
@@ -467,6 +467,11 @@ const exec = async (options: ExecOptions): Promise<void> => {
* sessionId — CC session id from `system.init` (undefined on resume failure)
* ingestError — true when a batch could not be flushed after retries
* resumeNotFound — true when a resume-not-found error was intercepted
* sawTerminalError — true when a terminal `error` event was pushed to the
* ingester (CC can relay an API/rate-limit error this way
* and still exit 0, so the exit code alone is not enough)
* terminalErrorMessage — the message from that terminal `error` event, used
* as the task-level error detail in the finish payload
* stderrContent — accumulated stderr (only when interceptResumeErrors=true)
*/
const runOneAgent = async (
@@ -477,9 +482,11 @@ const exec = async (options: ExecOptions): Promise<void> => {
code: number | null;
ingestError: boolean;
resumeNotFound: boolean;
sawTerminalError: boolean;
sessionId: string | undefined;
signal: NodeJS.Signals | null;
stderrContent: string;
terminalErrorMessage: string | undefined;
}> => {
// One raw-dump file pair per spawn attempt (the resume retry is a second
// attempt). The stdout tee runs inside `spawnAgent` before the adapter.
@@ -549,6 +556,8 @@ const exec = async (options: ExecOptions): Promise<void> => {
// into the ingester. When intercepting resume errors, a matching
// `error` event is withheld from the ingester and flags a retry instead.
let resumeNotFound = false;
let sawTerminalError = false;
let terminalErrorMessage: string | undefined;
const ingestError = false;
try {
for await (const event of handle.events) {
@@ -563,6 +572,16 @@ const exec = async (options: ExecOptions): Promise<void> => {
continue;
}
}
// A terminal `error` event (e.g. an API/rate-limit error relayed by CC)
// must mark the run as failed even when the child exits 0 — track it so
// the finish result is not derived from the exit code alone. Capture the
// message too, so the finish payload can surface it as the task-level
// error detail (CC relays these on stdout, not stderr).
if (event.type === 'error') {
sawTerminalError = true;
const data = event.data as Record<string, unknown> | undefined;
terminalErrorMessage = String(data?.message ?? data?.error ?? '') || undefined;
}
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
serverIngester?.push(event);
}
@@ -608,9 +627,11 @@ const exec = async (options: ExecOptions): Promise<void> => {
code,
ingestError,
resumeNotFound,
sawTerminalError,
sessionId: handle.sessionId,
signal,
stderrContent,
terminalErrorMessage,
};
};
@@ -675,16 +696,23 @@ const exec = async (options: ExecOptions): Promise<void> => {
result = { ...result, ingestError: true };
}
const exitedClean = !result.ingestError && (code === 0 || signal === 'SIGTERM');
// CC relays API/rate-limit errors as an in-stream terminal `error` event but
// still exits 0, so the exit code alone would report `success`. Treat any
// pushed terminal error as a failed run so the topic/task is marked failed.
const exitedClean =
!result.ingestError && !result.sawTerminalError && (code === 0 || signal === 'SIGTERM');
// When the run failed, pass stderr as the error detail so the server can
// surface a useful message instead of the generic "Agent execution failed"
// fallback. Trim to the last 1 KB — the tail is most informative and
// keeps the tRPC payload small.
// When the run failed, pass an error detail so the server surfaces a useful
// message instead of the generic "Agent execution failed" fallback. Prefer
// the in-stream terminal error (CC relays API/rate-limit errors here while
// exiting 0, so stderr is empty); otherwise fall back to the stderr tail.
// Trim to the last 1 KB — the tail is most informative and keeps the tRPC
// payload small.
const stderrTail = result.stderrContent.trim();
const errorDetail = result.terminalErrorMessage || stderrTail;
const finishError =
!exitedClean && stderrTail
? { message: stderrTail.slice(-1024), type: 'AgentRuntimeError' }
!exitedClean && errorDetail
? { message: errorDetail.slice(-1024), type: 'AgentRuntimeError' }
: undefined;
try {
+11 -72
View File
@@ -1,14 +1,12 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { getAuthInfo } from '../api/http';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
import { uploadLocalFile } from '../utils/uploadLocalFile';
function formatFileType(fileType: string): string {
if (!fileType) return '';
@@ -324,81 +322,22 @@ export function registerKbCommand(program: Command) {
.description('Upload a file to a knowledge base')
.option('--parent <parentId>', 'Parent folder ID')
.action(async (knowledgeBaseId: string, filePath: string, options: { parent?: string }) => {
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) {
log.error(`File not found: ${resolved}`);
process.exit(1);
}
const stat = fs.statSync(resolved);
const fileName = path.basename(resolved);
const fileBuffer = fs.readFileSync(resolved);
// Compute SHA-256 hash
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// Detect MIME type from extension
const ext = path.extname(fileName).toLowerCase().slice(1);
const mimeMap: Record<string, string> = {
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
gif: 'image/gif',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
json: 'application/json',
md: 'text/markdown',
mp3: 'audio/mpeg',
mp4: 'video/mp4',
pdf: 'application/pdf',
png: 'image/png',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
svg: 'image/svg+xml',
txt: 'text/plain',
webp: 'image/webp',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
const fileType = mimeMap[ext] || 'application/octet-stream';
const client = await getTrpcClient();
const { serverUrl, headers } = await getAuthInfo();
// 1. Get presigned URL
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
const pathname = `files/${date}/${hash}.${ext}`;
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
// 2. Upload to S3
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
const uploadRes = await fetch(presignedUrl, {
body: fileBuffer,
headers: { 'Content-Type': fileType },
method: 'PUT',
});
if (!uploadRes.ok) {
log.error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
let result;
try {
result = await uploadLocalFile(client, filePath, {
knowledgeBaseId,
parentId: options.parent,
});
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return;
}
// 3. Create file record
const result = await client.file.createFile.mutate({
fileType,
hash,
knowledgeBaseId,
metadata: {
date,
dirname: '',
filename: fileName,
path: pathname,
},
name: fileName,
parentId: options.parent,
size: stat.size,
url: pathname,
});
console.log(
`${pc.green('✓')} Uploaded ${pc.bold(fileName)}${pc.bold((result as any).id)}`,
`${pc.green('✓')} Uploaded ${pc.bold(path.basename(filePath))}${pc.bold((result as any).id)}`,
);
});
}
+31 -1
View File
@@ -1,7 +1,8 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearCredentials } from '../auth/credentials';
import { stopDaemon } from '../daemon/manager';
import { log } from '../utils/logger';
import { registerLogoutCommand } from './logout';
@@ -9,6 +10,10 @@ vi.mock('../auth/credentials', () => ({
clearCredentials: vi.fn(),
}));
vi.mock('../daemon/manager', () => ({
stopDaemon: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
@@ -19,6 +24,11 @@ vi.mock('../utils/logger', () => ({
}));
describe('logout command', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(stopDaemon).mockReturnValue(false);
});
function createProgram() {
const program = new Command();
program.exitOverride();
@@ -44,4 +54,24 @@ describe('logout command', () => {
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Already logged out'));
});
it('should stop the connect daemon before clearing credentials', async () => {
vi.mocked(stopDaemon).mockReturnValue(true);
vi.mocked(clearCredentials).mockReturnValue(true);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Disconnected device daemon'));
});
it('should still attempt daemon teardown when no credentials exist', async () => {
vi.mocked(clearCredentials).mockReturnValue(false);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(stopDaemon).toHaveBeenCalled();
});
});
+9
View File
@@ -1,6 +1,7 @@
import type { Command } from 'commander';
import { clearCredentials } from '../auth/credentials';
import { stopDaemon } from '../daemon/manager';
import { log } from '../utils/logger';
export function registerLogoutCommand(program: Command) {
@@ -8,6 +9,14 @@ export function registerLogoutCommand(program: Command) {
.command('logout')
.description('Log out and remove stored credentials')
.action(() => {
// Tear down the connect daemon first — otherwise it keeps the device
// online on the gateway with the cached token even after credentials are
// gone, leaving the machine remotely driveable past "logout".
const stopped = stopDaemon();
if (stopped) {
log.info('Disconnected device daemon.');
}
const removed = clearCredentials();
if (removed) {
log.info('Logged out. Credentials removed.');
+58
View File
@@ -100,6 +100,19 @@ describe('model command', () => {
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(visibleModels, null, 2));
});
it('should normalize the legacy `stt` type to `asr` when filtering', async () => {
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([
{ displayName: 'Whisper', enabled: true, id: 'whisper-1', type: 'asr' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'list', 'openai', '--type', 'stt']);
expect(mockTrpcClient.aiModel.getAiProviderModelList.query).toHaveBeenCalledWith(
expect.objectContaining({ id: 'openai', type: 'asr' }),
);
});
});
describe('view', () => {
@@ -157,6 +170,28 @@ describe('model command', () => {
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created model'));
});
it('should normalize the legacy `stt` type to `asr`', async () => {
mockTrpcClient.aiModel.createAiModel.mutate.mockResolvedValue('whisper-1');
const program = createProgram();
await program.parseAsync([
'node',
'test',
'model',
'create',
'--id',
'whisper-1',
'--provider',
'openai',
'--type',
'stt',
]);
expect(mockTrpcClient.aiModel.createAiModel.mutate).toHaveBeenCalledWith(
expect.objectContaining({ id: 'whisper-1', providerId: 'openai', type: 'asr' }),
);
});
});
describe('edit', () => {
@@ -184,6 +219,29 @@ describe('model command', () => {
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated model'));
});
it('should normalize the legacy `stt` type to `asr`', async () => {
mockTrpcClient.aiModel.updateAiModel.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'model',
'edit',
'whisper-1',
'--provider',
'openai',
'--type',
'stt',
]);
expect(mockTrpcClient.aiModel.updateAiModel.mutate).toHaveBeenCalledWith({
id: 'whisper-1',
providerId: 'openai',
value: expect.objectContaining({ type: 'asr' }),
});
});
it('should error when no changes specified', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'edit', 'gpt-4', '--provider', 'openai']);
+15 -8
View File
@@ -7,6 +7,11 @@ import { log } from '../utils/logger';
const isVisibleModel = (model: { visible?: boolean }) => model.visible !== false;
// The model type `stt` was renamed to the standard `asr`. Accept the legacy
// alias on CLI input and forward/compare `asr`, so existing scripts and muscle
// memory keep working against the new router schema.
const normalizeModelType = (type: string): string => (type === 'stt' ? 'asr' : type);
export function registerModelCommand(program: Command) {
const model = program.command('model').description('Manage AI models');
@@ -19,7 +24,7 @@ export function registerModelCommand(program: Command) {
.option('--enabled', 'Only show enabled models')
.option(
'--type <type>',
'Filter by model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
'Filter by model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
)
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
@@ -29,18 +34,20 @@ export function registerModelCommand(program: Command) {
) => {
const client = await getTrpcClient();
const typeFilter = options.type ? normalizeModelType(options.type) : undefined;
const input: Record<string, any> = { id: providerId };
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
if (options.enabled) input.enabled = true;
if (options.type) input.type = options.type;
if (typeFilter) input.type = typeFilter;
const result = await client.aiModel.getAiProviderModelList.query(input as any);
let items = (Array.isArray(result) ? result : ((result as any).items ?? [])).filter(
isVisibleModel,
);
if (options.type) {
items = items.filter((m: any) => m.type === options.type);
if (typeFilter) {
items = items.filter((m: any) => m.type === typeFilter);
}
if (options.json !== undefined) {
@@ -106,7 +113,7 @@ export function registerModelCommand(program: Command) {
.option('--display-name <name>', 'Display name')
.option(
'--type <type>',
'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
'chat',
)
.action(
@@ -116,7 +123,7 @@ export function registerModelCommand(program: Command) {
const input: Record<string, any> = {
id: options.id,
providerId: options.provider,
type: options.type || 'chat',
type: normalizeModelType(options.type || 'chat'),
};
if (options.displayName) input.displayName = options.displayName;
@@ -132,7 +139,7 @@ export function registerModelCommand(program: Command) {
.description('Update model info')
.requiredOption('--provider <providerId>', 'Provider ID')
.option('--display-name <name>', 'Display name')
.option('--type <type>', 'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)')
.option('--type <type>', 'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)')
.action(
async (id: string, options: { displayName?: string; provider: string; type?: string }) => {
if (!options.displayName && !options.type) {
@@ -144,7 +151,7 @@ export function registerModelCommand(program: Command) {
const value: Record<string, any> = {};
if (options.displayName) value.displayName = options.displayName;
if (options.type) value.type = options.type;
if (options.type) value.type = normalizeModelType(options.type);
await client.aiModel.updateAiModel.mutate({
id,
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { buildInstallCommand, isNewerVersion } from './update';
describe('isNewerVersion', () => {
it('compares core versions', () => {
expect(isNewerVersion('1.2.3', '1.2.2')).toBe(true);
expect(isNewerVersion('1.2.2', '1.2.3')).toBe(false);
expect(isNewerVersion('1.2.3', '1.2.3')).toBe(false);
expect(isNewerVersion('2.0.0', '1.9.9')).toBe(true);
});
it('tolerates a leading v and missing segments', () => {
expect(isNewerVersion('v1.2.0', '1.2.0')).toBe(false);
expect(isNewerVersion('1.2', '1.2.0')).toBe(false);
expect(isNewerVersion('1.3', '1.2.9')).toBe(true);
});
it('ranks a stable release above a prerelease of the same core', () => {
expect(isNewerVersion('1.2.3', '1.2.3-beta.1')).toBe(true);
expect(isNewerVersion('1.2.3-beta.1', '1.2.3')).toBe(false);
expect(isNewerVersion('1.2.3-beta.2', '1.2.3-beta.1')).toBe(true);
expect(isNewerVersion('1.2.3-beta.1', '1.2.3-beta.1')).toBe(false);
});
it('orders numeric prerelease identifiers numerically, not lexicographically', () => {
// The bug a raw string compare gets wrong: beta.10 must outrank beta.9.
expect(isNewerVersion('1.0.0-beta.10', '1.0.0-beta.9')).toBe(true);
expect(isNewerVersion('1.0.0-beta.9', '1.0.0-beta.10')).toBe(false);
expect(isNewerVersion('1.0.0-beta.2', '1.0.0-beta.10')).toBe(false);
});
it('returns false for an unparseable latest version', () => {
expect(isNewerVersion('not-a-version', '1.0.0')).toBe(false);
});
});
describe('buildInstallCommand', () => {
it('builds the global install command per package manager', () => {
expect(buildInstallCommand('npm', '@lobehub/cli@1.0.0')).toEqual({
args: ['install', '-g', '@lobehub/cli@1.0.0'],
command: 'npm',
});
expect(buildInstallCommand('pnpm', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'pnpm',
});
expect(buildInstallCommand('bun', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'bun',
});
expect(buildInstallCommand('yarn', '@lobehub/cli@1.0.0')).toEqual({
args: ['global', 'add', '@lobehub/cli@1.0.0'],
command: 'yarn',
});
});
});
+179
View File
@@ -0,0 +1,179 @@
import { spawn } from 'node:child_process';
import { realpathSync } from 'node:fs';
import type { Command } from 'commander';
import pc from 'picocolors';
import semver from 'semver';
// Pull package metadata from the shared `src/pkg.ts` module (resolved at the
// bundled entry's depth) rather than a local `require('../../package.json')`,
// which would point outside the package once bundled into dist/index.js.
import { cliPackageName, cliVersion } from '../pkg';
import { log } from '../utils/logger';
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
interface UpdateOptions {
check?: boolean;
packageManager?: PackageManager;
tag?: string;
}
/**
* Detect which package manager installed the CLI so we run the matching global
* upgrade command. We first trust an explicit `npm_config_user_agent` (set when
* invoked through a package-manager script) and otherwise infer from the path of
* the running binary. Falls back to npm.
*/
export function detectPackageManager(): PackageManager {
const ua = process.env.npm_config_user_agent;
if (ua) {
if (ua.startsWith('pnpm')) return 'pnpm';
if (ua.startsWith('yarn')) return 'yarn';
if (ua.startsWith('bun')) return 'bun';
if (ua.startsWith('npm')) return 'npm';
}
try {
const binPath = realpathSync(process.argv[1] ?? '').replaceAll('\\', '/');
if (binPath.includes('/pnpm/')) return 'pnpm';
if (binPath.includes('/.bun/') || binPath.includes('/bun/')) return 'bun';
if (binPath.includes('/yarn/') || binPath.includes('/.yarn/')) return 'yarn';
} catch {
// ignore fall back to npm
}
return 'npm';
}
/** Build the global-install command for the detected package manager. */
export function buildInstallCommand(
pm: PackageManager,
spec: string,
): { args: string[]; command: string } {
switch (pm) {
case 'pnpm': {
return { args: ['add', '-g', spec], command: 'pnpm' };
}
case 'yarn': {
return { args: ['global', 'add', spec], command: 'yarn' };
}
case 'bun': {
return { args: ['add', '-g', spec], command: 'bun' };
}
default: {
return { args: ['install', '-g', spec], command: 'npm' };
}
}
}
/**
* Whether `latest` is a newer version than `current`. Delegates to `semver` so
* prerelease identifiers order correctly (e.g. `1.0.0-beta.10` > `1.0.0-beta.9`,
* which a lexicographic compare gets wrong). Tolerates a leading `v` and missing
* segments via coercion; an unparseable `latest` is treated as "not newer".
*/
export function isNewerVersion(latest: string, current: string): boolean {
const latestParsed = semver.coerce(latest, { includePrerelease: true }) ?? semver.parse(latest);
const currentParsed =
semver.coerce(current, { includePrerelease: true }) ?? semver.parse(current);
if (!latestParsed || !currentParsed) return false;
return semver.gt(latestParsed, currentParsed);
}
async function fetchLatestVersion(name: string, tag: string): Promise<string> {
const url = `https://registry.npmjs.org/${name}/${encodeURIComponent(tag)}`;
const res = await fetch(url, { headers: { accept: 'application/json' } });
if (!res.ok) {
throw new Error(`npm registry returned status ${res.status} for tag "${tag}"`);
}
const data = (await res.json()) as { version?: string };
if (!data.version) {
throw new Error('npm registry response is missing the "version" field');
}
return data.version;
}
function runInstall(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
shell: process.platform === 'win32',
stdio: 'inherit',
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} exited with code ${code ?? 'null'}`));
});
});
}
export function registerUpdateCommand(program: Command) {
program
.command('update')
.description('Update the LobeHub CLI to the latest published version')
.option('--check', 'Only check for a newer version without installing')
.option('--tag <tag>', 'npm dist-tag to update to', 'latest')
.option(
'--package-manager <pm>',
`Force a package manager (${PACKAGE_MANAGERS.join(', ')}) instead of auto-detecting`,
)
.action(async (options: UpdateOptions) => {
if (options.packageManager && !PACKAGE_MANAGERS.includes(options.packageManager)) {
log.error(
`Unsupported package manager "${options.packageManager}". Use one of: ${PACKAGE_MANAGERS.join(', ')}.`,
);
process.exit(1);
return;
}
const current = cliVersion;
const tag = options.tag || 'latest';
log.info(`Current version: ${pc.bold(current)}`);
let latest: string;
try {
latest = await fetchLatestVersion(cliPackageName, tag);
} catch (error) {
log.error(`Unable to check for updates: ${(error as Error).message}`);
process.exit(1);
return;
}
log.info(`Latest version: ${pc.bold(latest)} ${pc.dim(`(${tag})`)}`);
if (!isNewerVersion(latest, current)) {
log.info(pc.green('Already on the latest version.'));
return;
}
if (options.check) {
log.info(
`Update available: ${current}${pc.green(latest)}. Run ${pc.cyan('lh update')} to upgrade.`,
);
return;
}
const pm = options.packageManager || detectPackageManager();
const spec = `${cliPackageName}@${latest}`;
const { args, command } = buildInstallCommand(pm, spec);
log.info(`Upgrading via ${pc.bold(pm)}: ${pc.dim([command, ...args].join(' '))}`);
try {
await runInstall(command, args);
log.info(pc.green(`Successfully updated to ${latest}. Restart any running sessions.`));
} catch (error) {
log.error(`Update failed: ${(error as Error).message}`);
log.error(`You can upgrade manually: ${[command, ...args].join(' ')}`);
process.exit(1);
}
});
}
+42
View File
@@ -88,3 +88,45 @@ describe('verify rubric config commands', () => {
expect(printed).toContain('4');
});
});
describe('verify evidence upload command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockGetTrpcClient.mockReset();
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit ${code}`);
}) as any);
});
afterEach(() => {
exitSpy.mockRestore();
});
const run = async (args: string[]) => {
const program = new Command();
program.exitOverride();
registerVerifyCommand(program);
await program.parseAsync(['node', 'lh', 'verify', ...args]);
};
it('rejects evidence with both file and inline content', async () => {
await expect(
run([
'evidence',
'upload',
'--check',
'result-1',
'--type',
'text',
'--file',
'artifact.txt',
'--content',
'inline payload',
]),
).rejects.toThrow('process.exit 1');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(mockGetTrpcClient).not.toHaveBeenCalled();
});
});
+434 -7
View File
@@ -1,9 +1,13 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
import { uploadLocalFile } from '../utils/uploadLocalFile';
// ── Helpers ────────────────────────────────────────────────
@@ -32,6 +36,36 @@ function assertEnum<T extends string>(value: T | undefined, allowed: T[], flag:
}
}
type Verdict = 'failed' | 'passed' | 'uncertain';
type EvidenceType = 'dom_snapshot' | 'gif' | 'screenshot' | 'text' | 'transcript' | 'video';
/** Map a free-form case/summary result token onto the verify verdict vocabulary. */
function toVerdict(raw: unknown): Verdict {
const s = String(raw ?? '').toLowerCase();
if (['pass', 'passed', 'ok', 'success'].includes(s)) return 'passed';
if (['fail', 'failed', 'error'].includes(s)) return 'failed';
return 'uncertain'; // partial / blocked / skipped / pending / unknown
}
/** Pick an evidence medium from a file extension. */
function evidenceTypeForFile(file: string): EvidenceType {
const ext = path.extname(file).toLowerCase().slice(1);
if (ext === 'gif') return 'gif';
if (['png', 'jpg', 'jpeg', 'webp', 'svg', 'bmp'].includes(ext)) return 'screenshot';
if (['mp4', 'webm', 'mov', 'm4v'].includes(ext)) return 'video';
if (['html', 'htm'].includes(ext)) return 'dom_snapshot';
return 'text';
}
/** Normalize a case's `evidence` field (string | string[] | {path}[]) to path strings. */
function evidencePaths(evidence: unknown): string[] {
if (!evidence) return [];
const arr = Array.isArray(evidence) ? evidence : [evidence];
return arr
.map((e) => (typeof e === 'string' ? e : (e?.path ?? e?.file)))
.filter((p): p is string => typeof p === 'string' && p.length > 0);
}
// ── Command Registration ───────────────────────────────────
export function registerVerifyCommand(program: Command) {
@@ -368,9 +402,9 @@ export function registerVerifyCommand(program: Command) {
console.log(`${pc.green('✓')} Skipped verification for run ${pc.bold(operationId)}`);
});
// ════════════ run / results ════════════
// ════════════ execute (agent path) ════════════
verify
.command('run <operationId>')
.command('execute <operationId>')
.description('Execute the confirmed plan against a deliverable (LLM judge)')
.requiredOption('--goal <goal>', "The run's task")
.requiredOption('--deliverable <text>', 'The output to judge')
@@ -406,13 +440,147 @@ export function registerVerifyCommand(program: Command) {
},
);
verify
.command('results <operationId>')
.description('List check results for a run')
// ════════════ run (verification session entity) ════════════
const run = verify.command('run').description('Verification sessions (verify_runs)');
run
.command('create')
.description('Create a standalone verification session')
.option('--source <source>', 'agent | agent-testing', 'agent-testing')
.option('--operation <id>', 'Link to an existing Agent Run')
.option('--title <title>', 'Session title')
.option('--goal <goal>', 'Goal/task being verified')
.option('--json [fields]', 'Output JSON')
.action(async (operationId: string, options: { json?: boolean | string }) => {
.action(
async (options: {
goal?: string;
json?: boolean | string;
operation?: string;
source?: string;
title?: string;
}) => {
const client = await getTrpcClient();
const created = await client.verify.createRun.mutate({
goal: options.goal,
operationId: options.operation,
source: options.source as any,
title: options.title,
});
if (options.json !== undefined) {
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.green('✓')} Created run ${pc.bold(created.id)}`);
},
);
run
.command('list')
.description('List recent verification sessions')
.option('--json [fields]', 'Output JSON')
.action(async (options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const results = await client.verify.listResults.query({ operationId });
const runs = await client.verify.listRuns.query();
if (options.json !== undefined) {
outputJson(runs, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (runs.length === 0) return void console.log('No runs found.');
printTable(
runs.map((r: any) => [
r.id,
truncate(r.title || '', 40),
r.source,
r.status ?? '',
r.operationId ? 'agent' : 'standalone',
r.createdAt ? timeAgo(r.createdAt) : '',
]),
['ID', 'TITLE', 'SOURCE', 'STATUS', 'KIND', 'CREATED'],
);
});
run
.command('get <runId>')
.description('Show a verification session')
.option('--json [fields]', 'Output JSON')
.action(async (runId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const item = await client.verify.getRun.query({ verifyRunId: runId });
if (options.json !== undefined) {
outputJson(item, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (!item) return void console.log('Run not found.');
console.log(JSON.stringify(item, null, 2));
});
// ════════════ result (check result entity) ════════════
const result = verify.command('result').description('Check results (verify_check_results)');
result
.command('ingest')
.description('Upsert one check result by (run, checkItemId) from a supplied verdict')
.requiredOption('--run <verifyRunId>', 'Target session id')
.requiredOption('--check <checkItemId>', 'Stable check item id within the session')
.requiredOption('--verdict <verdict>', 'passed|failed|uncertain')
.option('--title <title>', 'Check title')
.option('--index <n>', 'Display index')
.option('--confidence <n>', '0-1 confidence')
.option('--status <status>', 'pending|running|passed|failed|skipped (derived from verdict)')
.option('--evidence <text>', 'Key observation (stored as Toulmin evidence)')
.option('--suggestion <text>', 'Remediation hint')
.option('--soft', 'Non-blocking (required=false); defaults to blocking')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
check: string;
confidence?: string;
evidence?: string;
index?: string;
json?: boolean | string;
run: string;
soft?: boolean;
status?: string;
suggestion?: string;
title?: string;
verdict: string;
}) => {
const client = await getTrpcClient();
const created = await client.verify.ingestResult.mutate({
checkItemId: options.check,
checkItemIndex: options.index ? Number.parseInt(options.index, 10) : undefined,
checkItemTitle: options.title,
confidence: options.confidence ? Number.parseFloat(options.confidence) : undefined,
required: options.soft ? false : undefined,
status: options.status as any,
suggestion: options.suggestion,
toulmin: options.evidence ? { evidence: options.evidence } : undefined,
verdict: options.verdict as any,
verifyRunId: options.run,
});
if (options.json !== undefined) {
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.green('✓')} Result ${pc.bold(created.id)} (${created.verdict})`);
},
);
result
.command('list')
.description('List check results — by session (--run) or by Agent Run (--operation)')
.option('--run <verifyRunId>', 'List by verification session')
.option('--operation <operationId>', 'List by Agent Run')
.option('--json [fields]', 'Output JSON')
.action(async (options: { json?: boolean | string; operation?: string; run?: string }) => {
if (!options.run && !options.operation) {
log.error('Provide either --run or --operation');
process.exit(1);
}
const client = await getTrpcClient();
const results = options.run
? await client.verify.listResultsByRun.query({ verifyRunId: options.run })
: await client.verify.listResults.query({ operationId: options.operation! });
if (options.json !== undefined) {
outputJson(results, typeof options.json === 'string' ? options.json : undefined);
return;
@@ -421,6 +589,143 @@ export function registerVerifyCommand(program: Command) {
printResults(results);
});
// ════════════ evidence (artifact entity) ════════════
const evidence = verify.command('evidence').description('Evidence artifacts (verify_evidence)');
evidence
.command('upload')
.description('Attach an evidence artifact (file or inline text) to a check result')
.requiredOption('--check <checkResultId>', 'Target check result id')
.requiredOption('--type <type>', 'screenshot|gif|video|text|dom_snapshot|transcript')
.option('--file <path>', 'Local file to upload as the artifact')
.option('--content <text>', 'Inline text payload (instead of a file)')
.option('--by <capturedBy>', 'agent-browser|cdp|cli|program|llm_judge', 'cli')
.option('--desc <text>', 'Human-readable caption')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
by?: string;
check: string;
content?: string;
desc?: string;
file?: string;
json?: boolean | string;
type: string;
}) => {
if (Boolean(options.file) === Boolean(options.content)) {
log.error('Provide exactly one of --file or --content');
process.exit(1);
}
const client = await getTrpcClient();
let fileId: string | undefined;
if (options.file) {
const uploaded = await uploadLocalFile(client, options.file);
fileId = uploaded.id;
}
const ev = await client.verify.uploadEvidence.mutate({
capturedBy: options.by as any,
checkResultId: options.check,
content: options.content,
description: options.desc,
fileId,
type: options.type as any,
});
if (options.json !== undefined) {
outputJson(ev, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(
`${pc.green('✓')} Evidence ${pc.bold(ev.id)}${fileId ? ` (file ${fileId})` : ''}`,
);
},
);
evidence
.command('list <checkResultId>')
.description('List evidence for a check result')
.option('--json [fields]', 'Output JSON')
.action(async (checkResultId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const rows = await client.verify.listEvidence.query({ checkResultId });
if (options.json !== undefined) {
outputJson(rows, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (rows.length === 0) return void console.log('No evidence.');
printTable(
rows.map((e: any) => [
e.id,
e.type,
e.capturedBy ?? '',
e.fileId ? 'file' : 'inline',
truncate(e.description || '', 40),
]),
['ID', 'TYPE', 'BY', 'PAYLOAD', 'DESC'],
);
});
// ════════════ report (narrative entity) ════════════
const report = verify.command('report').description('Verification reports (verify_reports)');
report
.command('upsert')
.description('Write (overwrite) the report for a session')
.requiredOption('--run <verifyRunId>', 'Target session id')
.option('--verdict <verdict>', 'passed|failed|uncertain')
.option('--summary <text>', 'Short summary')
.option('--content <markdown>', 'Full markdown body')
.option('--total <n>', 'Total checks')
.option('--passed <n>', 'Passed checks')
.option('--failed <n>', 'Failed checks')
.option('--uncertain <n>', 'Uncertain checks')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
content?: string;
failed?: string;
json?: boolean | string;
passed?: string;
run: string;
summary?: string;
total?: string;
uncertain?: string;
verdict?: string;
}) => {
const num = (s?: string) => (s === undefined ? undefined : Number.parseInt(s, 10));
const client = await getTrpcClient();
const created = await client.verify.upsertReport.mutate({
content: options.content,
failedChecks: num(options.failed),
passedChecks: num(options.passed),
summary: options.summary,
totalChecks: num(options.total),
uncertainChecks: num(options.uncertain),
verdict: options.verdict as any,
verifyRunId: options.run,
});
if (options.json !== undefined) {
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.green('✓')} Report ${pc.bold(created.id)} (${created.verdict ?? '—'})`);
},
);
report
.command('get <runId>')
.description('Show the report for a session')
.option('--json [fields]', 'Output JSON')
.action(async (runId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const item = await client.verify.getReport.query({ verifyRunId: runId });
if (options.json !== undefined) {
outputJson(item, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (!item) return void console.log('No report.');
console.log(JSON.stringify(item, null, 2));
});
// ════════════ feedback ════════════
verify
.command('decision <resultId> <decision>')
@@ -431,6 +736,128 @@ export function registerVerifyCommand(program: Command) {
await client.verify.submitDecision.mutate({ decision, resultId });
console.log(`${pc.green('✓')} Recorded ${pc.bold(decision)} on result ${pc.bold(resultId)}`);
});
// ════════════ ingest (aggregate convenience over the atomic commands) ════════════
verify
.command('ingest-report <reportDir>')
.description(
'Ingest a local agent-testing report (result.json + report.md + assets) as a verify session',
)
.option('--source <source>', 'agent | agent-testing', 'agent-testing')
.option('--operation <id>', 'Link the session to an existing Agent Run')
.option('--title <title>', 'Override the session title')
.option('--goal <goal>', 'The goal/task being verified')
.option('--open', 'Print the in-app URL to open the report')
.option('--json [fields]', 'Output JSON')
.action(
async (
reportDir: string,
options: {
goal?: string;
json?: boolean | string;
open?: boolean;
operation?: string;
source?: string;
title?: string;
},
) => {
const dir = path.resolve(reportDir);
const resultPath = path.join(dir, 'result.json');
if (!existsSync(resultPath)) {
log.error(`result.json not found in ${dir}`);
process.exit(1);
}
let result: any;
try {
result = JSON.parse(readFileSync(resultPath, 'utf8'));
} catch {
log.error('result.json is not valid JSON');
process.exit(1);
}
const cases: any[] = Array.isArray(result.cases) ? result.cases : [];
const summary = result.summary ?? {};
const reportMdPath = path.join(dir, 'report.md');
const content = existsSync(reportMdPath) ? readFileSync(reportMdPath, 'utf8') : undefined;
const client = await getTrpcClient();
// 1. Create the verification session.
const run = await client.verify.createRun.mutate({
goal: options.goal,
operationId: options.operation,
source: options.source as any,
title: options.title ?? result.title,
});
// 2. Ingest each case as a check result + its evidence.
let uploaded = 0;
for (const [index, c] of cases.entries()) {
const checkItemId = String(c.id ?? c.checkItemId ?? `case-${index + 1}`);
const verdict = toVerdict(c.result ?? c.status ?? c.verdict);
const observation = c.keyObservation ?? c.observation ?? c.note;
const checkResult = await client.verify.ingestResult.mutate({
checkItemId,
checkItemIndex: index,
checkItemTitle: c.name ?? c.case ?? c.title ?? checkItemId,
required: c.required ?? true,
// The case's key observation is recorded as Toulmin evidence; a real
// remediation hint (if the report provides one) goes to `suggestion`.
suggestion: typeof c.suggestion === 'string' ? c.suggestion : undefined,
toulmin: typeof observation === 'string' ? { evidence: observation } : undefined,
verdict,
verifierType: 'agent',
verifyRunId: run.id,
});
for (const rel of evidencePaths(c.evidence)) {
const abs = path.isAbsolute(rel) ? rel : path.join(dir, rel);
if (!existsSync(abs)) {
log.warn(`evidence not found, skipping: ${rel}`);
continue;
}
const file = await uploadLocalFile(client, abs);
await client.verify.uploadEvidence.mutate({
capturedBy: 'cli',
checkResultId: checkResult.id,
description: c.name ?? path.basename(abs),
fileId: file.id,
type: evidenceTypeForFile(abs),
});
uploaded += 1;
}
}
// 3. Write the report (full markdown + stats snapshot).
await client.verify.upsertReport.mutate({
content,
failedChecks: summary.failed,
passedChecks: summary.passed,
summary: typeof summary.note === 'string' ? summary.note : undefined,
totalChecks: summary.total ?? cases.length,
uncertainChecks: (summary.blocked ?? 0) + (summary.uncertain ?? 0) || undefined,
verdict: summary.verdict ? toVerdict(summary.verdict) : undefined,
verifyRunId: run.id,
});
if (options.json !== undefined) {
outputJson(
{ cases: cases.length, evidence: uploaded, verifyRunId: run.id },
typeof options.json === 'string' ? options.json : undefined,
);
return;
}
console.log(
`${pc.green('✓')} Ingested ${pc.bold(String(cases.length))} case(s), ${pc.bold(String(uploaded))} evidence file(s)`,
);
console.log(`${pc.bold('verifyRunId')}: ${run.id}`);
if (options.open) {
console.log(`${pc.bold('open')}: /verify/${run.id}`);
}
},
);
}
function printResults(results: any[]): void {
+82
View File
@@ -19,11 +19,22 @@ vi.mock('node:os', async (importOriginal) => {
};
});
// Mock only `execFileSync` (used by isDaemonProcess to read a process command
// line); keep the real `spawn` so nothing else changes.
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
return { ...actual, execFileSync: vi.fn() };
});
// eslint-disable-next-line import-x/first
import { execFileSync } from 'node:child_process';
// eslint-disable-next-line import-x/first
import {
appendLog,
getLogPath,
getRunningDaemonPid,
isDaemonProcess,
isProcessAlive,
readPid,
readStatus,
@@ -35,9 +46,15 @@ import {
writeStatus,
} from './manager';
// A command line that matches the daemon signature (`connect … --daemon-child`).
const DAEMON_COMMAND = '/usr/local/bin/node /path/to/cli.js connect --daemon-child';
describe('daemon manager', () => {
beforeEach(async () => {
await mkdir(mockDir, { recursive: true });
// Default: any inspected PID looks like our daemon. Tests that need a
// reused / unrelated PID override this per-case.
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
});
afterEach(() => {
@@ -80,6 +97,36 @@ describe('daemon manager', () => {
});
});
describe('isDaemonProcess', () => {
it('should return true when the command line matches the daemon signature', () => {
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
expect(isDaemonProcess(12345)).toBe(true);
expect(execFileSync).toHaveBeenCalledWith(
'ps',
['-ww', '-p', '12345', '-o', 'command='],
expect.any(Object),
);
});
it('should return false for an unrelated process command line', () => {
vi.mocked(execFileSync).mockReturnValue('/usr/bin/vim notes.txt' as any);
expect(isDaemonProcess(12345)).toBe(false);
});
it('should return false when the signature is only partially present', () => {
// `connect` without the internal `--daemon-child` flag is not our daemon.
vi.mocked(execFileSync).mockReturnValue('/usr/bin/node /path/cli connect' as any);
expect(isDaemonProcess(12345)).toBe(false);
});
it('should return false when ps is unavailable / throws', () => {
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('ps: command not found');
});
expect(isDaemonProcess(12345)).toBe(false);
});
});
describe('getRunningDaemonPid', () => {
it('should return null when no PID file', () => {
expect(getRunningDaemonPid()).toBeNull();
@@ -110,6 +157,23 @@ describe('daemon manager', () => {
expect(readStatus()).toBeNull();
});
it('should treat a live but reused (non-daemon) PID as stale and clean up', () => {
// process.pid is alive, but the inspected command line is not our daemon —
// simulates the OS reusing a dead daemon's PID for an unrelated process.
writePid(process.pid);
writeStatus({
connectionStatus: 'connected',
gatewayUrl: 'https://test.com',
pid: process.pid,
startedAt: new Date().toISOString(),
});
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
expect(getRunningDaemonPid()).toBeNull();
expect(readPid()).toBeNull();
expect(readStatus()).toBeNull();
});
});
describe('status file', () => {
@@ -232,5 +296,23 @@ describe('daemon manager', () => {
killSpy.mockRestore();
});
it('should NOT SIGTERM a live PID that is not our daemon', () => {
// Stale daemon.pid whose PID was reused by an unrelated, living process.
writePid(process.pid);
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const result = stopDaemon();
expect(result).toBe(false);
// Only the liveness probe (signal 0) is allowed — never a real SIGTERM.
expect(killSpy).not.toHaveBeenCalledWith(process.pid, 'SIGTERM');
// Stale metadata is cleaned up so we don't keep re-checking it.
expect(readPid()).toBeNull();
killSpy.mockRestore();
});
});
});
+33 -3
View File
@@ -1,4 +1,4 @@
import { spawn } from 'node:child_process';
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -70,6 +70,34 @@ export function isProcessAlive(pid: number): boolean {
}
}
/**
* Verify a live PID actually belongs to a LobeHub connect daemon.
*
* A bare `isProcessAlive` check is not enough: if a daemon dies without cleaning
* up `daemon.pid` (crash, `kill -9`, reboot), the OS can later reuse that PID
* for an unrelated process. Acting on the stale PID would let `lh logout` /
* `connect stop` SIGTERM a stranger. The daemon is always spawned as
* `<node> … connect … --daemon-child`, so we confirm that signature in the
* process command line before trusting the PID.
*
* Best-effort and deliberately conservative: if the command line can't be read
* (e.g. `ps` is unavailable), we return `false` so callers never kill a process
* we can't positively identify.
*/
export function isDaemonProcess(pid: number): boolean {
try {
// `-ww` disables column truncation so the trailing `--daemon-child` flag is
// never cut off; stderr is silenced so a dead PID just yields an empty match.
const command = execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return command.includes('--daemon-child') && command.includes('connect');
} catch {
return false;
}
}
/**
* Get the PID of a running daemon, cleaning up stale PID files.
* Returns null if no daemon is running.
@@ -78,9 +106,11 @@ export function getRunningDaemonPid(): number | null {
const pid = readPid();
if (pid === null) return null;
if (isProcessAlive(pid)) return pid;
// Require both liveness AND identity — a live-but-reused PID is treated as
// stale so we never act on a process that isn't ours.
if (isProcessAlive(pid) && isDaemonProcess(pid)) return pid;
// Stale PID file — process is dead
// Stale PID file — process is dead or the PID now belongs to someone else.
removePid();
removeStatus();
return null;
+5
View File
@@ -10,6 +10,11 @@ export interface TaskEntry {
startedAt: string;
taskId: string;
topicId: string;
/**
* Workspace that owns the dispatched topic. Persisted so the cancel-time
* notify still scopes to the right workspace after the daemon restarts.
*/
workspaceId?: string;
}
function getRegistryPath(): string {
-10
View File
@@ -103,16 +103,6 @@ describe('spawnHeteroAgentRun', () => {
expect(args).toContain('sess-9');
});
it('passes an explicit CLI command through to `lh hetero exec`', () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
void spawnHeteroAgentRun({ ...baseParams, command: '/opt/bin/codex' });
const [, args] = spawnMock.mock.calls[0];
expect(args).toEqual(expect.arrayContaining(['--command', '/opt/bin/codex']));
});
it('sends a content-block array to stdin when systemContext is provided', async () => {
const child = makeFakeChild();
spawnMock.mockReturnValue(child);
-3
View File
@@ -7,7 +7,6 @@ import {
export interface SpawnHeteroAgentRunParams {
agentType: string;
command?: string;
cwd?: string;
/** Image attachments (signed URLs) appended as image content blocks. */
imageList?: HeteroExecImageRef[];
@@ -53,7 +52,6 @@ export function spawnHeteroAgentRun(
): Promise<AgentRunAckResult> {
const {
agentType,
command,
cwd,
imageList,
jwt,
@@ -74,7 +72,6 @@ export function spawnHeteroAgentRun(
'exec',
'--type',
agentType,
...(command ? ['--command', command] : []),
'--operation-id',
operationId,
'--topic',
+42
View File
@@ -38,3 +38,45 @@ export async function registerDevice(
platform: process.platform,
});
}
type Auth = { serverUrl: string; token: string; tokenType: 'apiKey' | 'jwt' | 'serviceToken' };
/**
* Identity for a WORKSPACE device: derived from the workspaceId (namespaced) so
* the same physical machine enrolled into a workspace is a distinct device from
* its personal identity, and stable across reconnects.
*/
export function resolveWorkspaceDeviceIdentity(
workspaceId: string,
explicitDeviceId?: string,
): DeviceIdentity {
if (explicitDeviceId) return { deviceId: explicitDeviceId, identitySource: 'fallback' };
return deriveDeviceId(`workspace:${workspaceId}`);
}
/**
* Mint a workspace-device connect token (owner-only on the server). The returned
* token carries the `workspace_id` claim the gateway routes by.
*/
export async function mintWorkspaceConnectToken(
auth: Auth,
workspaceId: string,
): Promise<{ token: string; workspaceId: string }> {
const trpc = createLambdaClient(auth, workspaceId);
return trpc.device.mintWorkspaceConnectToken.mutate();
}
/** Register this machine as a device of the given workspace (owner-only). */
export async function registerWorkspaceDevice(
auth: Auth,
identity: DeviceIdentity,
workspaceId: string,
): Promise<void> {
const trpc = createLambdaClient(auth, workspaceId);
await trpc.device.registerWorkspaceDevice.mutate({
deviceId: identity.deviceId,
hostname: os.hostname(),
identitySource: identity.identitySource,
platform: process.platform,
});
}
+16
View File
@@ -0,0 +1,16 @@
import { createRequire } from 'node:module';
/**
* Single source of truth for this package's own metadata.
*
* Must live directly under `src/` (depth 1), the same depth as the bundled
* entry `dist/index.js`, so `../package.json` resolves to `@lobehub/cli`'s own
* package.json both when running from source (`bun src/index.ts`) and from the
* tsdown bundle (`dist/index.js`). A module one directory deeper would resolve
* the path outside the package once everything is bundled into a single file.
*/
const require = createRequire(import.meta.url);
const pkg = require('../package.json') as { name: string; version: string };
export const cliPackageName = pkg.name;
export const cliVersion = pkg.version;
+5 -7
View File
@@ -1,5 +1,3 @@
import { createRequire } from 'node:module';
import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
@@ -33,11 +31,10 @@ import { registerStatusCommand } from './commands/status';
import { registerTaskCommand } from './commands/task';
import { registerThreadCommand } from './commands/thread';
import { registerTopicCommand } from './commands/topic';
import { registerUpdateCommand } from './commands/update';
import { registerUserCommand } from './commands/user';
import { registerVerifyCommand } from './commands/verify';
const require = createRequire(import.meta.url);
const { version } = require('../package.json');
import { cliVersion } from './pkg';
export function createProgram() {
const program = new Command();
@@ -45,7 +42,7 @@ export function createProgram() {
program
.name('lh')
.description('LobeHub CLI - manage and connect to LobeHub services')
.version(version);
.version(cliVersion);
registerLoginCommand(program);
registerLogoutCommand(program);
@@ -80,8 +77,9 @@ export function createProgram() {
registerConfigCommand(program);
registerEvalCommand(program);
registerMigrateCommand(program);
registerUpdateCommand(program);
return program;
}
export { version as cliVersion };
export { cliPackageName, cliVersion } from './pkg';
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getTrpcClient } from '../../api/client';
import { removeTask, saveTask } from '../../daemon/taskRegistry';
import { runHeteroTask } from '../heteroTask';
@@ -34,6 +35,8 @@ vi.mock('../../api/client', () => ({
}),
}));
const getTrpcClientMock = vi.mocked(getTrpcClient);
vi.mock('../../utils/logger', () => ({
log: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));
@@ -248,4 +251,56 @@ describe('runHeteroTask (openclaw)', () => {
expect(removeTask).toHaveBeenCalledWith('task-1');
killSpy.mockRestore();
});
it('threads workspaceId into the saved task entry and the spawned child env', async () => {
const child = makeMockChild(6666);
spawnMock.mockReturnValue(child);
await runHeteroTask({
agentId: 'agent-ws',
agentType: 'openclaw',
operationId: 'op-ws',
prompt: 'workspace dispatch',
taskId: 'task-ws',
topicId: 'topic-ws',
workspaceId: 'ws-42',
});
expect(saveTask).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws-42' }));
const [, , spawnOpts] = spawnMock.mock.calls[0] as [
string,
string[],
{ env: NodeJS.ProcessEnv },
];
expect(spawnOpts.env.LOBEHUB_WORKSPACE_ID).toBe('ws-42');
});
it('passes workspaceId to getTrpcClient when the close handler auto-notifies', async () => {
const child = makeMockChild(7777);
spawnMock.mockReturnValue(child);
await runHeteroTask({
agentId: 'agent-ws',
agentType: 'openclaw',
operationId: 'op-ws-2',
prompt: 'ws prompt',
taskId: 'task-ws-2',
topicId: 'topic-ws-2',
workspaceId: 'ws-99',
});
getTrpcClientMock.mockClear();
// Abnormal exit triggers sendAutoNotify + sendDoneSignal — both must scope
// to the dispatching workspace or agentNotify resolves the topic in
// personal mode and 404s.
child._emit('close', 1, null);
// Await microtask drain so the close-handler promise chain settles.
await new Promise((r) => setImmediate(r));
expect(getTrpcClientMock.mock.calls.length).toBeGreaterThan(0);
for (const call of getTrpcClientMock.mock.calls) {
expect(call[0]).toBe('ws-99');
}
});
});
+35 -14
View File
@@ -57,6 +57,13 @@ export interface RunHeteroTaskParams {
prompt: string;
taskId: string;
topicId: string;
/**
* Workspace id seeded by the server when the dispatched topic lives in a
* workspace. Threaded into auto-notify calls (as `X-Workspace-Id`) and into
* the spawned child's `LOBEHUB_WORKSPACE_ID` env so its own `lh notify`
* shells inherit the same scope.
*/
workspaceId?: string;
}
export interface CancelHeteroTaskParams {
@@ -69,9 +76,10 @@ async function sendAutoNotify(
taskId: string,
text: string,
agentId?: string,
workspaceId?: string,
): Promise<void> {
try {
const client = await getTrpcClient();
const client = await getTrpcClient(workspaceId);
await client.agentNotify.notify.mutate({
agentId,
content: text,
@@ -90,9 +98,13 @@ async function sendAutoNotify(
* `sendAutoNotify` which writes an error message AND triggers completion via
* the `done` flag.
*/
async function sendDoneSignal(topicId: string, agentId?: string): Promise<void> {
async function sendDoneSignal(
topicId: string,
agentId?: string,
workspaceId?: string,
): Promise<void> {
try {
const client = await getTrpcClient();
const client = await getTrpcClient(workspaceId);
await client.agentNotify.notify.mutate({
agentId,
content: '',
@@ -138,9 +150,15 @@ function buildNotifyProtocol(lhPath: string, topicId: string): string {
}
export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string> {
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = params;
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId, workspaceId } = params;
const workDir = cwd || process.cwd();
const lhPath = resolveLhPath();
// Propagate workspace scope into the spawned child so its own `lh notify`
// invocations (and any grandchildren it shells out) inherit the same scope
// via getTrpcClient → resolveWorkspaceId.
const childEnv: NodeJS.ProcessEnv = workspaceId
? { ...process.env, LOBEHUB_WORKSPACE_ID: workspaceId }
: { ...process.env };
if (agentType === 'openclaw') {
// openclaw agent --local is one-shot: each invocation processes one message and exits.
@@ -182,7 +200,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
{
cwd: workDir,
detached: true,
env: { ...process.env },
env: childEnv,
stdio: 'ignore',
},
);
@@ -201,6 +219,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
startedAt: new Date().toISOString(),
taskId,
topicId,
workspaceId,
});
log.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
@@ -216,12 +235,12 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
: `Task failed (exit code: ${code})`;
// Send error message first, THEN signal done (sequential).
// Fire-and-forget both, but ensure done is always sent even if notify fails.
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
void sendAutoNotify(topicId, taskId, text, agentId, workspaceId).finally(() =>
sendDoneSignal(topicId, agentId, workspaceId),
);
} else {
// Clean exit — openclaw already sent its final message; just signal done.
void sendDoneSignal(topicId, agentId);
void sendDoneSignal(topicId, agentId, workspaceId);
}
});
@@ -253,7 +272,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
const child = spawn('hermes', hermesArgs, {
cwd: workDir,
detached: true,
env: { ...process.env },
env: childEnv,
stdio: ['ignore', 'pipe', 'ignore'],
});
@@ -269,6 +288,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
startedAt: new Date().toISOString(),
taskId,
topicId,
workspaceId,
});
log.info(`Hermes task started: taskId=${taskId} pid=${pid}`);
@@ -284,8 +304,8 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
void sendAutoNotify(topicId, taskId, text, agentId, workspaceId).finally(() =>
sendDoneSignal(topicId, agentId, workspaceId),
);
return;
}
@@ -298,11 +318,11 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
if (sessionId) saveHermesSessionId(topicId, sessionId);
if (response) {
void sendAutoNotify(topicId, taskId, response, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
void sendAutoNotify(topicId, taskId, response, agentId, workspaceId).finally(() =>
sendDoneSignal(topicId, agentId, workspaceId),
);
} else {
void sendDoneSignal(topicId, agentId);
void sendDoneSignal(topicId, agentId, workspaceId);
}
});
@@ -334,6 +354,7 @@ export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<
taskId,
'Task already completed or cancelled',
entry.agentId,
entry.workspaceId,
);
}
+125
View File
@@ -0,0 +1,125 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { TrpcClient } from '../api/client';
/**
* Minimal extension → MIME map for files uploaded from the local filesystem.
* Unknown extensions fall back to `application/octet-stream`.
*/
const MIME_MAP: Record<string, string> = {
aac: 'audio/aac',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
flac: 'audio/flac',
gif: 'image/gif',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
json: 'application/json',
m4a: 'audio/mp4',
md: 'text/markdown',
mp3: 'audio/mpeg',
mp4: 'video/mp4',
ogg: 'audio/ogg',
pdf: 'application/pdf',
png: 'image/png',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
svg: 'image/svg+xml',
txt: 'text/plain',
wav: 'audio/wav',
webm: 'audio/webm',
webp: 'image/webp',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
/**
* Detect a MIME type from a file name's extension.
*/
export const detectMimeType = (fileName: string): string => {
const ext = path.extname(fileName).toLowerCase().slice(1);
return MIME_MAP[ext] || 'application/octet-stream';
};
export interface UploadLocalFileOptions {
knowledgeBaseId?: string;
parentId?: string;
}
/**
* Read a file from the local filesystem, upload it to S3 via a pre-signed URL,
* and create the corresponding file record. Shared by `file upload` and
* `kb upload`.
*
* @returns the created file record
*/
export const uploadLocalFile = async (
client: TrpcClient,
filePath: string,
options: UploadLocalFileOptions = {},
) => {
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) {
throw new Error(`File not found: ${resolved}`);
}
const stat = fs.statSync(resolved);
if (!stat.isFile()) {
throw new Error(`Not a file: ${resolved}`);
}
const fileName = path.basename(resolved);
const fileBuffer = fs.readFileSync(resolved);
// Compute SHA-256 hash for deduplication
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const ext = path.extname(fileName).toLowerCase().slice(1);
const fileType = detectMimeType(fileName);
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
// 1. Dedup: if the same bytes are already stored (and the object still
// exists), skip the S3 upload entirely and reuse the existing url.
const existing = (await client.file.checkFileHash.mutate({ hash })) as {
isExist?: boolean;
url?: string;
};
let pathname: string;
if (existing?.isExist && existing.url) {
pathname = existing.url;
} else {
// 2. Get a pre-signed upload URL and PUT the bytes to S3
pathname = ext ? `files/${date}/${hash}.${ext}` : `files/${date}/${hash}`;
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
const uploadRes = await fetch(presignedUrl, {
body: fileBuffer,
headers: { 'Content-Type': fileType },
method: 'PUT',
});
if (!uploadRes.ok) {
throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
}
}
// 3. Create the file record
return await client.file.createFile.mutate({
fileType,
hash,
knowledgeBaseId: options.knowledgeBaseId,
metadata: {
date,
dirname: '',
filename: fileName,
path: pathname,
},
name: fileName,
parentId: options.parentId,
size: stat.size,
url: pathname,
});
};
-8
View File
@@ -9,14 +9,6 @@ export default defineConfig({
find: '@lobechat/device-gateway-client',
replacement: path.resolve(__dirname, '../../packages/device-gateway-client/src/index.ts'),
},
{
find: '@lobechat/device-identity',
replacement: path.resolve(__dirname, '../../packages/device-identity/src/index.ts'),
},
{
find: '@lobechat/device-control',
replacement: path.resolve(__dirname, '../../packages/device-control/src/index.ts'),
},
{
find: '@lobechat/local-file-shell',
replacement: path.resolve(__dirname, '../../packages/local-file-shell/src/index.ts'),
+2 -2
View File
@@ -127,8 +127,8 @@
],
"overrides": {
"node-gyp": "^12.4.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react": "19.2.7",
"react-dom": "19.2.7",
"vitest": "3.2.6"
}
}
+6
View File
@@ -17,3 +17,9 @@ packages:
- './stubs/business-const'
- './stubs/types'
- '.'
allowBuilds:
electron: set this to true or false
electron-winstaller: set this to true or false
esbuild: set this to true or false
get-windows: set this to true or false
node-mac-permissions: set this to true or false
@@ -77,6 +77,12 @@ interface PlatformTaskEntry {
operationId: string;
pid: number;
topicId: string;
/**
* Workspace that owns the dispatched topic — used at exit time so the
* cleanup notify still scopes to the workspace agentNotify resolves the
* topic in (the server seeds this via the `runHeteroTask` args).
*/
workspaceId?: string;
}
/**
@@ -286,12 +292,11 @@ export default class GatewayConnectionCtr extends ControllerModule {
return { reason: 'Remote server URL not configured', status: 'rejected' };
}
// Hand off to `lh hetero exec`; the spawned CLI then owns adapt ->
// Fire-and-forget: lh hetero exec handles spawn -> adapt ->
// BatchIngester -> heteroIngest/heteroFinish -> server -> Gateway -> clients.
// Same command as spawnHeteroSandbox() on the server side.
await this.heterogeneousAgentCtr.spawnLhHeteroExec({
this.heterogeneousAgentCtr.spawnLhHeteroExec({
agentType: request.agentType,
command: request.command,
cwd: request.cwd,
imageList: request.imageList,
jwt: request.jwt,
@@ -525,6 +530,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
prompt: string;
taskId: string;
topicId: string;
workspaceId?: string;
},
);
return { content: json, state: safeJsonParse(json), success: true };
@@ -766,8 +772,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
prompt: string;
taskId: string;
topicId: string;
workspaceId?: string;
}): Promise<string> {
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = args;
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId, workspaceId } = args;
const workDir = cwd || process.cwd();
const [serverUrl, accessToken] = await Promise.all([
@@ -775,11 +782,15 @@ export default class GatewayConnectionCtr extends ControllerModule {
this.remoteServerConfigCtr.getAccessToken(),
]);
// Inject auth into child env so `lh notify` can authenticate without CLI config.
// Inject auth + workspace scope into child env so `lh notify` can
// authenticate AND target the same workspace as the dispatched topic
// (without LOBEHUB_WORKSPACE_ID, the CLI's notify falls back to personal
// mode and the workspace topic 404s).
const childEnv: NodeJS.ProcessEnv = {
...process.env,
...(accessToken && { LOBEHUB_JWT: accessToken }),
...(serverUrl && { LOBEHUB_SERVER: serverUrl }),
...(workspaceId && { LOBEHUB_WORKSPACE_ID: workspaceId }),
};
if (agentType === 'openclaw') {
@@ -824,7 +835,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
if (pid === undefined) throw new Error('Failed to get PID for openclaw process');
child.unref();
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
this.platformTasks.set(taskId, {
agentId,
agentType,
operationId,
pid,
topicId,
workspaceId,
});
child.on('close', (code, signal) => {
this.platformTasks.delete(taskId);
@@ -832,11 +850,31 @@ export default class GatewayConnectionCtr extends ControllerModule {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
void this.sendNotify({
agentId,
content: text,
role: 'assistant',
topicId,
workspaceId,
}).finally(() =>
this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
}),
);
} else {
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
void this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
});
}
});
@@ -875,7 +913,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
child.unref();
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
this.platformTasks.set(taskId, {
agentId,
agentType,
operationId,
pid,
topicId,
workspaceId,
});
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
@@ -889,8 +934,21 @@ export default class GatewayConnectionCtr extends ControllerModule {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
void this.sendNotify({
agentId,
content: text,
role: 'assistant',
topicId,
workspaceId,
}).finally(() =>
this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
}),
);
return;
}
@@ -903,11 +961,31 @@ export default class GatewayConnectionCtr extends ControllerModule {
if (sessionId) this.hermesSessionMap.set(topicId, sessionId);
if (response) {
void this.sendNotify({ agentId, content: response, role: 'assistant', topicId }).finally(
() => this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
void this.sendNotify({
agentId,
content: response,
role: 'assistant',
topicId,
workspaceId,
}).finally(() =>
this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
}),
);
} else {
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
void this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
});
}
});
@@ -935,6 +1013,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
content: 'Task already completed or cancelled',
role: 'assistant',
topicId: entry.topicId,
workspaceId: entry.workspaceId,
});
}
@@ -952,6 +1031,12 @@ export default class GatewayConnectionCtr extends ControllerModule {
done?: boolean;
role: string;
topicId: string;
/**
* Workspace scope for the notify. When set, attaches `X-Workspace-Id` so
* agentNotify resolves the workspace-owned topic instead of falling back
* to personal mode (which would 404 the lookup).
*/
workspaceId?: string;
}): Promise<void> {
try {
const [serverUrl, token] = await Promise.all([
@@ -960,12 +1045,16 @@ export default class GatewayConnectionCtr extends ControllerModule {
]);
if (!serverUrl || !token) return;
const { workspaceId, ...body } = params;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Oidc-Auth': token,
};
if (workspaceId) headers['X-Workspace-Id'] = workspaceId;
await fetch(`${serverUrl}/trpc/lambda/agentNotify.notify`, {
body: JSON.stringify({ json: params }),
headers: {
'Content-Type': 'application/json',
'Oidc-Auth': token,
},
body: JSON.stringify({ json: body }),
headers,
method: 'POST',
});
} catch {
@@ -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);
@@ -36,7 +36,6 @@ import {
import { app as electronApp, BrowserWindow } from 'electron';
import { HETERO_AGENT_FILES_DIR, HETERO_AGENT_TRACING_DIR } from '@/const/heteroAgent';
import type { ToolStatus } from '@/core/infrastructure/ToolDetectorManager';
import { getHeterogeneousAgentDriver } from '@/modules/heterogeneousAgent';
import type {
HeterogeneousAgentBuildPlan,
@@ -220,11 +219,6 @@ interface CliTraceSession {
writeQueue: Promise<void>;
}
interface ResolvedDeviceHeteroCommand {
command: string;
resolvedPathEnv?: string;
}
/**
* External Agent Controller — manages external agent CLI processes via Electron IPC.
*
@@ -265,58 +259,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
return session.agentType === 'codex' ? 'codex' : 'claude';
}
private getDefaultCommandForAgentType(agentType: string): string | undefined {
if (agentType === 'codex') return 'codex';
if (agentType === 'claude-code') return 'claude';
}
private isBareCommand(command: string): boolean {
return !command.includes('/') && !command.includes('\\');
}
private async resolveDeviceHeteroCommand(
agentType: string,
command?: string,
): Promise<ResolvedDeviceHeteroCommand | undefined> {
const requestedCommand = command?.trim() || this.getDefaultCommandForAgentType(agentType);
if (!requestedCommand) return;
if (agentType !== 'claude-code' && agentType !== 'codex') {
return { command: requestedCommand };
}
const defaultCommand = this.getDefaultCommandForAgentType(agentType);
let status: ToolStatus | undefined;
try {
status =
requestedCommand === defaultCommand && defaultCommand
? await this.app.toolDetectorManager?.detect?.(defaultCommand, true)
: await detectHeterogeneousCliCommand(
agentType === 'claude-code' ? 'claude-code' : 'codex',
requestedCommand,
);
if (status?.available && status.path && this.isBareCommand(requestedCommand)) {
return { command: status.path, resolvedPathEnv: status.resolvedPathEnv };
}
} catch (err) {
logger.warn(
'resolveDeviceHeteroCommand: failed to resolve %s command "%s": %s',
agentType,
requestedCommand,
err instanceof Error ? err.message : String(err),
);
}
if (status && !status.available) {
throw new Error(
`${agentType} CLI command "${requestedCommand}" was not found on this device`,
);
}
return { command: requestedCommand };
}
private buildCodexCliMissingError(session: AgentSession): HeterogeneousAgentSessionError {
const command = this.resolveSessionCommand(session);
@@ -1527,9 +1469,8 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
* AgentStreamPipeline or IPC broadcast needed. Mirrors
* `spawnHeteroSandbox()` on the server side.
*/
async spawnLhHeteroExec(params: {
spawnLhHeteroExec(params: {
agentType: string;
command?: string;
cwd?: string;
/** Image attachments (signed URLs) appended as image content blocks. */
imageList?: HeteroExecImageRef[];
@@ -1540,10 +1481,9 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
serverUrl: string;
systemContext?: string;
topicId: string;
}): Promise<void> {
}): void {
const {
agentType,
command,
cwd,
imageList,
jwt,
@@ -1555,7 +1495,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
topicId,
} = params;
const workDir = cwd ?? process.cwd();
const resolvedCommand = await this.resolveDeviceHeteroCommand(agentType, command);
// When CLI tracing is enabled (dev builds, or the Help-menu toggle in
// packaged builds), have `lh hetero exec` persist the agent process's RAW
@@ -1570,7 +1509,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
'exec',
'--type',
agentType,
...(resolvedCommand?.command ? ['--command', resolvedCommand.command] : []),
'--operation-id',
operationId,
'--topic',
@@ -1588,7 +1526,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
const env = {
...process.env,
...buildProxyEnv(this.app.storeManager.get('networkProxy')),
...(resolvedCommand?.resolvedPathEnv ? { PATH: resolvedCommand.resolvedPathEnv } : {}),
LOBEHUB_JWT: jwt,
LOBEHUB_SERVER: serverUrl,
};
@@ -366,14 +366,14 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
async readFiles({ paths, cwd }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
logger.debug('Starting batch file reading:', { count: paths.length });
const results: LocalReadFileResult[] = [];
for (const filePath of paths) {
logger.debug('Reading single file:', { filePath });
const result = await readLocalFile({ path: filePath });
const result = await readLocalFile({ cwd, path: filePath });
results.push(result);
}
@@ -400,9 +400,9 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
async handleMoveFiles({ items, cwd }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
logger.debug('Starting batch file move:', { itemsCount: items?.length });
return moveLocalFiles({ items });
return moveLocalFiles({ cwd, items });
}
@IpcMethod()
@@ -418,9 +418,9 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
async handleWriteFile({ path: filePath, content, cwd }: WriteLocalFileParams) {
logger.debug(`Writing file ${filePath}`, { contentLength: content?.length });
return writeLocalFile({ content, path: filePath });
return writeLocalFile({ content, cwd, path: filePath });
}
@IpcMethod()
@@ -438,12 +438,14 @@ export default class LocalFileCtr extends ControllerModule {
@IpcMethod()
async getLocalFilePreviewUrl({
accept,
allowExternalFile,
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
try {
const url = await this.app.localFileProtocolManager.createPreviewUrl({
accept,
allowExternalFile,
filePath,
workspaceRoot: workingDirectory,
});
@@ -462,12 +464,14 @@ export default class LocalFileCtr extends ControllerModule {
@IpcMethod()
async getLocalFilePreview({
accept,
allowExternalFile,
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewResult> {
try {
const preview = await this.app.localFileProtocolManager.readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot: workingDirectory,
});
@@ -199,10 +199,6 @@ vi.mock('@lobechat/device-gateway-client', () => ({
GatewayClient: MockGatewayClient,
}));
vi.mock('@lobechat/device-identity', () => ({
deriveDeviceId: vi.fn(() => 'mock-device-id'),
}));
vi.mock('@/services/imessageBridgeSrv', () => ({
default: class ImessageBridgeService {},
}));
@@ -849,10 +845,9 @@ describe('GatewayConnectionCtr', () => {
},
);
it('forwards cwd, command, and systemContext from the request to spawnLhHeteroExec', async () => {
it('forwards cwd and systemContext from the request to spawnLhHeteroExec', async () => {
const client = await connectAndOpen();
client.simulateAgentRunRequest('claude-code', 'op-ctx', 'hi', 'mock-jwt', {
command: '/custom/bin/claude',
cwd: '/Users/alice/repo',
systemContext: 'WORKSPACE CONTEXT',
});
@@ -860,7 +855,6 @@ describe('GatewayConnectionCtr', () => {
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
expect.objectContaining({
command: '/custom/bin/claude',
cwd: '/Users/alice/repo',
systemContext: 'WORKSPACE CONTEXT',
}),
@@ -127,71 +127,6 @@ describe('HeterogeneousAgentCtr', () => {
await rm(appStoragePath, { force: true, recursive: true });
});
describe('spawnLhHeteroExec', () => {
beforeEach(() => {
spawnCalls.length = 0;
execFileMock.mockReset();
nextFakeProc = null;
});
it('passes the detector-resolved Codex command to remote-device lh hetero exec', async () => {
const resolvedPath = '/Applications/Codex.app/Contents/Resources/codex';
const searchPath = '/Users/h/.local/share/mise/shims:/usr/bin:/bin';
const detect = vi
.fn()
.mockResolvedValue({ available: true, path: resolvedPath, resolvedPathEnv: searchPath });
const { proc, writes } = createFakeProc();
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
toolDetectorManager: { detect },
} as any);
await ctr.spawnLhHeteroExec({
agentType: 'codex',
command: 'codex',
jwt: 'jwt-1',
operationId: 'op-1',
prompt: 'hello codex',
serverUrl: 'https://server.example.com',
topicId: 'topic-1',
});
expect(detect).toHaveBeenCalledWith('codex', true);
expect(spawnCalls[0].command).toBe('lh');
expect(spawnCalls[0].args).toEqual(
expect.arrayContaining(['--type', 'codex', '--command', resolvedPath]),
);
expect(spawnCalls[0].options.env.PATH).toBe(searchPath);
expect(writes).toEqual([JSON.stringify('hello codex')]);
});
it('rejects before spawning lh when the Codex command is unavailable on the device', async () => {
const detect = vi.fn().mockResolvedValue({ available: false });
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
toolDetectorManager: { detect },
} as any);
await expect(
ctr.spawnLhHeteroExec({
agentType: 'codex',
command: 'codex',
jwt: 'jwt-1',
operationId: 'op-1',
prompt: 'hello codex',
serverUrl: 'https://server.example.com',
topicId: 'topic-1',
}),
).rejects.toThrow('codex CLI command "codex" was not found on this device');
expect(spawnCalls).toHaveLength(0);
});
});
describe('image cache (delegates to shared `normalizeImage`)', () => {
// Image fetch + cache moved to `@lobechat/heterogeneous-agents/spawn`'s
// `normalizeImage`. The desktop controller passes its own cacheDir so the
@@ -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');
+5
View File
@@ -3,6 +3,11 @@ import './pre-app-init';
import fixPath from 'fix-path';
import { App } from './core/App';
import { installProcessErrorHandlers } from './process-error-handlers';
// Guard the main process against transient network blips (Wi-Fi/VPN switch,
// system sleep) emitted by Electron's net stack as uncaught exceptions.
installProcessErrorHandlers();
const app = new App();
@@ -1,5 +1,5 @@
// apps/desktop/src/main/menus/impl/BaseMenuPlatform.ts
import type { MenuItemConstructorOptions } from 'electron';
import type { BaseWindow, MenuItemConstructorOptions } from 'electron';
import { BrowserWindow } from 'electron';
import type { App } from '@/core/App';
@@ -34,6 +34,26 @@ export abstract class BaseMenuPlatform {
];
}
protected closeFocusedTabOrWindow(targetWindow?: BaseWindow | null): void {
const focused =
targetWindow && 'webContents' in targetWindow
? (targetWindow as BrowserWindow)
: BrowserWindow.getFocusedWindow();
if (!focused) return;
if (focused.webContents.isDevToolsOpened()) {
focused.webContents.closeDevTools();
return;
}
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
}
private buildZoomMenuItemOption(
action: ZoomAction,
label: string,
@@ -1,4 +1,4 @@
import { app, dialog, Menu, shell } from 'electron';
import { app, BrowserWindow, dialog, Menu, shell } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
@@ -7,6 +7,9 @@ import { LinuxMenu } from './linux';
// Mock Electron modules
vi.mock('electron', () => ({
BrowserWindow: class BrowserWindow {
static getFocusedWindow = vi.fn();
},
Menu: {
buildFromTemplate: vi.fn((template) => ({ template })),
setApplicationMenu: vi.fn(),
@@ -339,6 +342,100 @@ describe('LinuxMenu', () => {
expect(closeItem.role).toBeUndefined();
});
it('should close open DevTools before delegating CmdOrCtrl+W to renderer window logic', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => true),
},
};
closeItem.click(undefined, focusedWindow);
expect(focusedWindow.webContents.closeDevTools).toHaveBeenCalled();
expect(focusedWindow.close).not.toHaveBeenCalled();
expect(mockApp.browserManager.getMainWindow).not.toHaveBeenCalled();
});
it('should broadcast tab close when CmdOrCtrl+W targets the main window', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const mainBrowserWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => false),
},
};
const broadcast = vi.fn();
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue({
broadcast,
browserWindow: mainBrowserWindow,
} as any);
closeItem.click(undefined, mainBrowserWindow);
expect(broadcast).toHaveBeenCalledWith('closeCurrentTabOrWindow');
expect(mainBrowserWindow.close).not.toHaveBeenCalled();
});
it('should close non-main windows when CmdOrCtrl+W has no DevTools panel to close', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const mainBrowserWindow = {
webContents: {
isDevToolsOpened: vi.fn(() => false),
},
};
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => false),
},
};
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue({
broadcast: vi.fn(),
browserWindow: mainBrowserWindow,
} as any);
closeItem.click(undefined, focusedWindow);
expect(focusedWindow.close).toHaveBeenCalled();
});
it('should use the focused window when Electron does not pass a menu target window', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => true),
},
};
vi.mocked(BrowserWindow.getFocusedWindow).mockReturnValue(focusedWindow as any);
closeItem.click();
expect(focusedWindow.webContents.closeDevTools).toHaveBeenCalled();
});
it('should use role for minimize (accelerator handled by Electron)', () => {
linuxMenu.buildAndSetAppMenu();
+2 -11
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, clipboard, dialog, Menu, shell } from 'electron';
import { app, clipboard, dialog, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -122,16 +122,7 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
{ type: 'separator' },
{
accelerator: 'CmdOrCtrl+W',
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
label: t('window.close'),
},
{ label: t('window.minimize'), role: 'minimize' },
+2 -11
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { app, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -164,16 +164,7 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
{ type: 'separator' },
{
accelerator: 'CmdOrCtrl+W',
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
label: t('window.close'),
},
],
+2 -11
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { app, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -185,16 +185,7 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
{ label: t('window.minimize'), role: 'minimize' },
{
accelerator: 'CmdOrCtrl+W',
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
label: t('window.close'),
},
],
@@ -0,0 +1,77 @@
import { createLogger } from '@/utils/logger';
const logger = createLogger('main:process-error-handlers');
/**
* Transient Chromium network errors emitted by Electron's `net` stack
* (`SimpleURLLoaderWrapper`). These happen during normal operation switching
* Wi-Fi / VPN, the machine sleeping, the network interface dropping and are
* NOT application bugs. Electron emits them as an `error` event on the internal
* loader; when nothing is listening they bubble up as an `uncaughtException`
* and pop the "A JavaScript error occurred in the main process" dialog, even
* though the request layer already handles the failure via promise rejection.
*
* We swallow these specific cases so transient connectivity blips never crash
* the main process. Everything else is re-thrown to preserve normal crash
* visibility.
*
* @see https://github.com/electron/electron/issues/24948
*/
const TRANSIENT_NET_ERROR_CODES = new Set([
'ERR_NETWORK_CHANGED',
'ERR_NETWORK_IO_SUSPENDED',
'ERR_INTERNET_DISCONNECTED',
'ERR_NETWORK_ACCESS_DENIED',
'ERR_CONNECTION_RESET',
'ERR_CONNECTION_ABORTED',
'ERR_CONNECTION_CLOSED',
'ERR_NAME_NOT_RESOLVED',
'ERR_TIMED_OUT',
]);
const isTransientNetError = (error: unknown): boolean => {
if (!error) return false;
const message = error instanceof Error ? error.message : String(error);
// Electron net errors are formatted as `net::ERR_XXX`.
const match = message.match(/net::(ERR_[A-Z_]+)/);
if (match && TRANSIENT_NET_ERROR_CODES.has(match[1])) return true;
// Belt-and-suspenders: these only ever originate from the net loader.
const stack = error instanceof Error ? (error.stack ?? '') : '';
return /net::ERR_/.test(message) && stack.includes('SimpleURLLoaderWrapper');
};
/**
* Install global guards for the Electron main process. Must be called as early
* as possible (before the rest of the app boots) so it catches errors from any
* module's top-level / async work.
*/
export const installProcessErrorHandlers = () => {
process.on('uncaughtException', (error) => {
if (isTransientNetError(error)) {
logger.warn('Ignoring transient network error in main process:', error.message);
return;
}
// Re-throw so genuine bugs still surface as a crash instead of being
// silently swallowed by this handler.
logger.error('Uncaught exception in main process:', error);
throw error;
});
process.on('unhandledRejection', (reason) => {
if (isTransientNetError(reason)) {
logger.warn(
'Ignoring transient network rejection in main process:',
reason instanceof Error ? reason.message : String(reason),
);
return;
}
logger.error('Unhandled rejection in main process:', reason);
});
logger.info('Process error handlers installed');
};
-1
View File
@@ -6,7 +6,6 @@ export default defineConfig({
alias: {
'@': resolve(__dirname, './src/main'),
'~common': resolve(__dirname, './src/common'),
'@lobechat/device-control': resolve(__dirname, '../../packages/device-control/src'),
'@lobechat/local-file-shell': resolve(__dirname, '../../packages/local-file-shell/src'),
},
coverage: {
@@ -28,8 +28,8 @@ const mockGlobalConfigDependencies = (
ENABLE_BUSINESS_FEATURES: enableBusinessFeatures,
}));
vi.doMock('@/config/klavis', () => ({
klavisEnv: {},
vi.doMock('@/config/composio', () => ({
composioEnv: {},
}));
vi.doMock('@/const/version', () => ({
+2 -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,9 +104,9 @@ export const getServerGlobalConfig = async () => {
disableEmailPassword: authEnv.AUTH_DISABLE_EMAIL_PASSWORD,
enableBusinessFeatures: ENABLE_BUSINESS_FEATURES,
enableEmailVerification: authEnv.AUTH_EMAIL_VERIFICATION,
enableComposio: !!composioEnv.COMPOSIO_API_KEY,
enableGatewayMode:
ENABLE_BUSINESS_FEATURES || (!!appEnv.ENABLE_AGENT_GATEWAY && !!appEnv.AGENT_GATEWAY_URL),
enableKlavis: !!klavisEnv.KLAVIS_API_KEY,
enableLobehubSkill: !!(appEnv.MARKET_TRUSTED_CLIENT_SECRET && appEnv.MARKET_TRUSTED_CLIENT_ID),
enableMagicLink: authEnv.AUTH_ENABLE_MAGIC_LINK,
enableMarketTrustedClient: !!(
@@ -14,14 +14,14 @@ import {
} from '@lobechat/agent-runtime';
import { LobeActivatorIdentifier } from '@lobechat/builtin-tool-activator';
import {
type ComposioServiceSummary,
type CredSummary,
generateComposioServicesList,
generateCredsList,
generateKlavisServicesList,
type KlavisServiceSummary,
} from '@lobechat/builtin-tool-creds';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { BRANDING_PROVIDER } from '@lobechat/business-const';
import { KLAVIS_SERVER_TYPES } from '@lobechat/const';
import { COMPOSIO_APP_TYPES } from '@lobechat/const';
import {
type AgentContextDocument,
type AgentGroupConfig,
@@ -38,7 +38,12 @@ import {
ToolResolver,
} from '@lobechat/context-engine';
import { parse } from '@lobechat/conversation-flow';
import { consumeStreamUntilDone } from '@lobechat/model-runtime';
import {
applyModelExtendParams,
type ChatStreamPayload,
consumeStreamUntilDone,
type ModelExtendParams,
} from '@lobechat/model-runtime';
import {
context as otelContext,
SpanKind,
@@ -67,8 +72,9 @@ import {
} from '@lobechat/types';
import { sanitizeToolCallArguments, serializePartsForStorage } from '@lobechat/utils';
import debug from 'debug';
import { type ExtendParamsType, ModelProvider } from 'model-bank';
import { klavisEnv } from '@/config/klavis';
import { composioEnv } from '@/config/composio';
import { type MessageModel, MessageModel as MessageModelClass } from '@/database/models/message';
import { TopicModel } from '@/database/models/topic';
import { UserModel } from '@/database/models/user';
@@ -80,6 +86,10 @@ import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/type
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import type { HookDispatcher } from '@/server/services/agentRuntime/hooks/HookDispatcher';
import type {
ExecGroupMemberParams,
ExecGroupMemberResult,
} from '@/server/services/agentRuntime/types';
import {
type DeviceAccessReason,
isDeviceToolIdentifier,
@@ -89,6 +99,7 @@ import { FileService } from '@/server/services/file';
import { MessageService } from '@/server/services/message';
import { OnboardingService } from '@/server/services/onboarding';
import {
type ServerAgentMemberRunner,
type ServerSubAgentRunner,
type ToolExecutionResultResponse,
type ToolExecutionService,
@@ -405,6 +416,147 @@ const buildServerVirtualSubAgentRunner = (
};
};
/**
* Build the per-tool "call agent member" runner for the group orchestration
* server tool (`lobe-group-management`). Mirrors {@link buildServerVirtualSubAgentRunner}
* but for group members: it owns the group tool message (the parked tool call)
* and the per-member anchors that drive the K=N member barrier.
*
* For each `agentMember.run(...)` it:
* 1. creates the group tool placeholder (`tool_call_id` = the group-management
* call id) stamped with the barrier target + finish disposition;
* 2. for a single member uses that placeholder as the member anchor; for
* multiple members creates one child anchor per member under it;
* 3. forks each member via `ctx.execGroupMember` (in-group or isolated);
* 4. backfills anchors for members that failed to start so the barrier can
* still complete, and tears everything down when none started.
*
* Returns `undefined` when group-member execution is unavailable (no
* `execGroupMember` callback, or missing agent/topic/group context).
*/
const buildServerAgentMemberRunner = (
ctx: RuntimeExecutorContext,
state: AgentState,
chatToolPayload: ChatToolPayload,
parentMessageId: string,
): ServerAgentMemberRunner | undefined => {
const execGroupMember = ctx.execGroupMember;
if (!execGroupMember) return undefined;
const agentId = state.metadata?.agentId;
const topicId = ctx.topicId ?? state.metadata?.topicId;
const groupId = state.metadata?.groupId ?? undefined;
if (!agentId || !topicId || !groupId) return undefined;
return {
run: async ({ members, mode, onComplete, disableTools, timeout }) => {
const expectedMembers = members.length;
if (expectedMembers === 0) return { started: false, startedCount: 0 };
// 1. Group tool placeholder — the parked tool call the supervisor op waits
// on. Stamped with the barrier target + finish disposition so the resume
// path (and verify watchdog) resolve resume-vs-finish on their own.
const groupTool = await ctx.messageModel.create({
agentId,
content: '',
parentId: parentMessageId,
plugin: chatToolPayload as any,
pluginState: { expectedMembers, onComplete, status: 'pending' },
role: 'tool',
threadId: state.metadata?.threadId,
tool_call_id: chatToolPayload.id,
topicId,
});
// 2. Per-member anchors. A single member collapses onto the group tool
// message; multiple members each get a child anchor under it.
const anchorIds: string[] = [];
if (expectedMembers === 1) {
anchorIds.push(groupTool.id);
} else {
for (let i = 0; i < expectedMembers; i += 1) {
const memberToolCallId = `${chatToolPayload.id}::m${i}`;
const anchor = await ctx.messageModel.create({
agentId,
content: '',
parentId: groupTool.id,
plugin: { ...(chatToolPayload as any), id: memberToolCallId },
pluginState: { status: 'pending' },
role: 'tool',
threadId: state.metadata?.threadId,
tool_call_id: memberToolCallId,
topicId,
});
anchorIds.push(anchor.id);
}
}
// 3. Fork members.
let startedCount = 0;
await Promise.all(
members.map(async (member, i) => {
const anchorMessageId = anchorIds[i];
try {
const result = await execGroupMember({
agentId: member.agentId,
anchorMessageId,
disableTools,
expectedMembers,
groupId,
groupToolMessageId: groupTool.id,
instruction: member.instruction,
mode,
onComplete,
parentOperationId: ctx.operationId,
timeout,
topicId,
});
if (result?.started) {
startedCount += 1;
return;
}
} catch (error) {
log(
'buildServerAgentMemberRunner: member %s failed to start: %O',
member.agentId,
error,
);
}
// Member failed to start — its completion bridge will never fire, so
// backfill the anchor as errored to keep the K=N barrier reachable.
try {
await ctx.messageModel.updateToolMessage(anchorMessageId, {
content: `Agent member "${member.agentId}" failed to start.`,
pluginState: { status: 'error' },
});
} catch (error) {
log(
'buildServerAgentMemberRunner: failed to mark anchor %s as errored: %O',
anchorMessageId,
error,
);
}
}),
);
// None started — no bridge will ever fire, so tear down the placeholders
// and let the caller surface an inline tool error instead of parking.
if (startedCount === 0) {
for (const id of new Set([...anchorIds, groupTool.id])) {
try {
await ctx.messageModel.deleteMessage(id);
} catch (error) {
log('buildServerAgentMemberRunner: cleanup failed for %s: %O', id, error);
}
}
return { started: false, startedCount: 0 };
}
return { started: true, startedCount };
},
};
};
const shouldRetryLLM = (kind: LLMErrorKind, attempt: number, maxRetries: number) =>
kind === 'retry' && attempt <= maxRetries;
@@ -522,6 +674,12 @@ export interface RuntimeExecutorContext {
botPlatformContext?: BotPlatformContext;
discordContext?: any;
evalContext?: EvalContext;
/**
* Callback to fork a group member ("call agent member") under a
* `lobe-group-management` tool call. Injected by AiAgentService; powers the
* per-tool `agentMember` runner (in-group + isolated members, K=N barrier).
*/
execGroupMember?: (params: ExecGroupMemberParams) => Promise<ExecGroupMemberResult>;
/**
* Callback to run a legacy agent invocation server-side.
* Injected by AiAgentService so exec_sub_agent / exec_sub_agents executors
@@ -721,6 +879,7 @@ export const createRuntimeExecutors = (
type ContentPart = { text: string; type: 'text' } | { image: string; type: 'image' };
let shouldReplayAssistantReasoning = false;
let preserveThinkingForPayload: boolean | undefined;
let resolvedExtendParams: ModelExtendParams | undefined;
// Process messages through serverMessagesEngine to inject system role, knowledge, etc.
// Rebuild params from agentConfig at execution time (capabilities built dynamically)
@@ -736,19 +895,39 @@ export const createRuntimeExecutors = (
: undefined;
const preserveThinkingRequested = preserveThinkingConfigured === true;
const readExtendParams = (
card: (typeof builtinModels)[number] | undefined,
): string[] | undefined =>
card &&
'settings' in card &&
card.settings &&
typeof card.settings === 'object' &&
'extendParams' in card.settings
? (card.settings as { extendParams?: string[] }).extendParams
: undefined;
const modelCard = builtinModels.find(
(item) =>
item.providerId === provider &&
(item.id === model || item.config?.deploymentName === model),
);
const modelExtendParams =
modelCard &&
'settings' in modelCard &&
modelCard.settings &&
typeof modelCard.settings === 'object' &&
'extendParams' in modelCard.settings
? (modelCard.settings as { extendParams?: string[] }).extendParams
: undefined;
const canonicalModelCard = builtinModels.find(
(item) => item.id === model || item.config?.deploymentName === model,
);
const modelKnowledgeCutoff =
modelCard?.knowledgeCutoff ??
(provider === ModelProvider.LobeHub ? canonicalModelCard?.knowledgeCutoff : undefined);
let modelExtendParams = readExtendParams(modelCard);
// Aggregation providers (e.g. `lobehub`) may serve a model without copying
// its origin `settings.extendParams`. Fall back to the canonical model card
// (matched by id across any provider) so reasoning/thinking params like
// `thinkingLevel` still reach the model. Mirrors the client-side
// `transformToAiModelList` re-namespacing behavior.
if (!modelExtendParams || modelExtendParams.length === 0) {
modelExtendParams = readExtendParams(canonicalModelCard);
}
const modelSupportsPreserveThinkingFromCard =
Array.isArray(modelExtendParams) && modelExtendParams.includes('preserveThinking');
@@ -763,6 +942,19 @@ export const createRuntimeExecutors = (
modelSupportsPreserveThinking && typeof preserveThinkingConfigured === 'boolean'
? preserveThinkingConfigured
: undefined;
// Resolve model extend params (thinkingLevel, reasoning effort, urlContext, …)
// from the agent chat config so the server-side agent runtime forwards the same
// runtime params the client chat service does. Without this, e.g. Gemini 3 Pro's
// `thinkingLevel` never reaches the request and thought summaries come back empty.
if (agentConfig.chatConfig) {
resolvedExtendParams = applyModelExtendParams({
chatConfig: agentConfig.chatConfig,
extendParams: modelExtendParams as ExtendParamsType[] | undefined,
model,
});
}
const messagesForContext = shouldReplayAssistantReasoning
? (llmPayload.messages as UIChatMessage[])
: stripAssistantReasoningForReplay(llmPayload.messages as UIChatMessage[]);
@@ -999,39 +1191,38 @@ export const createRuntimeExecutors = (
}
}
// {{KLAVIS_SERVICES_LIST}} — used by lobe-creds system role (Klavis integrations section).
// Mirrors client-side: klavisStoreSelectors.getServers() filtered by connection status.
let klavisServicesListStr = '';
if (ctx.serverDB && ctx.userId && !!klavisEnv.KLAVIS_API_KEY) {
// {{COMPOSIO_SERVICES_LIST}} — used by lobe-creds system role (Composio integrations section).
let composioServicesListStr = '';
if (ctx.serverDB && ctx.userId && !!composioEnv.COMPOSIO_API_KEY) {
try {
const { PluginModel } = await import('@/database/models/plugin');
const pluginModel = new PluginModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
const allPlugins = await pluginModel.query();
const validKlavisIds = new Set(KLAVIS_SERVER_TYPES.map((t) => t.identifier));
const validComposioIds = new Set(COMPOSIO_APP_TYPES.map((t) => t.identifier));
const connectedIds = new Set(
allPlugins
.filter(
(p) =>
validKlavisIds.has(p.identifier) &&
(p.customParams as any)?.klavis?.isAuthenticated === true,
validComposioIds.has(p.identifier) &&
(p.customParams as any)?.composio?.status === 'ACTIVE',
)
.map((p) => p.identifier),
);
const connected: KlavisServiceSummary[] = KLAVIS_SERVER_TYPES.filter((t) =>
const connected: ComposioServiceSummary[] = COMPOSIO_APP_TYPES.filter((t) =>
connectedIds.has(t.identifier),
).map((t) => ({ identifier: t.identifier, name: t.label }));
const available: KlavisServiceSummary[] = KLAVIS_SERVER_TYPES.filter(
const available: ComposioServiceSummary[] = COMPOSIO_APP_TYPES.filter(
(t) => !connectedIds.has(t.identifier),
).map((t) => ({ identifier: t.identifier, name: t.label }));
klavisServicesListStr = generateKlavisServicesList(connected, available);
composioServicesListStr = generateComposioServicesList(connected, available);
log(
'Fetched Klavis services for {{KLAVIS_SERVICES_LIST}}: connected=%d, available=%d',
'Fetched Composio services for {{COMPOSIO_SERVICES_LIST}}: connected=%d, available=%d',
connected.length,
available.length,
);
} catch (error) {
log(
'Failed to fetch Klavis services for {{KLAVIS_SERVICES_LIST}} substitution: %O',
'Failed to fetch Composio services for {{COMPOSIO_SERVICES_LIST}} substitution: %O',
error,
);
}
@@ -1055,12 +1246,18 @@ export const createRuntimeExecutors = (
sandbox_enabled: sandboxEnabled,
sandbox_uploaded_files: sandboxUploadedFiles,
CREDS_LIST: credsListStr,
KLAVIS_SERVICES_LIST: klavisServicesListStr,
COMPOSIO_SERVICES_LIST: composioServicesListStr,
// Memory tool variables
memory_effort: memoryEffort,
},
userTimezone: ctx.userTimezone,
capabilities: {
isCanUseAudio: (m: string, p: string) => {
const info =
builtinModels.find((item) => item.id === m && item.providerId === p) ??
builtinModels.find((item) => item.id === m);
return info?.abilities?.audio ?? false;
},
isCanUseFC: (m: string, p: string) => {
const info = builtinModels.find((item) => item.id === m && item.providerId === p);
return info?.abilities?.functionCall ?? true;
@@ -1106,6 +1303,7 @@ export const createRuntimeExecutors = (
},
messages: messagesForContext,
model,
modelKnowledgeCutoff,
provider,
systemRole: agentConfig.systemRole ?? undefined,
toolDiscoveryConfig,
@@ -1205,6 +1403,9 @@ export const createRuntimeExecutors = (
model,
stream,
tools,
// ModelExtendParams keeps provider-specific effort/thinking values as loose
// strings (e.g. hy3's 'no_think'); the runtime payload narrows them, so cast.
...(resolvedExtendParams as Partial<ChatStreamPayload>),
...(typeof preserveThinkingForPayload === 'boolean' && {
preserveThinking: preserveThinkingForPayload,
}),
@@ -2446,7 +2647,7 @@ export const createRuntimeExecutors = (
execution = { attempts: 1, result: dispatchResult };
} else {
// Inject source from sourceMap so BuiltinToolsExecutor can route
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
// lobehubSkill / composio tools correctly (LLM responses don't carry source)
if (toolSource && !chatToolPayload.source) {
chatToolPayload.source = toolSource;
}
@@ -2463,7 +2664,14 @@ export const createRuntimeExecutors = (
toolExecutionService.executeTool(chatToolPayload, {
activeDeviceId: state.metadata?.activeDeviceId,
agentId: state.metadata?.agentId,
agentMember: buildServerAgentMemberRunner(
ctx,
state,
chatToolPayload,
payload.parentMessageId,
),
documentId: state.metadata?.documentId,
editingAgentId: state.metadata?.editingAgentId,
execSubAgent: ctx.execSubAgent,
executionTimeoutMs: timeoutMs,
groupId: state.metadata?.groupId,
@@ -2496,6 +2704,10 @@ export const createRuntimeExecutors = (
toolResultMaxLength,
topicId: ctx.topicId,
userId: ctx.userId,
// Device-bound cwd folded into deviceSystemInfo at operation
// creation; resume-safe via computeDeviceContext (recovers it
// from the prior tool message's pluginState.metadata).
workingDirectory: state.metadata?.deviceSystemInfo?.workingDirectory,
workspaceId: state.metadata?.workspaceId ?? ctx.workspaceId,
}),
{
@@ -3026,7 +3238,7 @@ export const createRuntimeExecutors = (
execution = { attempts: 1, result: dispatchResult };
} else {
// Inject source from sourceMap so BuiltinToolsExecutor can route
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
// lobehubSkill / composio tools correctly (LLM responses don't carry source)
const batchToolSource =
state.operationToolSet?.sourceMap?.[chatToolPayload.identifier] ??
state.toolSourceMap?.[chatToolPayload.identifier];
@@ -3045,6 +3257,12 @@ export const createRuntimeExecutors = (
toolExecutionService.executeTool(chatToolPayload, {
activeDeviceId: state.metadata?.activeDeviceId,
agentId: state.metadata?.agentId,
agentMember: buildServerAgentMemberRunner(
ctx,
state,
chatToolPayload,
payload.parentMessageId,
),
documentId: state.metadata?.documentId,
execSubAgent: ctx.execSubAgent,
executionTimeoutMs: timeoutMs,
@@ -14,6 +14,7 @@ const mockBuiltinModels = vi.hoisted(() => [
{
abilities: { functionCall: true, video: false, vision: true },
id: 'gpt-4',
knowledgeCutoff: '2024-06',
providerId: 'openai',
},
{
@@ -58,6 +59,9 @@ vi.mock('@/server/services/message', () => ({
// @lobechat/model-runtime resolves to @cloud/business-model-runtime which has
// cloud-specific dependencies that are unavailable in the test environment
vi.mock('@lobechat/model-runtime', () => ({
// The executor resolves extend params via this helper; an empty result keeps
// the runtime payload unchanged, matching this suite's pre-existing behavior.
applyModelExtendParams: vi.fn(() => ({})),
consumeStreamUntilDone: vi.fn().mockResolvedValue(undefined),
// `llmErrorClassification.ts` reads these at module-load time; an empty
// spec map is fine here because this suite never exercises the runtime
@@ -74,13 +78,16 @@ vi.mock('@/business/client/model-bank/loadModels', () => ({
// model-bank is a TypeScript source file that cannot be dynamically imported in vitest
vi.mock('model-bank', () => ({
LOBE_DEFAULT_MODEL_LIST: mockBuiltinModels,
ModelProvider: {
LobeHub: 'lobehub',
},
}));
// klavisEnv uses @t3-oss/env-nextjs which throws in jsdom (treats it as client context)
vi.mock('@/config/klavis', () => ({
getKlavisConfig: vi.fn(),
getServerKlavisApiKey: vi.fn().mockReturnValue(undefined),
klavisEnv: { KLAVIS_API_KEY: undefined },
// composioEnv uses @t3-oss/env-nextjs which throws in jsdom (treats it as client context)
vi.mock('@/config/composio', () => ({
getComposioConfig: vi.fn(),
getServerComposioApiKey: vi.fn().mockReturnValue(undefined),
composioEnv: { COMPOSIO_API_KEY: undefined },
}));
// fileEnv uses @t3-oss/env-core; stub the only field the runtime reads so the
@@ -125,6 +132,7 @@ describe('RuntimeExecutors', () => {
mockMessageModel = {
create: vi.fn().mockResolvedValue({ id: 'msg-123' }),
deleteMessage: vi.fn().mockResolvedValue({ success: true }),
// call_llm does a parent existence preflight; return a truthy row by
// default so existing tests don't have to stub it.
findById: vi.fn().mockResolvedValue({ id: 'msg-existing' }),
@@ -1571,6 +1579,87 @@ describe('RuntimeExecutors', () => {
);
});
it('should pass model knowledge cutoff into serverMessagesEngine', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
const instruction = {
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'openai',
},
type: 'call_llm' as const,
};
await executors.call_llm!(instruction, state);
expect(engineSpy).toHaveBeenCalledWith(
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
);
});
it('should resolve LobeHub routed model knowledge cutoff by model id fallback', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
await executors.call_llm!(
{
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'lobehub',
},
type: 'call_llm' as const,
},
state,
);
expect(engineSpy).toHaveBeenCalledWith(
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
);
});
it('should omit model knowledge cutoff for unknown non-LobeHub providers', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
await executors.call_llm!(
{
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'custom-openai',
},
type: 'call_llm' as const,
},
state,
);
expect(engineSpy.mock.calls[0][0]).toHaveProperty('modelKnowledgeCutoff', undefined);
});
it('should keep current turn when agent historyCount is 0', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
@@ -4850,10 +4939,9 @@ describe('RuntimeExecutors', () => {
...overrides,
});
it('call_tool sets stop:true in tool_result payload when tool returns execSubAgent state', async () => {
// Simulate agentManagement.callAgent returning execSubAgent state
it('call_tool preserves stop:true for legacy execSubAgent state', async () => {
mockToolExecutionService.executeTool.mockResolvedValue({
content: '🚀 Triggered async task to call agent "target-agent"',
content: 'Legacy async task result',
executionTime: 10,
state: {
parentMessageId: 'tool-msg-id',
@@ -4894,13 +4982,112 @@ describe('RuntimeExecutors', () => {
expect((result.nextContext?.payload as any).stop).toBe(true);
});
it('exec_sub_agent executor creates task message and calls execSubAgent callback', async () => {
const mockExecSubAgentTask = vi
it('call_tool lets server callAgent run as a deferred tool via the subAgent runner', async () => {
const mockExecVirtualSubAgent = vi
.fn()
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
const ctxWithCallback = {
...ctx,
execSubAgent: mockExecSubAgentTask,
execVirtualSubAgent: mockExecVirtualSubAgent,
topicId: 'topic-123',
};
mockMessageModel.create.mockResolvedValueOnce({ id: 'tool-msg-id' });
mockToolExecutionService.executeTool.mockImplementation(
async (_payload: any, context: any) => {
const subAgent = await context.subAgent.run({
agentId: 'target-agent-id',
description: 'Call agent target-agent',
instruction: 'Do something useful',
timeout: 1_800_000,
});
return {
content: '',
deferred: true,
executionTime: 10,
state: {
status: 'pending',
subOperationId: subAgent.subOperationId,
targetAgentId: 'target-agent-id',
threadId: subAgent.threadId,
},
success: subAgent.started,
};
},
);
const executors = createRuntimeExecutors(ctxWithCallback);
const state = createMockState();
const instruction = {
payload: {
parentMessageId: 'assistant-msg-id',
toolCalling: {
apiName: 'callAgent',
arguments: JSON.stringify({
agentId: 'target-agent-id',
instruction: 'Do something useful',
runAsTask: true,
}),
id: 'tool-call-1',
identifier: 'lobe-agent-management',
type: 'default' as const,
},
},
type: 'call_tool' as const,
};
const result = await executors.call_tool!(instruction, state);
expect(mockMessageModel.create).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'parent-agent-id',
plugin: expect.objectContaining({
apiName: 'callAgent',
identifier: 'lobe-agent-management',
}),
pluginState: { status: 'pending' },
parentId: 'assistant-msg-id',
role: 'tool',
tool_call_id: 'tool-call-1',
topicId: 'topic-123',
}),
);
expect(mockExecVirtualSubAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'target-agent-id',
instruction: 'Do something useful',
parentMessageId: 'tool-msg-id',
parentOperationId: 'op-123',
title: 'Call agent target-agent',
topicId: 'topic-123',
}),
);
expect(result.newState.status).toBe('waiting_for_async_tool');
expect(result.newState.pendingToolsCalling).toEqual([
expect.objectContaining({
apiName: 'callAgent',
id: 'tool-call-1',
identifier: 'lobe-agent-management',
}),
]);
expect(result.events).toEqual([
expect.objectContaining({
canResume: true,
reason: 'async_tool',
type: 'interrupted',
}),
]);
expect(result.nextContext).toBeUndefined();
});
it('exec_sub_agent executor creates task message and calls execSubAgent callback', async () => {
const mockExecSubAgent = vi
.fn()
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
const ctxWithCallback = {
...ctx,
execSubAgent: mockExecSubAgent,
topicId: 'topic-123',
};
@@ -4926,6 +5113,9 @@ describe('RuntimeExecutors', () => {
expect(mockMessageModel.create).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'parent-agent-id',
metadata: expect.objectContaining({
targetAgentId: 'target-agent-id',
}),
role: 'task',
parentId: 'tool-msg-id',
topicId: 'topic-123',
@@ -4933,7 +5123,7 @@ describe('RuntimeExecutors', () => {
);
// execSubAgent callback fired with targetAgentId
expect(mockExecSubAgentTask).toHaveBeenCalledWith(
expect(mockExecSubAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'target-agent-id',
instruction: 'Do something useful',
@@ -4947,10 +5137,10 @@ describe('RuntimeExecutors', () => {
});
it('exec_sub_agent blocks nested dispatch when current state is already a sub-agent', async () => {
const mockExecSubAgentTask = vi.fn();
const mockExecSubAgent = vi.fn();
const ctxWithCallback = {
...ctx,
execSubAgentTask: mockExecSubAgentTask,
execSubAgent: mockExecSubAgent,
topicId: 'topic-123',
};
@@ -4983,7 +5173,7 @@ describe('RuntimeExecutors', () => {
success: false,
});
expect(mockMessageModel.create).not.toHaveBeenCalled();
expect(mockExecSubAgentTask).not.toHaveBeenCalled();
expect(mockExecSubAgent).not.toHaveBeenCalled();
});
it('exec_sub_agent gracefully skips dispatch when execSubAgent not injected', async () => {
@@ -1,3 +1,4 @@
import { AgentRuntimeErrorType } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { formatErrorEventData } from '../formatErrorEventData';
@@ -62,6 +63,75 @@ describe('formatErrorEventData', () => {
});
describe('business-typed errors (must not be overridden)', () => {
it('preserves traceable runtime payload body for gateway error events', () => {
const out = formatErrorEventData(
{
error: { message: 'Upstream failed', traceId: 'trace-123' },
errorType: AgentRuntimeErrorType.ProviderBizError,
provider: 'openai',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Upstream failed',
provider: 'openai',
traceId: 'trace-123',
},
error: 'Upstream failed',
errorType: AgentRuntimeErrorType.ProviderBizError,
phase: 'llm_execution',
});
});
it('uses the normalized runtime type for gateway error events', () => {
const out = formatErrorEventData(
{
error: { message: 'Payment required', status: 402, traceId: 'trace-402' },
errorType: AgentRuntimeErrorType.ProviderBizError,
provider: 'lobehub',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Payment required',
provider: 'lobehub',
status: 402,
traceId: 'trace-402',
},
error: 'Payment required',
errorType: AgentRuntimeErrorType.InsufficientQuota,
phase: 'llm_execution',
});
});
it('uses the normalized runtime message when the payload message is a placeholder', () => {
const out = formatErrorEventData(
{
error: { message: 'Payment required', status: 402, traceId: 'trace-402' },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'error',
provider: 'lobehub',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Payment required',
provider: 'lobehub',
status: 402,
traceId: 'trace-402',
},
error: 'Payment required',
errorType: AgentRuntimeErrorType.InsufficientQuota,
phase: 'llm_execution',
});
});
it('preserves ConversationParentMissing errorType and message even when .cause has PG info', () => {
// Mirrors createConversationParentMissingError from messagePersistErrors.ts:
// the user-facing errorType lives on the error object directly, and the
@@ -1,5 +1,11 @@
import { pickNonEmptyString, toRecord } from '@lobechat/utils/object';
import { formatErrorForState } from './formatErrorForState';
import { formatPgError, pgErrorType, unwrapPgError } from './pgError';
const isErrorType = (value: unknown): value is string | number =>
typeof value === 'string' || typeof value === 'number';
/**
* Normalize an arbitrary thrown value into the shape the runtime stream-event
* protocol expects. Extracts a human-readable `error` string and a best-effort
@@ -23,55 +29,38 @@ import { formatPgError, pgErrorType, unwrapPgError } from './pgError';
* DB failures by SQLSTATE.
*/
export const formatErrorEventData = (error: unknown, phase: string) => {
let errorMessage = 'Unknown error';
let errorType: string | undefined;
// True when `errorType` came from a business-typed field on the error
// payload (step 1 above). Driver class names assigned via `error.name`
// do NOT set this flag, so raw `PostgresError` / `DatabaseError` instances
// still fall through to the PG unwrap step.
let hasBusinessErrorType = false;
if (error && typeof error === 'object') {
const payload = error as { error?: unknown; errorType?: unknown; message?: unknown };
if (typeof payload.errorType === 'string') {
errorType = payload.errorType;
hasBusinessErrorType = true;
}
if (typeof payload.message === 'string' && payload.message.length > 0) {
errorMessage = payload.message;
} else if (typeof payload.error === 'string' && payload.error.length > 0) {
errorMessage = payload.error;
} else if (
payload.error &&
typeof payload.error === 'object' &&
'message' in payload.error &&
typeof payload.error.message === 'string'
) {
errorMessage = payload.error.message;
} else if (error instanceof Error && error.message.length > 0) {
errorMessage = error.message;
} else if (errorType) {
errorMessage = errorType;
}
} else if (error instanceof Error && error.message.length > 0) {
errorMessage = error.message;
errorType = error.name;
} else if (typeof error === 'string' && error.length > 0) {
errorMessage = error;
}
const payload = toRecord(error);
const rawPayloadErrorType = payload?.errorType ?? payload?.type;
const payloadErrorType = isErrorType(rawPayloadErrorType) ? rawPayloadErrorType : undefined;
const structuredError =
error instanceof Error || payloadErrorType === undefined
? undefined
: formatErrorForState(payload);
const body = structuredError?.body;
const hasPayloadErrorType = payloadErrorType !== undefined;
let errorType = hasPayloadErrorType
? String(structuredError?.type ?? payloadErrorType)
: undefined;
const payloadError = payload?.error;
let errorMessage =
pickNonEmptyString(structuredError?.message) ??
pickNonEmptyString(payload?.message) ??
pickNonEmptyString(payloadError) ??
pickNonEmptyString(toRecord(payloadError)?.message) ??
(error instanceof Error ? pickNonEmptyString(error.message) : pickNonEmptyString(error)) ??
errorType ??
'Unknown error';
if (!errorType && error instanceof Error && error.name) {
errorType = error.name;
}
// Enrichment: run PG unwrap whenever no *business-typed* errorType was
// Enrichment: run PG unwrap whenever no payload errorType was
// declared. This covers both Drizzle-wrapped errors (PG info under .cause)
// AND raw top-level driver errors like `PostgresError` / `DatabaseError`
// which carry a specific `name` but are still real PG errors deserving
// `pg_<sqlstate>` classification on the dashboard.
if (!hasBusinessErrorType) {
if (!hasPayloadErrorType) {
const pg = unwrapPgError(error);
if (pg) {
errorMessage = formatPgError(pg);
@@ -80,6 +69,7 @@ export const formatErrorEventData = (error: unknown, phase: string) => {
}
return {
...(body === undefined ? {} : { body }),
error: errorMessage,
errorType,
phase,
@@ -16,7 +16,35 @@ describe('formatErrorForState', () => {
expect(result.type).toBe(AgentRuntimeErrorType.InvalidProviderAPIKey);
expect(result.message).toBe('Invalid API key');
expect(result.body).toEqual({ detail: 'Unauthorized' });
expect(result.body).toEqual({
detail: 'Unauthorized',
message: 'Invalid API key',
provider: 'openai',
});
});
it('preserves top-level context from ChatCompletionErrorPayload', () => {
const budget = { required: 12 };
const result = formatErrorForState({
budget,
error: { message: 'Budget exceeded' },
errorType: ChatErrorType.FreePlanLimit,
provider: 'lobehub',
});
expect(result).toMatchObject({
attribution: 'user',
body: {
budget,
message: 'Budget exceeded',
provider: 'lobehub',
},
category: 'quota',
httpStatus: 402,
message: 'Budget exceeded',
type: ChatErrorType.FreePlanLimit,
});
});
it('wraps standard Error as InternalServerError', () => {
@@ -180,6 +208,43 @@ describe('formatErrorForState', () => {
expect(result.category).toBe('quota');
});
it('keeps payload.error available when _responseBody is present', () => {
const result = formatErrorForState({
_responseBody: { provider: 'lobehub' },
error: { status: 402 },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'opaque upstream message',
});
expect(result).toMatchObject({
body: {
error: { status: 402 },
message: 'opaque upstream message',
provider: 'lobehub',
},
category: 'quota',
type: AgentRuntimeErrorType.InsufficientQuota,
});
});
it('merges payload status into an existing _responseBody error object', () => {
const result = formatErrorForState({
_responseBody: { error: { message: 'Payment required' }, provider: 'lobehub' },
error: { status: 402 },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'opaque upstream message',
});
expect(result).toMatchObject({
body: {
error: { message: 'Payment required', status: 402 },
provider: 'lobehub',
},
category: 'quota',
type: AgentRuntimeErrorType.InsufficientQuota,
});
});
it('keeps a genuine residual as ProviderBizError (E8002)', () => {
const result = formatErrorForState({
errorType: AgentRuntimeErrorType.ProviderBizError,
@@ -1,5 +1,6 @@
import { getErrorCodeSpec, refineErrorCode } from '@lobechat/model-runtime';
import { AgentRuntimeErrorType, ChatErrorType, type ChatMessageError } from '@lobechat/types';
import { isRecord } from '@lobechat/utils';
/** Pull a usable HTTP status out of the nested upstream error object. */
const extractHttpStatus = (body: unknown): number | undefined => {
@@ -19,6 +20,80 @@ const extractProvider = (body: unknown): string | undefined => {
return typeof p === 'string' ? p : undefined;
};
const extractMessage = (value: unknown): string | undefined => {
if (!isRecord(value)) return undefined;
const message = value.message;
if (typeof message === 'string' && message) return message;
const nestedError = value.error;
if (isRecord(nestedError)) {
const nestedMessage = nestedError.message;
if (typeof nestedMessage === 'string' && nestedMessage) return nestedMessage;
}
};
interface ChatCompletionErrorPayloadLike {
_responseBody?: unknown;
budget?: unknown;
error?: unknown;
errorType: ChatMessageError['type'];
message?: string;
provider?: unknown;
}
const mergePayloadError = (
sourceBody: Record<string, unknown>,
payload: ChatCompletionErrorPayloadLike,
): unknown | undefined => {
if (payload._responseBody === undefined || payload.error === undefined) return undefined;
if (!('error' in sourceBody)) return payload.error;
if (isRecord(sourceBody.error) && isRecord(payload.error)) {
return { ...payload.error, ...sourceBody.error };
}
};
const buildPayloadBody = (
payload: ChatCompletionErrorPayloadLike,
originalError: unknown,
message: string,
): unknown => {
// Runtime payloads often keep UI context (for example quota hints) next to
// `error`, while `error` itself only carries the display message. Merge both
// layers so normalizing `{ errorType, error }` does not drop the fields the
// chat error renderer needs later.
const sourceBody = payload._responseBody ?? payload.error ?? originalError;
const context: Record<string, unknown> = {};
if (payload.budget !== undefined) context.budget = payload.budget;
if (typeof payload.provider === 'string') context.provider = payload.provider;
if (isRecord(sourceBody)) {
const payloadError = mergePayloadError(sourceBody, payload);
return {
...sourceBody,
// `_responseBody` is the display-facing body, but gateway/model-runtime
// still carries status/provider details in `error` for some failures:
// `{ _responseBody: { error: { message } }, error: { status: 402 } }`.
...(payloadError === undefined ? {} : { error: payloadError }),
...(payload.budget !== undefined && !('budget' in sourceBody)
? { budget: payload.budget }
: {}),
...(typeof payload.provider === 'string' && !('provider' in sourceBody)
? { provider: payload.provider }
: {}),
...('message' in sourceBody ? {} : { message }),
};
}
return {
...context,
...(sourceBody === undefined ? {} : { error: sourceBody }),
message,
};
};
/**
* Merge classification metadata from `ERROR_CODE_SPECS` onto a normalized
* `ChatMessageError`. Codes that aren't in the spec table (fallbacks like
@@ -79,14 +154,16 @@ const enrichWithSpec = (formatted: ChatMessageError): ChatMessageError => {
*/
export const formatErrorForState = (error: unknown): ChatMessageError => {
if (error && typeof error === 'object' && 'errorType' in error) {
const payload = error as {
error?: unknown;
errorType: ChatMessageError['type'];
message?: string;
};
const payload = error as ChatCompletionErrorPayloadLike;
const message =
(payload.message && payload.message !== 'error' ? payload.message : undefined) ??
extractMessage(payload._responseBody) ??
extractMessage(payload.error) ??
String(payload.errorType);
return enrichWithSpec({
body: payload.error || error,
message: payload.message || String(payload.errorType),
body: buildPayloadBody(payload, error, message),
message,
type: payload.errorType,
});
}
@@ -659,6 +659,59 @@ describe('createServerAgentToolsEngine', () => {
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should disable RemoteDevice when a device is explicitly bound (locked to the selection)', () => {
// A user-selected (bound) device locks the run to that device — the
// activate-device tool is never offered, so the model cannot switch.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
canUseDevice: true,
deviceContext: {
autoActivated: true,
boundDeviceId: 'device-001',
deviceOnline: true,
gatewayConfigured: true,
},
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [RemoteDeviceManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should disable RemoteDevice when the bound device is OFFLINE — no silent hop to another machine', () => {
// The bound device going offline makes the plan device-unrouted, so
// `autoActivated` is false. Without the `boundDeviceId` gate the tool
// would resurface and let the model activate a *different* online device.
// The explicit selection must keep the run locked instead.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
canUseDevice: true,
deviceContext: {
boundDeviceId: 'device-001',
deviceOnline: true,
gatewayConfigured: true,
},
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [RemoteDeviceManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should enable RemoteDevice in bot conversations when caller is trusted (canUseDevice=true)', () => {
// The `!isBotConversation` clause was dropped in — the
// confused-deputy concern that motivated it is now handled at a
@@ -9,7 +9,6 @@
* - Gets model capabilities from provided function
* - No dependency on frontend stores (useToolStore, useAgentStore, etc.)
*/
import { AgentDocumentsManifest } from '@lobechat/builtin-tool-agent-documents';
import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
import { KnowledgeBaseManifest } from '@lobechat/builtin-tool-knowledge-base';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
@@ -28,7 +27,11 @@ import { ToolsEngine } from '@lobechat/context-engine';
import { type RuntimeEnvMode, type RuntimePlatform } from '@lobechat/types';
import debug from 'debug';
import { executionTargetToRuntimeMode, resolveExecutionTarget } from '@/helpers/executionTarget';
import {
executionTargetToRuntimeMode,
resolveExecutionTarget,
resolveToolMode,
} from '@/helpers/executionTarget';
import {
buildAllowedBuiltinTools,
DEVICE_TOOL_IDENTIFIERS,
@@ -86,7 +89,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];
@@ -131,7 +134,6 @@ export const createServerAgentToolsEngine = (
disableLocalSystem = false,
executionPlan,
globalMemoryEnabled = false,
hasAgentDocuments = false,
hasEnabledKnowledgeBases = false,
isBotConversation = false,
model,
@@ -157,7 +159,7 @@ export const createServerAgentToolsEngine = (
const executionTarget =
executionPlan?.target ??
resolveExecutionTarget(agentConfig.agencyConfig, {
isDesktop: platform === 'desktop',
clientExecutionAvailable: platform === 'desktop',
});
const runtimeMode: RuntimeEnvMode = executionTargetToRuntimeMode(executionTarget);
// Device tools (local-system, remote-device proxy) only exist for
@@ -170,9 +172,7 @@ export const createServerAgentToolsEngine = (
const isSearchEnabled = searchMode !== 'off';
// Tool mode: explicit `toolMode` wins; otherwise derive from `enableAgentMode`
// (undefined = agent). `custom` = toolset is exactly the agent's plugins.
const toolMode: 'agent' | 'chat' | 'custom' =
agentConfig.chatConfig?.toolMode ??
(agentConfig.chatConfig?.enableAgentMode === false ? 'chat' : 'agent');
const toolMode = resolveToolMode(agentConfig.chatConfig ?? undefined);
const isChatMode = toolMode === 'chat';
const isCustomMode = toolMode === 'custom';
@@ -231,13 +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,
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
deviceCapable &&
hasDeviceProxy &&
!deviceContext?.autoActivated &&
!deviceContext?.boundDeviceId,
[WebBrowsingManifest.identifier]: isSearchEnabled,
};
@@ -256,7 +263,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 .
*/
@@ -101,8 +101,6 @@ export interface ServerCreateAgentToolsEngineParams {
executionPlan?: ExecutionPlan;
/** Whether the user's global memory setting is enabled */
globalMemoryEnabled?: boolean;
/** Whether agent has agent documents */
hasAgentDocuments?: boolean;
/** Whether agent has enabled knowledge bases */
hasEnabledKnowledgeBases?: boolean;
/** Whether the request originates from a bot conversation (auto-enables message tool) */
@@ -70,6 +70,25 @@ describe('serverMessagesEngine', () => {
expect(result[0].content).toBe(systemRole + '\n\n' + getCurrentDateContent());
});
it('should inject model knowledge cutoff when provided', async () => {
const messages = createBasicMessages();
const result = await serverMessagesEngine({
messages,
model: 'gpt-4',
modelKnowledgeCutoff: '2024-06',
provider: 'openai',
systemRole: 'You are a helpful assistant',
});
expect(result[0].role).toBe('system');
expect(result[0].content).toBe(
'You are a helpful assistant\n\n' +
getCurrentDateContent() +
'\n\nModel knowledge cutoff: 2024-06',
);
});
it('should handle empty messages', async () => {
const result = await serverMessagesEngine({
messages: [],
@@ -51,6 +51,7 @@ const createServerVariableGenerators = (params: {
export const serverMessagesEngine = async ({
messages = [],
model,
modelKnowledgeCutoff,
provider,
systemRole,
inputTemplate,
@@ -83,6 +84,7 @@ export const serverMessagesEngine = async ({
const engine = new MessagesEngine({
// Capability injection
capabilities: {
isCanUseAudio: capabilities?.isCanUseAudio,
isCanUseFC: capabilities?.isCanUseFC,
isCanUseVideo: capabilities?.isCanUseVideo,
isCanUseVision: capabilities?.isCanUseVision,
@@ -120,6 +122,7 @@ export const serverMessagesEngine = async ({
// Model info
model,
modelKnowledgeCutoff,
provider,
systemRole,
@@ -23,6 +23,8 @@ import type { RuntimeInitialContext, UIChatMessage } from '@lobechat/types';
* Model capability checker functions for server-side
*/
export interface ServerModelCapabilities {
/** Check if audio input is supported */
isCanUseAudio?: (model: string, provider: string) => boolean;
/** Check if function calling is supported */
isCanUseFC?: (model: string, provider: string) => boolean;
/** Check if video is supported */
@@ -130,6 +132,8 @@ export interface ServerMessagesEngineParams {
/** Model ID */
model: string;
/** Model knowledge cutoff date, e.g. `2024-06`. Omit when unknown. */
modelKnowledgeCutoff?: string;
/** Page content context (optional, for document editing) */
pageContentContext?: PageContentContext;
+6 -8
View File
@@ -20,8 +20,8 @@ import { GenerationModel } from '@/database/models/generation';
import { asyncAuthedProcedure, asyncRouter as router } from '@/libs/trpc/async';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { VideoGenerationService } from '@/server/services/generation/video';
import { buildVideoGenerationFilePayload } from '@/server/services/generation/videoFile';
import { FileSource } from '@/types/files';
import { sanitizeFileName } from '@/utils/sanitizeFileName';
const log = debug('lobe-video:async');
@@ -196,13 +196,11 @@ export const videoRouter = router({
url: processResult.videoKey,
width: processResult.width,
},
{
fileHash: processResult.fileHash,
fileType: processResult.mimeType,
name: `${sanitizeFileName(batch?.prompt ?? '', generationId)}.mp4`,
size: processResult.fileSize,
url: processResult.videoKey,
},
buildVideoGenerationFilePayload({
generationId,
processResult,
prompt: batch?.prompt,
}),
FileSource.VideoGeneration,
);
@@ -393,6 +393,7 @@ describe('agentRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentRouter.createCaller(wsCtx());
@@ -410,6 +411,7 @@ describe('agentRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentRouter.createCaller(wsCtx());
@@ -0,0 +1,146 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type * as AgentDocumentModels from '@/database/models/agentDocuments';
import { createCallerFactory } from '@/libs/trpc/lambda';
import { createContextInner } from '@/libs/trpc/lambda/context';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import { agentDocumentRouter } from '../agentDocument';
const mocks = vi.hoisted(() => ({
associate: vi.fn(),
createTopic: vi.fn(),
findByAgentAndDocumentTrigger: vi.fn(),
findRowByDocumentId: vi.fn(),
getServerDB: vi.fn(),
}));
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: mocks.getServerDB,
}));
vi.mock('@/database/models/agentDocuments', async (importOriginal) => {
const actual = await importOriginal<typeof AgentDocumentModels>();
return {
...actual,
AgentDocumentModel: vi.fn(),
};
});
vi.mock('@/database/models/topic', () => ({
TopicModel: vi.fn().mockImplementation(() => ({
create: mocks.createTopic,
findByAgentAndDocumentTrigger: mocks.findByAgentAndDocumentTrigger,
})),
}));
vi.mock('@/database/models/topicDocument', () => ({
TopicDocumentModel: vi.fn().mockImplementation(() => ({
associate: mocks.associate,
})),
}));
vi.mock('@/server/services/agentDocuments', () => ({
AgentDocumentsService: vi.fn(),
}));
vi.mock('@/server/services/agentDocumentVfs', () => ({
AgentDocumentVfsService: vi.fn(),
}));
vi.mock('@/server/services/agentDocuments/toolOutcome', () => ({
emitAgentDocumentToolOutcomeSafely: vi.fn(),
}));
const createCaller = createCallerFactory(agentDocumentRouter);
describe('agentDocumentRouter.getOrCreateChatTopic', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getServerDB.mockResolvedValue({ kind: 'server-db' });
vi.mocked(AgentDocumentsService).mockImplementation(
() =>
({ findRowByDocumentId: mocks.findRowByDocumentId }) as unknown as AgentDocumentsService,
);
});
it('returns the existing topic when a doc-anchored row is already linked', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue({ id: 'topic-existing' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
const result = await caller.getOrCreateChatTopic({
agentId: 'agent-1',
documentId: 'docs_abc',
});
expect(result).toEqual({ topicId: 'topic-existing' });
expect(mocks.findByAgentAndDocumentTrigger).toHaveBeenCalledWith({
agentId: 'agent-1',
documentId: 'docs_abc',
trigger: 'document',
});
expect(mocks.createTopic).not.toHaveBeenCalled();
expect(mocks.associate).not.toHaveBeenCalled();
});
it('creates a new doc-anchored topic and associates it when none exists', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue({
filename: 'spec.md',
id: 'agent-document-1',
title: 'Spec',
});
mocks.createTopic.mockResolvedValue({ id: 'topic-new' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
const result = await caller.getOrCreateChatTopic({
agentId: 'agent-1',
documentId: 'docs_abc',
});
expect(result).toEqual({ topicId: 'topic-new' });
expect(mocks.createTopic).toHaveBeenCalledWith({
agentId: 'agent-1',
title: 'Spec',
trigger: 'document',
});
expect(mocks.associate).toHaveBeenCalledWith({
documentId: 'docs_abc',
topicId: 'topic-new',
});
});
it('falls back to the filename when the document has no title', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue({
filename: 'fallback.md',
id: 'agent-document-1',
title: undefined,
});
mocks.createTopic.mockResolvedValue({ id: 'topic-new' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
await caller.getOrCreateChatTopic({ agentId: 'agent-1', documentId: 'docs_abc' });
expect(mocks.createTopic).toHaveBeenCalledWith({
agentId: 'agent-1',
title: 'fallback.md',
trigger: 'document',
});
});
it('throws NOT_FOUND when the document is missing or not owned by the agent', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue(undefined);
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
await expect(
caller.getOrCreateChatTopic({ agentId: 'agent-1', documentId: 'docs_missing' }),
).rejects.toThrow(/Document not found/);
expect(mocks.createTopic).not.toHaveBeenCalled();
expect(mocks.associate).not.toHaveBeenCalled();
});
});
@@ -500,6 +500,7 @@ describe('agentGroupRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentGroupRouter.createCaller(wsCtx());
@@ -517,6 +518,7 @@ describe('agentGroupRouter', () => {
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
ownerId: null,
});
const caller = agentGroupRouter.createCaller(wsCtx());
@@ -29,10 +29,12 @@ describe('aiModelRouter', () => {
it('should create ai model', async () => {
const mockCreate = vi.fn().mockResolvedValue({ id: 'model-1' });
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
@@ -44,12 +46,68 @@ describe('aiModelRouter', () => {
});
expect(result).toBe('model-1');
expect(mockFindByIdAndProvider).toHaveBeenCalledWith('test-model', 'test-provider');
expect(mockCreate).toHaveBeenCalledWith({
id: 'test-model',
providerId: 'test-provider',
});
});
it('should reject duplicate ai model before creating', async () => {
const mockCreate = vi.fn();
const mockFindByIdAndProvider = vi.fn().mockResolvedValue({ id: 'test-model' });
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
const caller = aiModelRouter.createCaller(mockCtx);
await expect(
caller.createAiModel({
id: 'test-model',
providerId: 'test-provider',
}),
).rejects.toMatchObject({
code: 'CONFLICT',
message: 'Model "test-model" already exists',
});
expect(mockCreate).not.toHaveBeenCalled();
});
it('should convert duplicate insert races to conflict errors', async () => {
const duplicateError = Object.assign(new Error('failed query'), {
cause: Object.assign(new Error('duplicate key'), {
code: '23505',
constraint: 'ai_models_id_provider_id_user_id_pk',
}),
});
const mockCreate = vi.fn().mockRejectedValue(duplicateError);
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
const caller = aiModelRouter.createCaller(mockCtx);
await expect(
caller.createAiModel({
id: 'test-model',
providerId: 'test-provider',
}),
).rejects.toMatchObject({
code: 'CONFLICT',
message: 'Model "test-model" already exists',
});
});
it('should get ai model by id', async () => {
const mockModel = {
id: 'model-1',
@@ -0,0 +1,118 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { asrRouter } from '../asr';
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: vi.fn(() => ({})),
}));
const transcribeMock = vi.fn();
vi.mock('@/server/modules/ModelRuntime', () => ({
initModelRuntimeFromDB: vi.fn(async () => ({ transcribe: transcribeMock })),
}));
const findByIdMock = vi.fn();
vi.mock('@/database/models/file', () => ({
FileModel: vi.fn(() => ({ findById: findByIdMock })),
}));
const getFileByteArrayMock = vi.fn();
vi.mock('@/server/services/file', () => ({
FileService: vi.fn(() => ({ getFileByteArray: getFileByteArrayMock })),
}));
const caller = asrRouter.createCaller({ jwtPayload: { userId: 'u1' }, userId: 'u1' } as any);
beforeEach(() => {
transcribeMock.mockResolvedValue({ text: 'hello world' });
});
afterEach(() => {
vi.clearAllMocks();
});
describe('asrRouter.transcribe', () => {
it('transcribes inline base64 audio', async () => {
const res = await caller.transcribe({
audioBase64: Buffer.from('audio-bytes').toString('base64'),
fileName: 'clip.mp3',
model: 'whisper-1',
provider: 'openai',
});
expect(res).toEqual({ text: 'hello world' });
expect(findByIdMock).not.toHaveBeenCalled();
const payload = transcribeMock.mock.calls[0][0];
expect(payload.file).toBeInstanceOf(File);
expect(payload.fileName).toBe('clip.mp3');
expect(await payload.file.text()).toBe('audio-bytes');
});
it('resolves a fileId by downloading the bytes from storage', async () => {
findByIdMock.mockResolvedValue({
fileType: 'audio/mp4',
name: 'meeting.m4a',
url: 's3-key/meeting.m4a',
});
getFileByteArrayMock.mockResolvedValue(new Uint8Array(Buffer.from('from-s3')));
const res = await caller.transcribe({ fileId: 'file_123', model: 'whisper-1' });
expect(res).toEqual({ text: 'hello world' });
expect(findByIdMock).toHaveBeenCalledWith('file_123');
expect(getFileByteArrayMock).toHaveBeenCalledWith('s3-key/meeting.m4a');
const payload = transcribeMock.mock.calls[0][0];
expect(payload.fileName).toBe('meeting.m4a');
expect(payload.file.type).toBe('audio/mp4');
expect(await payload.file.text()).toBe('from-s3');
});
it('rejects when neither fileId nor audioBase64 is provided', async () => {
await expect(caller.transcribe({ model: 'whisper-1' } as any)).rejects.toThrow();
});
it('rejects oversized inline base64 and guides to fileId', async () => {
// > 3MB decoded → base64 string exceeds the cap
const tooBig = 'A'.repeat(5 * 1024 * 1024);
await expect(caller.transcribe({ audioBase64: tooBig, model: 'whisper-1' })).rejects.toThrow(
/fileId/i,
);
expect(transcribeMock).not.toHaveBeenCalled();
});
it('rejects when both fileId and audioBase64 are provided', async () => {
await expect(
caller.transcribe({
audioBase64: Buffer.from('x').toString('base64'),
fileId: 'file_123',
model: 'whisper-1',
} as any),
).rejects.toThrow();
});
it('throws NOT_FOUND when the fileId does not exist', async () => {
findByIdMock.mockResolvedValue(undefined);
await expect(caller.transcribe({ fileId: 'missing', model: 'whisper-1' })).rejects.toThrow(
/not found/i,
);
expect(getFileByteArrayMock).not.toHaveBeenCalled();
});
it('throws NOT_FOUND when the stored object is gone (NoSuchKey)', async () => {
findByIdMock.mockResolvedValue({
fileType: 'audio/mp4',
name: 'gone.m4a',
url: 's3-key/gone.m4a',
});
getFileByteArrayMock.mockRejectedValue({ Code: 'NoSuchKey' });
await expect(caller.transcribe({ fileId: 'file_x', model: 'whisper-1' })).rejects.toThrow(
/no longer available/i,
);
});
});
@@ -0,0 +1,70 @@
import { TRPCError } from '@trpc/server';
import { describe, expect, it, vi } from 'vitest';
import type { DeviceModel } from '@/database/models/device';
import { assertWorkspaceRootApproved } from '../deviceWorkspaceGuard';
const mockModel = (row: { defaultCwd?: string | null; workingDirs?: { path: string }[] } | null) =>
({
findByDeviceId: vi.fn().mockResolvedValue(row),
}) as unknown as DeviceModel;
describe('assertWorkspaceRootApproved', () => {
it('allows a root that exactly matches a bound workingDir', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).resolves.toBeUndefined();
});
it('allows a root nested inside a bound workingDir', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj/packages/app'),
).resolves.toBeUndefined();
});
it('allows a root matching defaultCwd when no workingDirs match', async () => {
const model = mockModel({ defaultCwd: '/Users/me/default', workingDirs: [] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/default'),
).resolves.toBeUndefined();
});
it('rejects a root that escapes the approved roots (filesystem root)', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(assertWorkspaceRootApproved(model, 'dev-1', '/')).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
it('rejects a sibling directory that shares a path prefix but is not contained', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj-evil'),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('rejects when the device has no approved roots at all', async () => {
const model = mockModel({ workingDirs: [] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('rejects when the device row is missing', async () => {
const model = mockModel(null);
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).rejects.toBeInstanceOf(TRPCError);
});
it('rejects an empty workspace root with BAD_REQUEST before hitting the DB', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(assertWorkspaceRootApproved(model, 'dev-1', '')).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(model.findByDeviceId).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,289 @@
// @vitest-environment node
/**
* Integration test for the server `lobe-agent-management.callAgent` deferred
* execution flow.
*
* Verifies the full lifecycle end-to-end on the in-memory runtime:
* 1. Parent op LLM emits a `lobe-agent-management____callAgent` tool call.
* 2. The real server executor parks the parent, creates a pending tool
* placeholder, and forks the target agent as a child op.
* 3. The child op completes.
* 4. The completion bridge backfills the placeholder and resumes the parent.
* 5. The parent reaches `done`.
*/
import { type LobeChatDatabase } from '@lobechat/database';
import { agentOperations, agents, messagePlugins, messages } from '@lobechat/database/schemas';
import { getTestDB } from '@lobechat/database/test-utils';
import { and, eq } from 'drizzle-orm';
import OpenAI from 'openai';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { inMemoryAgentStateManager } from '@/server/modules/AgentRuntime/InMemoryAgentStateManager';
import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager';
import { aiAgentRouter } from '../../../aiAgent';
import { cleanupTestUser, createTestUser } from '../setup';
import { createMockResponsesStream, waitForOperationComplete } from './helpers';
process.env.OPENAI_API_KEY = 'sk-test-fake-api-key-for-testing';
let testDB: LobeChatDatabase;
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: vi.fn(() => testDB),
}));
vi.mock('@/server/services/file', () => ({
FileService: vi.fn().mockImplementation(() => ({
getFullFileUrl: vi.fn().mockImplementation((path: string) => (path ? `/files${path}` : null)),
})),
}));
let mockResponsesCreate: any;
let serverDB: LobeChatDatabase;
let userId: string;
let parentAgentId: string;
let targetAgentId: string;
const TARGET_ANSWER = 'The target agent completed the delegated callAgent work.';
const PARENT_FINAL = 'I received the target agent result and the delegated work is complete.';
const createTestContext = () => ({ jwtPayload: { userId }, userId });
const createCallAgentResponse = () => {
const responseId = `resp_call_agent_${Date.now()}`;
const msgItemId = `msg_call_agent_${Date.now()}`;
const callId = 'call_agent_1';
const fnCall = {
arguments: JSON.stringify({
agentId: targetAgentId,
instruction: 'Handle the delegated backend integration task.',
runAsTask: true,
taskTitle: 'Delegated backend integration task',
timeout: 30_000,
}),
call_id: callId,
name: 'lobe-agent-management____callAgent',
type: 'function_call',
};
return createMockResponsesStream([
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [],
status: 'in_progress',
},
type: 'response.created',
},
{
item: {
content: [],
id: msgItemId,
role: 'assistant',
status: 'in_progress',
type: 'message',
},
output_index: 0,
type: 'response.output_item.added',
},
{
content_index: 0,
delta: 'I will delegate this to the target agent.',
item_id: msgItemId,
output_index: 0,
type: 'response.output_text.delta',
},
{ item: fnCall, output_index: 1, type: 'response.output_item.added' },
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [
{
content: [{ text: 'I will delegate this to the target agent.', type: 'output_text' }],
id: msgItemId,
role: 'assistant',
status: 'completed',
type: 'message',
},
fnCall,
],
status: 'completed',
usage: { input_tokens: 30, output_tokens: 20, total_tokens: 50 },
},
type: 'response.completed',
},
]);
};
const createFinalTextResponse = (content: string) => {
const responseId = `resp_final_${Date.now()}_${content.length}`;
const msgItemId = `msg_final_${Date.now()}_${content.length}`;
return createMockResponsesStream([
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [],
status: 'in_progress',
},
type: 'response.created',
},
{
content_index: 0,
delta: content,
item_id: msgItemId,
output_index: 0,
type: 'response.output_text.delta',
},
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [
{
content: [{ text: content, type: 'output_text' }],
id: msgItemId,
role: 'assistant',
status: 'completed',
type: 'message',
},
],
status: 'completed',
usage: { input_tokens: 40, output_tokens: 20, total_tokens: 60 },
},
type: 'response.completed',
},
]);
};
beforeEach(async () => {
serverDB = await getTestDB();
testDB = serverDB;
userId = await createTestUser(serverDB);
const insertedAgents = await serverDB
.insert(agents)
.values([
{
chatConfig: {},
model: 'gpt-5-pro',
plugins: ['lobe-agent-management'],
provider: 'openai',
systemRole: 'You are a supervisor that delegates work to other agents.',
title: 'callAgent Supervisor',
userId,
},
{
chatConfig: {},
model: 'gpt-5-pro',
plugins: [],
provider: 'openai',
systemRole: 'You are the target agent. Return a concise result.',
title: 'callAgent Target',
userId,
},
])
.returning();
parentAgentId = insertedAgents[0].id;
targetAgentId = insertedAgents[1].id;
// `create` is overloaded (streaming / non-streaming); its precise spy type
// isn't assignable to the generic MockInstance fallback, so widen via unknown.
mockResponsesCreate = vi.spyOn(
OpenAI.Responses.prototype,
'create',
) as unknown as typeof mockResponsesCreate;
});
afterEach(async () => {
await cleanupTestUser(serverDB, userId);
vi.clearAllMocks();
vi.restoreAllMocks();
inMemoryAgentStateManager.clear();
inMemoryStreamEventManager.clear();
});
describe('Server callAgent deferred execution', () => {
it('parks the parent, runs the target agent, backfills the tool message and resumes', async () => {
let callCount = 0;
mockResponsesCreate.mockImplementation(() => {
callCount++;
if (callCount === 1) return Promise.resolve(createCallAgentResponse() as any);
if (callCount === 2) return Promise.resolve(createFinalTextResponse(TARGET_ANSWER) as any);
return Promise.resolve(createFinalTextResponse(PARENT_FINAL) as any);
});
const caller = aiAgentRouter.createCaller(createTestContext());
const createResult = await caller.execAgent({
agentId: parentAgentId,
prompt: 'Delegate this work to the target agent and report back.',
userInterventionConfig: { approvalMode: 'headless' },
});
expect(createResult.success).toBe(true);
const finalState = await waitForOperationComplete(
inMemoryAgentStateManager,
createResult.operationId,
{ maxWaitTime: 20_000 },
);
expect(finalState.status).toBe('done');
expect(finalState.pendingToolsCalling ?? []).toHaveLength(0);
expect(mockResponsesCreate).toHaveBeenCalledTimes(3);
const childOps = await serverDB
.select()
.from(agentOperations)
.where(eq(agentOperations.parentOperationId, createResult.operationId));
expect(childOps).toHaveLength(1);
expect(childOps[0]).toMatchObject({
agentId: targetAgentId,
status: 'done',
});
const toolMessages = await serverDB
.select({
content: messages.content,
role: messages.role,
state: messagePlugins.state,
identifier: messagePlugins.identifier,
apiName: messagePlugins.apiName,
toolCallId: messagePlugins.toolCallId,
})
.from(messages)
.innerJoin(messagePlugins, eq(messagePlugins.id, messages.id))
.where(
and(
eq(messages.userId, userId),
eq(messagePlugins.identifier, 'lobe-agent-management'),
eq(messagePlugins.apiName, 'callAgent'),
),
);
expect(toolMessages).toHaveLength(1);
expect(toolMessages[0]).toMatchObject({
apiName: 'callAgent',
content: TARGET_ANSWER,
identifier: 'lobe-agent-management',
role: 'tool',
toolCallId: 'call_agent_1',
});
expect(toolMessages[0].state).toMatchObject({
status: 'completed',
threadId: childOps[0].threadId,
});
}, 30_000);
});
@@ -348,6 +348,72 @@ describe('Task Router Integration', () => {
});
});
describe('verify config', () => {
it('should set and retrieve verify config (round-trip)', async () => {
const task = await caller.create({ instruction: 'Test' });
await caller.updateVerifyConfig({
id: task.data.id,
verify: {
enabled: true,
maxIterations: 3,
verifierAgentId: 'agt_codex',
verifyCriteriaIds: ['c1', 'c2'],
verifyRubricId: 'rub_1',
},
});
const verify = await caller.getVerifyConfig({ id: task.data.id });
expect(verify.data).toEqual({
enabled: true,
maxIterations: 3,
verifierAgentId: 'agt_codex',
verifyCriteriaIds: ['c1', 'c2'],
verifyRubricId: 'rub_1',
});
// task.detail must surface the saved verify config (not leave it undefined).
const detail = await caller.detail({ id: task.data.id });
expect(detail.data!.verify).toEqual({
enabled: true,
maxIterations: 3,
verifierAgentId: 'agt_codex',
verifyCriteriaIds: ['c1', 'c2'],
verifyRubricId: 'rub_1',
});
});
it('should clear a saved field when passed null', async () => {
const task = await caller.create({ instruction: 'Test' });
await caller.updateVerifyConfig({
id: task.data.id,
verify: { enabled: true, verifierAgentId: 'agt_codex', verifyRubricId: 'rub_1' },
});
// Switch the verifier back to default + drop the rubric.
await caller.updateVerifyConfig({
id: task.data.id,
verify: { verifierAgentId: null, verifyRubricId: null },
});
const verify = await caller.getVerifyConfig({ id: task.data.id });
expect(verify.data).toEqual({ enabled: true });
});
it('getVerifyConfig falls back to the legacy review key', async () => {
const task = await caller.create({ instruction: 'Test' });
await caller.updateReview({
id: task.data.id,
review: { autoRetry: true, enabled: true, maxIterations: 4, rubrics: [] },
});
const verify = await caller.getVerifyConfig({ id: task.data.id });
expect(verify.data).toEqual({ enabled: true, maxIterations: 4 });
});
});
describe('run idempotency', () => {
it('should reject run when a topic is already running', async () => {
const task = await caller.create({
@@ -12,6 +12,7 @@ const mockFindById = vi.fn();
const mockCountTopicsForMemoryExtractor = vi.fn();
const mockDeleteAll = vi.fn();
const mockDeletePersona = vi.fn();
const { mockTriggerProcessUsers } = vi.hoisted(() => ({
mockTriggerProcessUsers: vi.fn(),
}));
@@ -43,6 +44,12 @@ vi.mock('@/database/models/userMemory', () => ({
UserMemoryPreferenceModel: vi.fn(() => ({})),
}));
vi.mock('@/database/models/userMemory/persona', () => ({
UserPersonaModel: vi.fn(() => ({
deletePersona: mockDeletePersona,
})),
}));
vi.mock('@/envs/app', () => ({
appEnv: {
APP_URL: 'https://example.com',
@@ -301,11 +308,13 @@ describe('userMemoryRouter.deleteAll', () => {
it('purges all user memories through the aggregate model', async () => {
mockDeleteAll.mockResolvedValue(undefined);
mockDeletePersona.mockResolvedValue(undefined);
const caller = createCaller();
const result = await caller.deleteAll();
expect(mockDeleteAll).toHaveBeenCalledOnce();
expect(mockDeletePersona).toHaveBeenCalledOnce();
expect(result).toEqual({ success: true });
});
});
@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { verifyRouter } from '@/server/routers/lambda/verify';
import { FileService } from '@/server/services/file';
const modelMocks = vi.hoisted(() => ({
findRunById: vi.fn(),
findResultById: vi.fn(),
getFullFileUrl: vi.fn(),
getServerDB: vi.fn(async () => ({})),
upsertByCheckItem: vi.fn(),
}));
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: modelMocks.getServerDB,
}));
vi.mock('@/database/models/verifyCheckResult', () => ({
VerifyCheckResultModel: vi.fn(() => ({
findById: modelMocks.findResultById,
upsertByCheckItem: modelMocks.upsertByCheckItem,
})),
}));
vi.mock('@/database/models/verifyRun', () => ({
VerifyRunModel: vi.fn(() => ({
findById: modelMocks.findRunById,
})),
}));
vi.mock('@/server/services/verify', () => ({
VerifyExecutorService: class VerifyExecutorService {},
VerifyFeedbackService: class VerifyFeedbackService {},
VerifyPlanGeneratorService: class VerifyPlanGeneratorService {},
}));
vi.mock('@/server/services/file', () => ({
FileService: vi.fn(() => ({
getFullFileUrl: modelMocks.getFullFileUrl,
})),
}));
const createCaller = () => verifyRouter.createCaller({ userId: 'verify-router-test-user' } as any);
const createPublicCaller = () => verifyRouter.createCaller({} as any);
const selectRows = <T>(rows: T[]) => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn(async () => rows),
})),
})),
});
describe('verifyRouter', () => {
beforeEach(() => {
vi.clearAllMocks();
modelMocks.getServerDB.mockResolvedValue({});
vi.mocked(FileService).mockImplementation(
() =>
({
getFullFileUrl: modelMocks.getFullFileUrl,
}) as any,
);
});
describe('ingestResult', () => {
it("rejects a run outside the caller's scope before upserting the result", async () => {
modelMocks.findRunById.mockResolvedValueOnce(undefined);
await expect(
createCaller().ingestResult({
checkItemId: 'shared-check',
checkItemTitle: 'attacker update',
status: 'passed',
verdict: 'passed',
verifyRunId: 'other-user-run',
}),
).rejects.toThrow('Verification run not found');
expect(modelMocks.findRunById).toHaveBeenCalledWith('other-user-run');
expect(modelMocks.upsertByCheckItem).not.toHaveBeenCalled();
});
});
describe('uploadEvidence', () => {
it('rejects evidence with both inline content and fileId', async () => {
await expect(
createCaller().uploadEvidence({
checkResultId: 'result-1',
content: 'inline payload',
fileId: 'files-1',
type: 'text',
}),
).rejects.toThrow('Provide exactly one of `content` or `fileId`.');
});
it('rejects evidence without inline content or fileId', async () => {
await expect(
createCaller().uploadEvidence({
checkResultId: 'result-1',
type: 'text',
}),
).rejects.toThrow('Provide exactly one of `content` or `fileId`.');
});
});
describe('getReportBundle', () => {
it('reads a standalone report without a logged-in user', async () => {
const run = {
goal: 'Ship a working page',
id: 'run-1',
title: 'Run report',
userId: 'owner-user',
workspaceId: null,
};
const report = {
id: 'report-1',
totalChecks: 1,
verdict: 'passed',
verifyRunId: 'run-1',
};
const result = {
checkItemId: 'check-1',
checkItemIndex: 0,
checkItemTitle: 'Page renders',
id: 'result-1',
required: true,
status: 'passed',
verdict: 'passed',
verifyRunId: 'run-1',
};
const evidence = {
checkResultId: 'result-1',
content: null,
description: 'Homepage screenshot',
fileId: 'file-1',
id: 'evidence-1',
type: 'screenshot',
};
const serverDB = {
query: {
files: {
findFirst: vi.fn(async () => ({ id: 'file-1', url: 'verify/evidence.png' })),
},
verifyReports: {
findFirst: vi.fn(async () => report),
},
verifyRuns: {
findFirst: vi.fn(async () => run),
},
},
select: vi
.fn()
.mockReturnValueOnce(selectRows([result]))
.mockReturnValueOnce(selectRows([evidence])),
};
modelMocks.getServerDB.mockResolvedValue(serverDB);
modelMocks.getFullFileUrl.mockResolvedValue('https://cdn.example.com/verify/evidence.png');
const bundle = await createPublicCaller().getReportBundle({ verifyRunId: 'run-1' });
expect(bundle).toMatchObject({
report,
results: [
{
checkItemId: 'check-1',
evidence: [
{
fileId: 'file-1',
fileUrl: 'https://cdn.example.com/verify/evidence.png',
},
],
},
],
run,
});
expect(modelMocks.findRunById).not.toHaveBeenCalled();
});
it('keeps returning the bundle when file URL resolution is unavailable', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(FileService).mockImplementation(() => {
throw new Error('S3 env missing');
});
const run = {
goal: 'Ship a working page',
id: 'run-1',
title: 'Run report',
userId: 'owner-user',
workspaceId: null,
};
const result = {
checkItemId: 'check-1',
checkItemIndex: 0,
checkItemTitle: 'Page renders',
id: 'result-1',
required: true,
status: 'passed',
verdict: 'passed',
verifyRunId: 'run-1',
};
const evidence = {
checkResultId: 'result-1',
content: null,
description: 'Homepage screenshot',
fileId: 'file-1',
id: 'evidence-1',
type: 'screenshot',
};
const serverDB = {
query: {
files: {
findFirst: vi.fn(async () => ({ id: 'file-1', url: 'verify/evidence.png' })),
},
verifyReports: {
findFirst: vi.fn(async () => null),
},
verifyRuns: {
findFirst: vi.fn(async () => run),
},
},
select: vi
.fn()
.mockReturnValueOnce(selectRows([result]))
.mockReturnValueOnce(selectRows([evidence])),
};
modelMocks.getServerDB.mockResolvedValue(serverDB);
const bundle = await createPublicCaller().getReportBundle({ verifyRunId: 'run-1' });
expect(bundle).toMatchObject({
results: [
{
evidence: [
{
fileId: 'file-1',
fileUrl: null,
},
],
},
],
run,
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[verify:getReportBundle:resolveFileUrl]',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
});
});
@@ -41,6 +41,7 @@ export const updateDocumentInputSchema = z.object({
editorData: z.string().optional(),
fileType: z.string().optional(),
id: z.string(),
lockOwnerId: z.string().optional(),
metadata: z.record(z.any()).optional(),
parentId: z.string().nullable().optional(),
restoreFromHistoryId: z.string().optional(),
@@ -51,6 +52,7 @@ export const updateDocumentInputSchema = z.object({
export const saveDocumentHistoryInputSchema = z.object({
documentId: z.string(),
editorData: z.string(),
lockOwnerId: z.string().optional(),
saveSource: documentHistorySaveSourceSchema,
});
@@ -98,6 +100,8 @@ export interface UpdateDocumentOutput {
export interface SaveDocumentHistoryInput {
documentId: string;
editorData: string;
/** Edit-session id proving the client still holds the workspace page lease. */
lockOwnerId?: string;
saveSource: DocumentHistorySaveSource;
}
@@ -130,6 +134,7 @@ export interface UpdateDocumentInput {
editorData?: string;
fileType?: string;
id: string;
lockOwnerId?: string;
metadata?: Record<string, any>;
parentId?: string | null;
restoreFromHistoryId?: string;
@@ -8,6 +8,7 @@ import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { TopicTrigger } from '@/const/topic';
import { AgentDocumentModel } from '@/database/models/agentDocuments';
import { TopicModel } from '@/database/models/topic';
import { TopicDocumentModel } from '@/database/models/topicDocument';
@@ -254,6 +255,56 @@ export const agentDocumentRouter = router({
return ctx.agentDocumentService.getDocument(input.agentId, input.filename);
}),
/**
* Return the chat topic that anchors the doc-scoped conversation for this
* `(documentId, agentId)` pair, creating it idempotently on the first call.
*
* Topics are marked with `trigger='document'` so they stay out of the main
* sidebar history (`MAIN_SIDEBAR_EXCLUDE_TRIGGERS` already excludes them).
* The mapping is persisted through `topic_documents`, so subsequent calls
* resolve the same topic id.
*/
getOrCreateChatTopic: agentDocumentProcedure
.input(
z.object({
agentId: z.string(),
documentId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.topicModel.findByAgentAndDocumentTrigger({
agentId: input.agentId,
documentId: input.documentId,
trigger: TopicTrigger.Document,
});
if (existing) return { topicId: existing.id };
const document = await ctx.agentDocumentService.findRowByDocumentId(
input.agentId,
input.documentId,
);
if (!document) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Document not found for agentId=${input.agentId}`,
});
}
const title = document.title || document.filename || 'Document chat';
const topic = await ctx.topicModel.create({
agentId: input.agentId,
title,
trigger: TopicTrigger.Document,
});
await ctx.topicDocumentModel.associate({
documentId: input.documentId,
topicId: topic.id,
});
return { topicId: topic.id };
}),
/**
* Create or update a document
*/
@@ -372,12 +423,16 @@ export const agentDocumentRouter = router({
.input(
z.object({
agentId: z.string(),
// Reveal the auto-created `.tool-results` archive. Off by default so
// user-facing lists stay clean; the agent document-listing tool opts in.
includeArchivedToolResults: z.boolean().optional().default(false),
scope: z.enum(['agent', 'currentTopic']).optional().default('agent'),
sourceType: z.enum(['all', 'file', 'web']).optional().default('all'),
topicId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { includeArchivedToolResults } = input;
if (input.scope === 'currentTopic') {
if (!input.topicId) throw new Error('topicId is required to list current topic documents');
@@ -385,10 +440,13 @@ export const agentDocumentRouter = router({
input.agentId,
input.topicId,
input.sourceType,
{ includeArchivedToolResults },
);
}
return ctx.agentDocumentService.listDocuments(input.agentId, input.sourceType);
return ctx.agentDocumentService.listDocuments(input.agentId, input.sourceType, {
includeArchivedToolResults,
});
}),
/**
@@ -139,6 +139,8 @@ const ExecAgentSchema = z
.object({
defaultTaskAssigneeAgentId: z.string().optional(),
documentId: z.string().optional().nullable(),
/** The agent being edited when scope is 'agent_builder' (not the builder builtin itself). */
editingAgentId: z.string().optional(),
groupId: z.string().optional().nullable(),
initialTopicMetadata: z
.object({
+36 -2
View File
@@ -1,3 +1,4 @@
import { TRPCError } from '@trpc/server';
import { type AiProviderModelListItem } from 'model-bank';
import {
AiModelTypeSchema,
@@ -18,6 +19,30 @@ import { getServerGlobalConfig } from '@/server/globalConfig';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { type ProviderConfig } from '@/types/user/settings';
const AI_MODEL_UNIQUE_CONSTRAINT = 'ai_models_id_provider_id_user_id_pk';
const getPostgresErrorField = (error: unknown, field: 'code' | 'constraint') => {
let current = error;
while (current && typeof current === 'object') {
const value = (current as Record<string, unknown>)[field];
if (typeof value === 'string') return value;
current = (current as { cause?: unknown }).cause;
}
};
const isDuplicateAiModelError = (error: unknown) =>
getPostgresErrorField(error, 'code') === '23505' &&
getPostgresErrorField(error, 'constraint') === AI_MODEL_UNIQUE_CONSTRAINT;
const throwDuplicateAiModelError = (id: string): never => {
throw new TRPCError({
code: 'CONFLICT',
message: `Model "${id}" already exists`,
});
};
const aiModelProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
const wsId = ctx.workspaceId ?? undefined;
@@ -82,9 +107,18 @@ export const aiModelRouter = router({
.use(withScopedPermission('ai_model:create'))
.input(CreateAiModelSchema)
.mutation(async ({ input, ctx }) => {
const data = await ctx.aiModelModel.create(input);
const existingModel = await ctx.aiModelModel.findByIdAndProvider(input.id, input.providerId);
if (existingModel) throwDuplicateAiModelError(input.id);
return data?.id;
try {
const data = await ctx.aiModelModel.create(input);
return data?.id;
} catch (error) {
if (isDuplicateAiModelError(error)) throwDuplicateAiModelError(input.id);
throw error;
}
}),
getAiModelById: aiModelProcedure
+161
View File
@@ -0,0 +1,161 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { FileModel } from '@/database/models/file';
import type { LobeChatDatabase } from '@/database/type';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { FileService } from '@/server/services/file';
const asrProcedure = wsCompatProcedure.use(serverDatabase);
// Inline base64 is only for short clips. The whole request must fit inside the
// platform body limit (≈4.5MB on serverless deploys) and base64 inflates bytes
// by ~4/3, so cap the decoded audio well under that — anything larger should be
// uploaded and passed as `fileId`.
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
// base64 length ≈ ceil(bytes / 3) * 4; validating the string length lets us
// reject oversized payloads before allocating/decoding them.
const MAX_INLINE_AUDIO_BASE64_CHARS = Math.ceil(MAX_INLINE_AUDIO_BYTES / 3) * 4;
interface ResolvedAudio {
bytes: Uint8Array;
fileName: string;
mimeType?: string;
}
export const asrRouter = router({
/**
* Automatic Speech Recognition (speech-to-text).
*
* Accepts the audio either as an already-uploaded `fileId` (preferred the
* server streams the bytes from storage, nothing large travels over tRPC) or
* inline as base64 for short clips (capped at `MAX_INLINE_AUDIO_BYTES`;
* larger payloads are rejected with guidance to upload and pass `fileId`).
*
* Note on base64: tRPC here uses an `httpLink` + superjson (JSON only), which
* has no binary representation for a `Buffer`/`Uint8Array` a raw buffer would
* serialize to a per-byte JSON object, far worse than base64. So inline bytes
* stay base64; use `fileId` to avoid inlining entirely.
*
* Transcription is a single request/response (not streamed), so a mutation is
* the right shape.
*/
transcribe: asrProcedure
.input(
z
.object({
/** Base64-encoded audio bytes (short clips only). Mutually exclusive with `fileId`. */
audioBase64: z
.string()
.min(1)
.max(MAX_INLINE_AUDIO_BASE64_CHARS, {
message: `Inline audio is limited to ${MAX_INLINE_AUDIO_BYTES / 1024 / 1024}MB. Upload the file and pass \`fileId\` instead.`,
})
.optional(),
/** Already-uploaded audio file id. Mutually exclusive with `audioBase64`. */
fileId: z.string().min(1).optional(),
/** Original file name (base64 path); its extension helps format detection. */
fileName: z.string().optional(),
/** ISO-639-1 language code (e.g. `en`, `zh`). */
language: z.string().optional(),
/** Audio mime type (base64 path, e.g. `audio/mp4`). */
mimeType: z.string().optional(),
model: z.string().min(1),
/** Optional text to guide the model's style. */
prompt: z.string().optional(),
provider: z.string().default('openai'),
responseFormat: z.enum(['json', 'srt', 'text', 'verbose_json', 'vtt']).optional(),
})
.refine((d) => Boolean(d.fileId) !== Boolean(d.audioBase64), {
message: 'Provide exactly one of `fileId` or `audioBase64`.',
}),
)
.mutation(async ({ ctx, input }): Promise<{ text: string }> => {
const workspaceId = ctx.workspaceId ?? undefined;
const { bytes, fileName, mimeType } = await resolveAudio(ctx, input, workspaceId);
// Resolve the user's provider config (key + baseURL) from the database,
// falling back to server env keys, exactly like chat/embeddings do.
const runtime = await initModelRuntimeFromDB(
ctx.serverDB,
ctx.userId,
input.provider,
workspaceId,
);
// `Uint8Array` is a valid BlobPart at runtime; the cast sidesteps the
// `Uint8Array<ArrayBufferLike>` vs BlobPart generic mismatch in lib.dom.
const file = new File([bytes as BlobPart], fileName, {
type: mimeType || 'application/octet-stream',
});
const result = await runtime.transcribe(
{
file,
fileName,
language: input.language,
model: input.model,
prompt: input.prompt,
responseFormat: input.responseFormat,
},
{ user: ctx.userId },
);
if (!result) {
throw new TRPCError({
code: 'NOT_IMPLEMENTED',
message: `Provider "${input.provider}" does not support ASR.`,
});
}
return result;
}),
});
/**
* Turn the request into raw audio bytes + metadata, from either a stored file
* (downloaded from S3, ownership enforced by the userId-scoped FileModel) or the
* inline base64 payload.
*/
async function resolveAudio(
ctx: { serverDB: LobeChatDatabase; userId: string },
input: { audioBase64?: string; fileId?: string; fileName?: string; mimeType?: string },
workspaceId?: string,
): Promise<ResolvedAudio> {
if (input.fileId) {
const fileModel = new FileModel(ctx.serverDB, ctx.userId, workspaceId);
const fileItem = await fileModel.findById(input.fileId);
if (!fileItem) {
throw new TRPCError({ code: 'NOT_FOUND', message: `File "${input.fileId}" not found.` });
}
const fileService = new FileService(ctx.serverDB, ctx.userId, workspaceId);
let bytes: Uint8Array;
try {
bytes = await fileService.getFileByteArray(fileItem.url);
} catch (error) {
if ((error as { Code?: string }).Code === 'NoSuchKey') {
throw new TRPCError({
code: 'NOT_FOUND',
message: `File "${input.fileId}" is no longer available in storage.`,
});
}
throw error;
}
return { bytes, fileName: fileItem.name, mimeType: fileItem.fileType };
}
return {
bytes: new Uint8Array(Buffer.from(input.audioBase64!, 'base64')),
fileName: input.fileName || 'audio',
mimeType: input.mimeType,
};
}
export type AsrRouter = typeof asrRouter;
+256
View File
@@ -0,0 +1,256 @@
import { type ToolManifest } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { getServerComposioAuthConfigId } from '@/config/composio';
import { PluginModel } from '@/database/models/plugin';
import { getComposioClient } from '@/libs/composio';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
const composioProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const client = getComposioClient();
const pluginModel = new PluginModel(opts.ctx.serverDB, opts.ctx.userId);
return opts.next({
ctx: { ...opts.ctx, composioClient: client, pluginModel },
});
});
export const composioRouter = router({
createConnection: composioProcedure
.input(
z.object({
appSlug: z.string(),
identifier: z.string(),
label: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const { appSlug, identifier, label } = input;
const { userId } = ctx;
const callbackUrl = `${process.env.APP_URL || process.env.NEXTAUTH_URL || ''}/api/composio/oauth/callback`;
// Prefer a pre-configured auth config (e.g. a custom/white-label config
// created in the Composio dashboard), pinned per toolkit via env. Falls
// back to discovering an existing config for this toolkit, and finally to
// auto-creating a Composio-managed one.
let authConfigId = getServerComposioAuthConfigId(identifier);
if (!authConfigId) {
const authConfigs = await (ctx.composioClient.authConfigs as any).list();
let authConfig = authConfigs?.items?.find(
(c: any) => c.toolkit?.slug?.toLowerCase() === appSlug.toLowerCase(),
);
if (!authConfig) {
authConfig = await (ctx.composioClient.authConfigs as any).create(appSlug, {
name: appSlug,
type: 'use_composio_managed_auth',
});
}
authConfigId = authConfig.id;
}
if (!authConfigId) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Failed to resolve a Composio auth config for "${appSlug}".`,
});
}
// Composio-managed OAuth auth configs no longer support `initiate`; use
// `link` (POST /api/v3/connected_accounts/link) to get the redirect URL.
const connReq = await (ctx.composioClient.connectedAccounts as any).link(
userId,
authConfigId,
{ callbackUrl },
);
let rawTools: any[] = [];
try {
const toolsResp = await (ctx.composioClient.tools as any).getRawComposioTools({
toolkits: [appSlug],
});
rawTools = toolsResp?.items || toolsResp || [];
} catch {
// tools may not be available before auth
}
const manifest: ToolManifest = {
api: Array.isArray(rawTools)
? rawTools.map((tool: any) => ({
description: tool.description || '',
name: tool.slug || tool.name || '',
parameters: tool.inputParameters ||
tool.inputSchema || {
properties: {},
type: 'object',
},
}))
: [],
identifier,
meta: {
avatar: '🔌',
description: `Composio: ${label}`,
title: label,
},
type: 'default',
};
await ctx.pluginModel.create({
customParams: {
composio: {
appSlug,
authConfigId,
connectedAccountId: connReq.id,
redirectUrl: connReq.redirectUrl,
status: 'PENDING',
},
},
identifier,
manifest,
source: 'composio',
type: 'plugin',
});
return {
authConfigId,
connectedAccountId: connReq.id,
identifier,
redirectUrl: connReq.redirectUrl,
};
}),
deleteConnection: composioProcedure
.input(
z.object({
connectedAccountId: z.string(),
identifier: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
await (ctx.composioClient.connectedAccounts as any).delete(input.connectedAccountId);
} catch (error) {
console.warn('[Composio] Failed to delete remote connection:', error);
}
await ctx.pluginModel.delete(input.identifier);
return { success: true };
}),
getComposioPlugins: composioProcedure.query(async ({ ctx }) => {
const allPlugins = await ctx.pluginModel.query();
return allPlugins.filter((plugin) => plugin.customParams?.composio);
}),
getConnection: composioProcedure
.input(
z.object({
connectedAccountId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
try {
const account = await (ctx.composioClient.connectedAccounts as any).get(
input.connectedAccountId,
);
return {
appSlug: account?.toolkit?.slug || '',
connectedAccountId: input.connectedAccountId,
error: undefined as 'AUTH_ERROR' | undefined,
status: (account?.status || 'PENDING') as string,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const isAuthError = errorMessage.includes('401') || errorMessage.includes('Unauthorized');
if (isAuthError) {
return {
appSlug: '',
connectedAccountId: input.connectedAccountId,
error: 'AUTH_ERROR' as const,
status: 'FAILED',
};
}
throw error;
}
}),
removeComposioPlugin: composioProcedure
.input(z.object({ identifier: z.string() }))
.mutation(async ({ input, ctx }) => {
await ctx.pluginModel.delete(input.identifier);
return { success: true };
}),
updateComposioPlugin: composioProcedure
.input(
z.object({
appSlug: z.string(),
authConfigId: z.string(),
connectedAccountId: z.string(),
identifier: z.string(),
label: z.string(),
redirectUrl: z.string().optional(),
status: z.string(),
tools: z.array(
z.object({
description: z.string().optional(),
inputSchema: z.any().optional(),
name: z.string(),
}),
),
}),
)
.mutation(async ({ input, ctx }) => {
const {
identifier,
label,
appSlug,
authConfigId,
connectedAccountId,
tools,
status,
redirectUrl,
} = input;
const existingPlugin = await ctx.pluginModel.findById(identifier);
const manifest: ToolManifest = {
api: tools.map((tool) => ({
description: tool.description || '',
name: tool.name,
parameters: tool.inputSchema || { properties: {}, type: 'object' },
})),
identifier,
meta: existingPlugin?.manifest?.meta || {
avatar: '🔌',
description: `Composio: ${label}`,
title: label,
},
type: 'default',
};
const customParams = {
composio: { appSlug, authConfigId, connectedAccountId, redirectUrl, status },
};
if (existingPlugin) {
await ctx.pluginModel.update(identifier, { customParams, manifest });
} else {
await ctx.pluginModel.create({
customParams,
identifier,
manifest,
source: 'composio',
type: 'plugin',
});
}
return { savedCount: tools.length };
}),
});
export type ComposioRouter = typeof composioRouter;
+34 -2
View File
@@ -115,6 +115,33 @@ export const connectorRouter = router({
return toolsByConnector;
}),
/**
* Return the connector record with decrypted user-set credentials so the
* edit form can pre-fill accurately. Only the connector owner can call this
* (enforced by connectorProcedure ownership check).
*
* Machine-managed secrets are intentionally excluded:
* - OAuth access/refresh tokens (type 'oauth2') stripped, returned as null
* - oidcConfig.clientSecret (DCR-registered secret) stripped
* User-set credentials (bearer token, custom headers) are returned as-is so
* the edit form can display them.
*/
getForEdit: connectorProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const connector = await ctx.connectorModel.findById(input.id);
if (!connector)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Connector not found' });
const { oidcConfig, credentials, ...rest } = connector;
const safeOidcConfig = oidcConfig ? { ...oidcConfig, clientSecret: undefined } : oidcConfig;
// OAuth tokens are machine-managed — don't return them; the UI only needs
// to know an OAuth flow is configured (reflected via oidcConfig presence).
const safeCredentials = credentials?.type === 'oauth2' ? null : credentials;
return { ...rest, credentials: safeCredentials, oidcConfig: safeOidcConfig };
}),
/**
* The exact redirect URI the server will send to the OAuth/DCR endpoints.
* The Add modal must display THIS value (not a client-derived origin) so the
@@ -268,9 +295,14 @@ export const connectorRouter = router({
await ctx.connectorModel.update(input.id, {
...patch,
// undefined → leave untouched; null → clear; object → encrypt the JSON string.
// When credentials are cleared, also drop the cached expiry timestamp so
// token-refresh logic doesn't act on a stale value for the new server.
...(credentials === undefined
? {}
: { credentials: credentials ? JSON.stringify(credentials) : null }),
: {
credentials: credentials ? JSON.stringify(credentials) : null,
...(credentials === null ? { tokenExpiresAt: null } : {}),
}),
} as any);
}),
@@ -358,7 +390,7 @@ export const connectorRouter = router({
}),
/**
* Sync tools from a client-provided list (for Lobehub OAuth skills, Klavis, etc.
* Sync tools from a client-provided list (for Lobehub OAuth skills, Composio, etc.
* that already have their tool list available on the client side).
* Idempotent safe to call whenever the detail panel opens.
*/
+298 -74
View File
@@ -1,13 +1,19 @@
import { REMOTE_HETEROGENEOUS_AGENT_CONFIGS } from '@lobechat/heterogeneous-agents';
import type { DeviceChannel, DeviceListItem, WorkingDirEntry } from '@lobechat/types';
import type { DeviceChannel, DeviceListItem, DeviceScope, WorkingDirEntry } from '@lobechat/types';
import { z } from 'zod';
import {
wsCompatProcedure,
wsOwnerProcedure,
} from '@/business/server/trpc-middlewares/workspaceAuth';
import { DeviceModel } from '@/database/models/device';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { deviceGateway } from '@/server/services/deviceGateway';
import { signWorkspaceDeviceToken } from '@/libs/trpc/utils/internalJwt';
import { type DeviceAttachment, deviceGateway } from '@/server/services/deviceGateway';
import { preserveWorkspaceCache } from './deviceWorkingDirs';
import { assertWorkspaceRootApproved } from './deviceWorkspaceGuard';
// Derive the zod enum from the canonical config so new platforms are
// automatically covered without touching this file.
@@ -21,14 +27,39 @@ const remotePlatformEnum = z.enum(
const CAPABILITY_TIMEOUT_MS = 5_000;
const PROFILE_TIMEOUT_MS = 5_000;
const deviceProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
// Workspace-aware (compat): with an `X-Workspace-Id` header the device list also
// surfaces the workspace's shared devices; without it, the personal path is
// unchanged (`ctx.workspaceId === undefined`).
const deviceProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
const wsId = ctx.workspaceId ?? undefined;
return opts.next({
ctx: { deviceModel: new DeviceModel(ctx.serverDB, ctx.userId), userId: ctx.userId },
ctx: {
deviceModel: new DeviceModel(ctx.serverDB, ctx.userId, wsId),
userId: ctx.userId,
workspaceId: wsId,
},
});
});
const workspaceFileInput = z.object({
deviceId: z.string(),
workingDirectory: z.string(),
});
/**
* `deviceProcedure` that additionally requires `workingDirectory` to be an
* approved workspace root for the device. Builds the guard into the procedure
* so every file-mutating route inherits it and can never forget the check
* see {@link assertWorkspaceRootApproved} for why the check is necessary.
*/
const workspaceFileProcedure = deviceProcedure.input(workspaceFileInput).use(async (opts) => {
const { deviceId, workingDirectory } = workspaceFileInput.parse(await opts.getRawInput());
await assertWorkspaceRootApproved(opts.ctx.deviceModel, deviceId, workingDirectory);
return opts.next();
});
export const deviceRouter = router({
/**
* Probe whether a specific agent platform (openclaw / hermes) is available
@@ -44,7 +75,7 @@ export const deviceRouter = router({
)
.query(async ({ ctx, input }) => {
const result = await deviceGateway.executeToolCall(
{ deviceId: input.deviceId, userId: ctx.userId },
{ deviceId: input.deviceId, userId: ctx.userId, workspaceId: ctx.workspaceId },
{
apiName: 'checkPlatformCapability',
arguments: JSON.stringify({ platform: input.platform }),
@@ -81,6 +112,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -93,6 +125,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -104,6 +137,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -115,10 +149,28 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
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,
workspaceId: ctx.workspaceId,
});
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
@@ -136,6 +188,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? [];
}),
@@ -160,6 +213,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
@@ -183,6 +237,7 @@ export const deviceRouter = router({
path: input.path,
to: input.to,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
@@ -204,6 +259,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
@@ -218,6 +274,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
@@ -232,6 +289,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
@@ -247,6 +305,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -263,6 +322,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -278,6 +338,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? [];
}),
@@ -294,6 +355,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -310,6 +372,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
scope: input.scope,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -318,24 +381,23 @@ export const deviceRouter = router({
* Read-only local file preview for a file on a remote device. The web client
* receives render data, not a `localfile://` URL; saving remains unsupported.
*/
getLocalFilePreview: deviceProcedure
getLocalFilePreview: workspaceFileProcedure
.input(
z.object({
accept: z.enum(['image']).optional(),
deviceId: z.string(),
path: z.string(),
workingDirectory: z.string(),
}),
)
.query(async ({ ctx, input }) =>
deviceGateway.getLocalFilePreview({
.query(async ({ ctx, input }) => {
return deviceGateway.getLocalFilePreview({
accept: input.accept,
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workingDirectory: input.workingDirectory,
}),
),
});
}),
/**
* Project skills (`.agents/skills` / `.claude/skills`) for a directory on a
@@ -349,6 +411,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
scope: input.scope,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -365,9 +428,74 @@ export const deviceRouter = router({
filePath: input.filePath,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
/**
* Move files/folders within a directory on a remote device, via the device's
* `moveLocalFiles` RPC. Powers the Files tree's drag-to-move in device mode.
*/
moveProjectFiles: workspaceFileProcedure
.input(
z.object({
items: z.array(z.object({ newPath: z.string(), oldPath: z.string() })),
}),
)
.mutation(async ({ ctx, input }) => {
return deviceGateway.moveProjectFiles({
deviceId: input.deviceId,
items: input.items,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workingDirectory: input.workingDirectory,
});
}),
/**
* Rename a single file/folder in a directory on a remote device, via the
* device's `renameLocalFile` RPC.
*/
renameProjectFile: workspaceFileProcedure
.input(
z.object({
newName: z.string(),
path: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
return deviceGateway.renameProjectFile({
deviceId: input.deviceId,
newName: input.newName,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workingDirectory: input.workingDirectory,
});
}),
/**
* Save edited content back to a file on a remote device, via the device's
* `writeLocalFile` RPC. Powers remote save in the LocalFile editor.
*/
writeProjectFile: workspaceFileProcedure
.input(
z.object({
content: z.string(),
path: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
return deviceGateway.writeProjectFile({
content: input.content,
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workingDirectory: input.workingDirectory,
});
}),
/**
* Check whether a path exists on a remote device and is a directory, via the
* device's `statPath` RPC. Lets a web client validate a manually-entered
@@ -381,6 +509,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -399,7 +528,7 @@ export const deviceRouter = router({
)
.query(async ({ ctx, input }) => {
const result = await deviceGateway.executeToolCall(
{ deviceId: input.deviceId, userId: ctx.userId },
{ deviceId: input.deviceId, userId: ctx.userId, workspaceId: ctx.workspaceId },
{
apiName: 'getAgentProfile',
arguments: JSON.stringify({ platform: input.platform }),
@@ -424,7 +553,7 @@ export const deviceRouter = router({
getDeviceSystemInfo: deviceProcedure
.input(z.object({ deviceId: z.string() }))
.query(async ({ ctx, input }) => {
return deviceGateway.queryDeviceSystemInfo(ctx.userId, input.deviceId);
return deviceGateway.queryDeviceSystemInfo(ctx.userId, input.deviceId, ctx.workspaceId);
}),
/**
@@ -440,76 +569,171 @@ export const deviceRouter = router({
* a currently-reachable device during rollout.
*/
listDevices: deviceProcedure.query(async ({ ctx }): Promise<DeviceListItem[]> => {
const [registered, onlineList] = await Promise.all([
ctx.deviceModel.query(),
const wsId = ctx.workspaceId;
// Personal devices resolve under the user principal; workspace devices under
// the `workspace:<id>` principal (a separate gateway pool). Fetch both.
const [personalRows, workspaceRows, personalOnline, workspaceOnline] = await Promise.all([
ctx.deviceModel.queryPersonal(),
wsId ? ctx.deviceModel.queryWorkspaceDevices() : Promise.resolve([]),
deviceGateway.queryDeviceList(ctx.userId),
wsId ? deviceGateway.queryDeviceList(ctx.userId, wsId) : Promise.resolve([]),
]);
// The gateway already groups by device, exposing live sessions as nested
// `channels`. Flatten them into the UI-facing channel shape; fall back to a
// single synthetic channel for a legacy gateway that omits the field.
const channelsByDevice = new Map<string, DeviceChannel[]>();
for (const conn of onlineList) {
const channels: DeviceChannel[] =
conn.channels && conn.channels.length > 0
? conn.channels.map((c) => ({
channel: c.channel ?? null,
connectedAt: c.connectedAt,
// `channels`. Flatten one connection into the UI-facing channel shape; fall
// back to a single synthetic channel for a legacy gateway that omits the field.
const toChannels = (conn: DeviceAttachment): DeviceChannel[] =>
conn.channels && conn.channels.length > 0
? conn.channels.map((c) => ({
channel: c.channel ?? null,
connectedAt: c.connectedAt,
hostname: conn.hostname ?? null,
platform: conn.platform ?? null,
}))
: [
{
channel: null,
connectedAt: conn.lastSeen,
hostname: conn.hostname ?? null,
platform: conn.platform ?? null,
}))
: [
{
channel: null,
connectedAt: conn.lastSeen,
hostname: conn.hostname ?? null,
platform: conn.platform ?? null,
},
];
channelsByDevice.set(conn.deviceId, channels);
}
},
];
const seen = new Set<string>();
// Merge a DB-registered set with its live gateway pool into the UI shape.
// `scope` tags the group; deviceIds never collide across pools (a personal id
// is derived from userId, a workspace id from workspaceId).
const buildItems = (
rows: Awaited<ReturnType<typeof ctx.deviceModel.queryPersonal>>,
onlineList: DeviceAttachment[],
scope: DeviceScope,
): DeviceListItem[] => {
const channelsByDevice = new Map<string, DeviceChannel[]>();
for (const conn of onlineList) channelsByDevice.set(conn.deviceId, toChannels(conn));
const fromDb = registered.map((d) => {
seen.add(d.deviceId);
const channels = channelsByDevice.get(d.deviceId) ?? [];
const live = channels[0];
return {
channels,
defaultCwd: d.defaultCwd,
deviceId: d.deviceId,
friendlyName: d.friendlyName,
hostname: d.hostname ?? live?.hostname ?? null,
identitySource: d.identitySource,
lastSeen: d.lastSeenAt.toISOString(),
online: channels.length > 0,
platform: d.platform ?? live?.platform ?? null,
registered: true,
workingDirs: d.workingDirs ?? [],
};
});
const seen = new Set<string>();
const fromDb = rows.map((d): DeviceListItem => {
seen.add(d.deviceId);
const channels = channelsByDevice.get(d.deviceId) ?? [];
const live = channels[0];
return {
channels,
defaultCwd: d.defaultCwd,
deviceId: d.deviceId,
friendlyName: d.friendlyName,
hostname: d.hostname ?? live?.hostname ?? null,
identitySource: d.identitySource,
lastSeen: d.lastSeenAt.toISOString(),
online: channels.length > 0,
platform: d.platform ?? live?.platform ?? null,
registered: true,
scope,
workingDirs: d.workingDirs ?? [],
};
});
// Online but not yet persisted — transient until the client auto-registers.
const ghosts = [...channelsByDevice.entries()]
.filter(([deviceId]) => !seen.has(deviceId))
.map(([deviceId, channels]) => ({
channels,
defaultCwd: null,
deviceId,
friendlyName: null,
hostname: channels[0]?.hostname ?? null,
identitySource: null,
lastSeen: channels[0]?.connectedAt ?? new Date().toISOString(),
online: true,
platform: channels[0]?.platform ?? null,
registered: false,
workingDirs: [] as WorkingDirEntry[],
}));
// Online but not yet persisted — transient until the client auto-registers.
const ghosts = [...channelsByDevice.entries()]
.filter(([deviceId]) => !seen.has(deviceId))
.map(
([deviceId, channels]): DeviceListItem => ({
channels,
defaultCwd: null,
deviceId,
friendlyName: null,
hostname: channels[0]?.hostname ?? null,
identitySource: null,
lastSeen: channels[0]?.connectedAt ?? new Date().toISOString(),
online: true,
platform: channels[0]?.platform ?? null,
registered: false,
scope,
workingDirs: [] as WorkingDirEntry[],
}),
);
return [...fromDb, ...ghosts];
return [...fromDb, ...ghosts];
};
return [
...buildItems(personalRows, personalOnline, 'personal'),
...buildItems(workspaceRows, workspaceOnline, 'workspace'),
];
}),
/**
* Mint a short-lived connect token for enrolling a WORKSPACE-owned device.
* Owner-only (`wsOwnerProcedure`) the server verifies the caller is an admin
* of the workspace, then signs a token carrying the `workspace_id` claim that
* the device gateway trusts to route the device to the `workspace:<id>`
* principal. The CLI (`lh connect --workspace`) / settings page use this.
*/
mintWorkspaceConnectToken: wsOwnerProcedure.mutation(async ({ ctx }) => {
const token = await signWorkspaceDeviceToken(ctx.workspaceId);
return { token, workspaceId: ctx.workspaceId };
}),
/**
* Enroll the calling machine as a device of the current workspace. Owner-only;
* stamps `workspace_id` so the row belongs to the workspace. Used by
* `lh connect --workspace` after minting the connect token.
*/
registerWorkspaceDevice: wsOwnerProcedure
.use(serverDatabase)
.input(
z.object({
deviceId: z.string().min(1).max(64),
hostname: z.string().nullable().optional(),
identitySource: z.enum(['machine-id', 'fallback']),
platform: z.string().max(20).nullable().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const model = new DeviceModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
return model.registerWorkspaceDevice({ ...input, workspaceId: ctx.workspaceId });
}),
/**
* Rename / set working dirs of a WORKSPACE device scoped by `workspace_id`,
* owner-gated, so any workspace owner can manage it (not just the enroller).
* Mirrors {@link deviceRouter.updateDevice} but for the workspace pool.
*/
updateWorkspaceDevice: wsOwnerProcedure
.use(serverDatabase)
.input(
z.object({
defaultCwd: z.string().nullable().optional(),
deviceId: z.string(),
friendlyName: z.string().max(100).nullable().optional(),
workingDirs: z
.array(z.object({ path: z.string(), repoType: z.enum(['git', 'github']).optional() }))
.max(20)
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const model = new DeviceModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
const { deviceId, workingDirs, ...value } = input;
const nextWorkingDirs = workingDirs
? preserveWorkspaceCache(
workingDirs,
(await model.findWorkspaceDeviceById(deviceId))?.workingDirs ?? [],
)
: undefined;
await model.updateWorkspaceDevice(deviceId, { ...value, workingDirs: nextWorkingDirs });
return { success: true };
}),
/** Remove a WORKSPACE device — scoped by `workspace_id`, owner-gated. */
removeWorkspaceDevice: wsOwnerProcedure
.use(serverDatabase)
.input(z.object({ deviceId: z.string() }))
.mutation(async ({ ctx, input }) => {
const model = new DeviceModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
await model.deleteWorkspaceDevice(input.deviceId);
return { success: true };
}),
/**
* Auto-register the calling device (desktop after OIDC login / CLI on first
* `lh connect`). Upserts on (userId, deviceId); user-owned fields are
@@ -536,7 +760,7 @@ export const deviceRouter = router({
}),
status: deviceProcedure.query(async ({ ctx }) => {
return deviceGateway.queryDeviceStatus(ctx.userId);
return deviceGateway.queryDeviceStatus(ctx.userId, ctx.workspaceId);
}),
/** User-editable fields only — never the machine-reported identity columns. */
@@ -0,0 +1,52 @@
import { TRPCError } from '@trpc/server';
import type { DeviceModel } from '@/database/models/device';
import { isPathWithinRoot } from '@/server/services/deviceGateway';
/**
* Validate that a client-supplied workspace root is actually one the user has
* bound to this device.
*
* The file routes (move / rename / write / preview) receive `workingDirectory`
* from the same untrusted browser session that supplies the file paths. The
* gateway's `assertPathsWithinWorkspace` only proves the paths sit *inside that
* directory* it never proves the directory itself is legitimate. So a caller
* could set `workingDirectory` to `/` (or `C:\`), pass that containment check
* trivially, and reach any path on the device.
*
* To close that hole we re-derive the approved roots from the *server-owned*
* device row the `workingDirs` recent list and `defaultCwd`, both written only
* via `device.updateDevice` / the run path, never trusted from this request
* and require the requested root to equal or nest inside one of them before any
* RPC is forwarded. The picker upserts every chosen directory into `workingDirs`
* (see `useCommitWorkingDirectory`) and run start upserts the bound cwd, so a
* legitimately-selected workspace is always present here.
*/
export const assertWorkspaceRootApproved = async (
deviceModel: DeviceModel,
deviceId: string,
workingDirectory: string,
): Promise<void> => {
if (!workingDirectory) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'A workspace root is required for file operations',
});
}
const device = await deviceModel.findByDeviceId(deviceId);
const approvedRoots = [
...(device?.workingDirs ?? []).map((dir) => dir.path),
...(device?.defaultCwd ? [device.defaultCwd] : []),
].filter((root): root is string => Boolean(root));
const approved = approvedRoots.some((root) => isPathWithinRoot(root, workingDirectory));
if (!approved) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Working directory is not an approved workspace for this device',
});
}
};
+11 -6
View File
@@ -183,6 +183,7 @@ export const documentRouter = router({
input.documentId,
editorData,
input.saveSource,
input.lockOwnerId,
);
}),
@@ -255,23 +256,27 @@ export const documentRouter = router({
acquireDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string() }))
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
return ctx.documentService.acquireDocumentLock(input.id);
return input.ownerId
? ctx.documentService.acquireDocumentLockWithOwner(input.id, input.ownerId)
: ctx.documentService.acquireDocumentLock(input.id);
}),
getDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string() }))
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
.query(async ({ ctx, input }) => {
return ctx.documentService.getDocumentLock(input.id);
return ctx.documentService.getDocumentLock(input.id, input.ownerId);
}),
releaseDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string() }))
.input(z.object({ id: z.string(), ownerId: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
await ctx.documentService.releaseDocumentLock(input.id);
if (input.ownerId)
await ctx.documentService.releaseDocumentLockWithOwner(input.id, input.ownerId);
else await ctx.documentService.releaseDocumentLock(input.id);
}),
updateDocument: documentProcedure

Some files were not shown because too many files have changed in this diff Show More