Compare commits

...

321 Commits

Author SHA1 Message Date
Arvin Xu f47e65d215 🐛 fix(server): rehydrate subagent runs from DB on cold replica (#15788)
* 🐛 fix(server): rehydrate subagent runs from DB on cold replica

Server-side hetero persistence kept per-operation state in a module-level
map. On a cold serverless replica (or any cross-replica batch), the main
agent state is rebuilt from DB but `MainAgentRunState.subagents` was seeded
empty. A continuing subagent event then hit the `!existing` branch of
`ensureRun` and forked a brand-new isolation thread for a parentToolCallId
that already had one — producing piles of generic "Subagent" threads that
were never attached to the right thread. Desktop never hit this (one
long-lived run-state closure).

Rebuild `state.main.subagents` from DB the same way the main half is
rehydrated: add `rehydrateSubagentRunsState` to @lobechat/heterogeneous-agents
and call a new `refreshSubagentRunsFromDb` each ingest. Only runs MISSING
from memory are rehydrated (warm accumulators win); finalized (Active)
threads are excluded so completed spawns are never resurrected.

Sibling of #15783 (main message chaining) — same root cause, subagent half.

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

* 🐛 fix(server): scope subagent rehydration to operation + de-dupe inner tools

Two follow-up fixes on the cold-replica subagent rehydration:

- P1: de-dupe inner tool creation against the run-lifetime tool set, not just
  the per-turn `persistedIds`. Per-turn state is reset on every turn boundary
  and starts empty after a rehydration, so a replayed / continued tools_calling
  on a cold replica minted a SECOND tool message for an id the run already
  wrote. `lifetimeToolCallIds` survives boundaries and is restored from DB, so
  it is the durable de-dupe key. Mirrors the main-agent retry protection.

- P2: scope `refreshSubagentRunsFromDb` to the current operation. Topics are
  reused across turns; a prior crashed/cancelled run can leave a subagent
  thread stuck `Processing`. Rehydrating purely by topic+status would merge
  that unrelated thread into the new operation's reducer state and finalize it
  on the new run's terminal drain. Stamp `operationId` on the subagent thread
  metadata at creation and filter rehydration by it.

Adds regression cases for both (each verified to fail without its fix).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:13:35 +08:00
Arvin Xu 6dcbd387f7 feat: support drag-to-reorder for desktop tabs (#15787)
*  feat: support drag-to-reorder for desktop tabs

Make the Electron titlebar tabs draggable horizontally to reorder them,
like Chrome tab dragging. Wires the existing `reorderTabs` store action
to a @dnd-kit sortable context.

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

* 🐛 fix: preserve scroll position when reordering background tabs

The active-tab auto-scroll effect depends on `tabs`, so reordering
retriggered it and jumped the viewport back to the active tab. Guard it
with a ref so it only scrolls when the active tab id actually changes.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:57:21 +08:00
Arvin Xu fa58fd12a0 🔨 chore(testing): automate local auth setup (#15790)
🧪 test(agent): automate local auth setup
2026-06-14 02:00:49 +08:00
Rdmclin2 913ee4210d feat: page/agent/agentGroup/task edit lock (#15786)
* feat: support page editor lock

Squashed page-lock feature work:
- support page editor lock
- support agent group / agent / task edit
- add edit lock to agent/agentgroup/task
- refactor page lock
- fix workspaceId for edit objects
- align with agent/group/task

* fix: collaborative edit lock

* chore: update i18n

* fix: redis acquire

* fix: release lock

* fix: test case

* chore: complement page lock test cases
2026-06-14 01:40:36 +08:00
Arvin Xu 99411041b9 feat(device): share remote-device gateway RPC between desktop and CLI (#15780)
*  feat(device): share remote-device gateway RPC between desktop and CLI

Extract the desktop's remote-device gateway RPC surface into a shared
`@lobechat/device-control` package and wire it into the CLI so `lh connect`
serves the same git / workspace / file device RPCs as the desktop app.

- local-file-shell: relocate all git operations (branches, working-tree
  patches, branch diff, checkout/rename/delete/pull/push/revert) from the
  desktop GitCtr into the shared package as pure functions
- device-control (new): the `executeDeviceRpc` dispatch + workspace scan +
  portable file-preview / file-index defaults, with platform hooks injected
- desktop: GitCtr / WorkspaceCtr / GatewayConnectionCtr become thin wrappers
  delegating to the shared package (local IPC path unchanged)
- cli: handle `rpc_request` over the gateway via the shared dispatcher

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

*  test(device): cover git branch ops and device-control portable defaults

- local-file-shell: real-git integration tests for branch checkout / rename /
  delete (+ validation), working-tree files & patches, revert, branch-diff with
  no remote, and push / pull / ahead-behind against a bare origin
- device-control: defaultGetLocalFilePreview (text / image / accept filter /
  workspace containment / missing file) and defaultGetProjectFileIndex (git
  ls-files path + glob fallback)

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

* 🐛 fix(device): preserve directory entries in the glob project-file index

The CLI `getProjectFileIndex` glob fallback used `globLocalFiles`, which returns
only non-hidden file paths and no directory entries — so the Files tree builder
flattened nested files to the root and dropped dot-directories.

Walk with fast-glob (`dot: true`) and synthesize directory entries via the same
`collectProjectDirectories` path the git branch uses, so nesting and dot-dirs
(e.g. `.agents`) render correctly. Extracted a shared `buildEntries` helper.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:56:53 +08:00
YuTengjing 39bce329fd 🐛 fix: surface model list fetch failures (#15753) 2026-06-13 23:05:44 +08:00
Arvin Xu 55a969a3c1 🐛 fix(server): stabilize heterogeneous main message chaining (#15783)
* ♻️ refactor(server): reduce main heterogeneous persistence

* 🐛 fix(server): anchor hetero turns to latest tool row
2026-06-13 22:13:45 +08:00
Arvin Xu f51dd06a36 🐛 fix(model-runtime): classify "Agent state not found" as StateStoreReadError (#15778)
`coordinator.loadAgentState(operationId)` returning null throws a raw
`Error("Agent state not found for operation …")`, which (after the refine fix)
otherwise lands as a bare 500. It is a state-store READ failure, so route it to
StateStoreReadError alongside the caller-gone abort.

Because losing an operation's state is a genuine system fault (not benign
client abandonment), promote StateStoreReadError to countAsFailure: true /
severity: error. `ERR caller gone` now counts too — accepted trade-off, both
are system-side read failures worth tracking.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:11:33 +08:00
Arvin Xu 24e34c7545 Revert "🐛 fix(agent-document): support image LiteXML in headless editor (#15764)"
This reverts commit 3f3f12dbd2.
2026-06-13 20:29:35 +08:00
Arvin Xu 81d40b90d4 ♻️ refactor(chat): unify client hetero executor on a shared mainAgentReducer (#15762)
*  feat(hetero): add shared mainAgentCoordinator reducer

Pure, transactional main-agent run reducer mirroring subagentCoordinator.
Owns the asst→tool→asst chain rule (lastToolMsgIdEver) as the single source
of truth so client and server can converge on one processing flow. Not yet
wired into either interpreter.

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

* ♻️ refactor(chat): drive client hetero executor via shared mainAgentReducer

Replace the renderer's hand-written main-agent event state machine with the
shared reduceMainAgent + an applyIntent interpreter (main + delegated subagent
intents). The executor keeps its shell (persistQueue/IPC ordering, optimistic
intervention UI, op usage-metrics tray, notifications, resume fallback) and
still forwards raw events to the gateway handler for live UI; durable DB writes
now flow through the reducer's intents, so the asst→tool→asst parent chain
(incl. the lastToolMsgIdEver toolless-step rescue) is a single shared source of
truth with the server.

Tool/assistant message ids are now pre-allocated by the reducer (matching the
subagent path); updated the executor tests to honor caller-provided ids and
assert against captured ids instead of mock-minted ones.

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

* 📝 docs(chat): clarify why main-scope streamContent intent is a no-op

It's intentional, not dead code: main live token UI is driven by the raw
stream_chunk forward to the gateway handler; the intent only drives the
subagent thread bucket (whose events are dropped before that forward).

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

* 🐛 fix(chat): close two hetero executor races from reducer refactor

Two review-found bugs introduced by moving main-agent state into the queued
reduceAndApplyMain:

1. retryWithoutResume's hasStreamedState() read mainState, which is now only
   updated inside the queued reduce — so a recoverable resume error landing
   after partial output was queued (but before the queue drained) could start a
   second run and duplicate/interleave messages. Restore the old synchronous
   guarantee with a `sawStreamedEvent` flag set the moment a stream_chunk /
   tool_result arrives, before queueing.

2. A transient createMessage failure on a step-boundary assistant was
   best-effort (logged, not rethrown), so reduceAndApplyMain still committed
   currentAssistantId to a row that was never created — every later
   content/tool/result write then targeted a missing assistant and was lost.
   Rethrow so the commit is skipped and currentAssistantId stays valid, mirroring
   the subagent createMessage path.

Both guarded by regression tests that fail without the fix.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:10:51 +08:00
Arvin Xu 9cde29fb14 💄 style(workflow): inset partial warning badge (#15773)
* 💄 style(workflow): inset partial warning badge

*  feat(portal): support preview for local markdown images

* 🐛 fix(portal): narrow markdown image src
2026-06-13 20:10:08 +08:00
Arvin Xu ebe8411e7e 💄 style: compact device guard alert (#15776) 2026-06-13 20:09:16 +08:00
Arvin Xu 381e87474c feat(device): add rename & delete actions to branch switcher (#15774)
Hover a branch row in the branch switcher to rename or delete it. Wires
new renameGitBranch / deleteGitBranch operations through both transports
(Electron IPC for the local machine, device.* TRPC RPCs for remote/web),
mirroring the existing checkoutGitBranch / revertGitFile stack.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:07:45 +08:00
Arvin Xu 09fd6f3411 💄 style(chat): carousel the OpStatusTray generating phrase every 4s (#15775)
The generating status phrase was picked once per operation and stayed
frozen for the whole run. Rotate it like a carousel — advancing to the
next phrase every 4s with a subtle fade — so a long-running task feels
alive instead of stuck on one line.

- add pickRotatingStatusPhrase: seed keeps the starting phrase stable
  per operation, step advances the carousel; reuses the existing 1s
  elapsed ticker so no extra timer is needed
- fade/slide the phrase on each switch via a keyed wrapper span (keeps
  the shiny-text shimmer animation intact)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:03:07 +08:00
Arvin Xu d9d9f44cb2 🐛 fix(model-runtime): classify untyped Error throws via message patterns (#15767)
* 🐛 fix(model-runtime): classify untyped Error throws via message patterns

`refineErrorCode` only re-derived a specific code when the incoming errorType
was `ProviderBizError`, so raw `Error` throws — which `formatErrorForState`
wraps as `InternalServerError` (HTTP 500) — never reached `matchErrorPattern`.
Persistence-layer (`Failed query: …`) and state-store drops therefore landed
as bare, un-classified 500s instead of `DatabasePersistError` etc.

Add the two un-typed fallback wrappers (`InternalServerError`, `AgentRuntimeError`)
to `REFINABLE_CODES` so their message runs through the pattern registry before
falling back. The existing `Failed query:` pattern already classifies these;
this just lets it run again.

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

* 🐛 fix(model-runtime): classify Upstash readonly-upgrade & dropped-caller drops

Add `READONLY Writes are temporarily rejected` and `ERR caller gone` to the
StateStorePersistError pattern block — both are Redis/Upstash state-store
failures that otherwise fall through to a bare 500. They describe the
connection/server condition rather than a specific command, so there is no
read-vs-write signal to split on.

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

* 🐛 fix(model-runtime): split caller-gone state-store reads into StateStoreReadError

`ERR caller gone` is an Upstash reply when an in-flight blocking READ
(XREAD on the agent event stream, BLPOP on a tool result) is aborted because
the originating caller disconnected — a benign client abandonment tied to the
request lifecycle, not a write/persist fault. Bucketing it under
StateStorePersistError mislabelled it as a harness failure (attribution:
harness, countAsFailure: true).

Add a dedicated StateStoreReadError (E7007, attribution: system, severity:
warning, countAsFailure: false) and route `ERR caller gone` to it. The
write-side rejection `READONLY Writes are temporarily rejected` stays under
StateStorePersistError.

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

* 🐛 fix(model-runtime): scope HTTP-status fallback to provider catch-alls

Opening the un-typed wrappers (InternalServerError / AgentRuntimeError) to the
full refine path also let them hit the leadingStatusFromMessage /
codeFromHttpStatus fallback. A harness/DB/Redis throw like `Error('429 …')` or
`Error('500 …')` with no registered pattern would then be recast as
RateLimitExceeded / ProviderServiceUnavailable — provider retry/failure
semantics on a harness error.

Split the sets: PATTERN_REFINABLE_CODES (message matching) stays open to the
wrappers; STATUS_REFINABLE_CODES (the coarse HTTP-status bucket) is limited to
ProviderBizError, where a leading status is a real upstream signal.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:16:43 +08:00
Arvin Xu 1244a40950 🐛 fix(chat): stop ContentLoading from leaking raw operation i18n keys (#15752)
Internal/bookkeeping operation types (createToolMessage, executeToolCall,
pluginApi, builtinTool*, callLLM, searchWorkflow, ...) have no `operation.*`
locale key, so ContentLoading fell back to rendering the raw key
(e.g. `operation.toolCalling...`).

Extract OpStatusTray's operation→activity mapping into a shared
`resolveOperationActivity` helper and reuse it in ContentLoading: mappable
ops show the localized `opStatusTray.status.*` phase label, container ops
keep their dedicated copy, and unmappable ones fall back to the dot loader.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:14:24 +08:00
Arvin Xu a48c2badd9 💄 style: improve shared Linear tool rendering (#15769) 2026-06-13 18:37:51 +08:00
Arvin Xu 3f3f12dbd2 🐛 fix(agent-document): support image LiteXML in headless editor (#15764) 2026-06-13 17:37:51 +08:00
Rylan Cai 99023811d8 📝 fix: clarify local system shell result wording (#15745)
* 🔥 remove local system listFiles exposure

* 📝 clarify local system shell result wording

* 📝 refine local system shell manifest copy

* 📝 simplify local system shell prompt semantics

* 🐛 fix command wait-window result wording

* 📝 limit transient device retry guidance

*  show command output duration

* 🏷️ narrow command duration result type

* 🐛 propagate operation id for device tool calls

* 🐛 update project skill discovery hint

* 📝 clarify project skill file access

* 📝 align project skill discovery comment
2026-06-13 16:34:10 +08:00
Arvin Xu 480a2979e1 🐛 fix(codex): parse retry time in stated timezone (#15758)
* 🐛 fix(codex): parse retry time in stated timezone

* 🐛 fix: enable remote git review panel

* 🐛 fix(codex): preserve adjacent retry meridiem
2026-06-13 16:32:35 +08:00
Arvin Xu 531900cf70 🐛 fix(desktop): detect bundled Codex CLI from Codex.app on macOS (#15759)
* 🐛 fix(desktop): detect bundled Codex CLI from Codex.app on macOS

OpenAI's Codex desktop app bundles the real codex CLI inside Codex.app
(Contents/Resources/codex) but never symlinks it onto PATH. A user with
only the desktop app installed failed PATH-based detection, so codex was
never spawned and the chat silently produced no reply.

Add a well-known install-location fallback inside detectHeterogeneousCliCommand
(tried after the PATH lookup, so a user's own install still wins), covering
both /Applications and ~/Applications. The fallback runs at detection time,
not module load, so it touches no node:os named exports on import. Feed the
detector-resolved absolute path through to spawn so a bare `codex` doesn't
ENOENT under spawn's leaner env.

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

* 🐛 fix(desktop): carry login-shell PATH into CLI spawn env

When the detector resolved a bare command via the login-shell PATH, only
the absolute shim path was kept; the PATH used for resolution was dropped.
spawn() then built its env from the leaner Finder-inherited PATH, so an
absolute shim with `#!/usr/bin/env node` still failed with
`env: node: No such file or directory` even though preflight succeeded
(npm/Homebrew/mise installs launched from Finder on macOS).

Surface the resolved PATH through ToolStatus.resolvedPathEnv, stash it on
the session, and merge it into spawnEnv (session.env still wins). Only set
when resolution fell back to the login-shell PATH, so the common on-PATH
case is unchanged.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 16:32:27 +08:00
Arvin Xu c9325794e5 🐛 fix(portal): close empty local file preview (#15760) 2026-06-13 16:31:56 +08:00
Innei 4a11ed9887 ♻️ refactor(auth): migrate auth pages to a standalone lightweight SPA (#15689)
*  feat(oidc): add interaction details endpoint

*  feat(auth-spa): scaffold standalone auth SPA shell and build pipeline

* 🐛 fix(auth-spa): address review findings in AuthShell copies

*  feat(auth-spa): add spa-auth html route handler

* ♻️ refactor(auth-spa): migrate simple auth pages into auth SPA

* 🔒 fix(auth-spa): validate locale segment in spa-auth route

* ♻️ refactor(auth-spa): move verify-im route to main SPA

* 🔒 fix(auth-spa): sanitize callbackUrl, fix signup form wiring, add router error element

* ♻️ refactor(auth-spa): migrate oauth pages into auth SPA

* 🐛 fix(auth-spa): address oauth migration review findings

* ♻️ refactor(auth): route auth pages to standalone SPA and drop Next auth tree

* 🔒 fix(auth): validate locale before middleware rewrite

* 🔥 chore(auth-spa): drop unused messenger i18n namespace from auth shell

* ️ perf(build): share one react vendor bundle across web/mobile/auth SPA builds

Build react core (react, react-dom, react-dom/client, react/jsx-runtime)
once as a self-contained ESM bundle under /_spa/vendor-shared, then mark
those specifiers external in every SPA build and map them via rolldown
output.paths to the same hashed URLs, so the auth page warms the main
app's react cache. react-router-dom stays per-build: apps use ~19K of it
after tree shaking while a shared bundle must export all 252K.

Also split auth i18n namespaces into per-locale chunks, keep locale
runtime helpers out of the default locale chunk, and group packages/const
into app-const so vendor-ai-runtime no longer captures it.

* ♻️ refactor(spa): extract shared SPA html serving helpers

Both the main SPA and auth SPA route handlers duplicated the Vite dev
asset rewriting, analytics config assembly and html template rendering.
Move them into src/server/spaHtml.ts; the desktop umami block becomes an
opt-in flag only the main SPA enables.

* 🐛 fix(auth-spa): bundle default locale resources and disable i18n suspense to fix signin mount loop

*  feat(auth-spa): wrap auth shell with BusinessAuthProvider slot

* 👷 build(spa): support custom vite dev origin and mark SPA entries side-effectful

* 🔥 chore: drop dead /welcome entry from nextjsOnlyRoutes

* 🐛 fix(auth-spa): forward referral to signup and fix error boundary dark-mode contrast

* ♻️ refactor(spa): lift NextThemeProvider above RouterProvider so route error boundaries are theme-aware

* update
2026-06-13 16:15:04 +08:00
Arvin Xu be7b759820 🛠️ chore(agent-testing): add local dev env bootstrap (#15757) 2026-06-13 13:54:13 +08:00
Arvin Xu fa76928f62 🐛 fix: fix Codex resumed usage reporting for heterogeneous agents (#15751)
🐛 fix(heterogeneous-agent): normalize codex resumed usage
2026-06-13 13:34:41 +08:00
Arvin Xu f6db1361ee feat(agent): show topic sidebar status indicators (#15739) 2026-06-13 13:32:56 +08:00
Arvin Xu 5d6eaf53f3 📝 docs(agent-testing): require inline visual evidence (#15750) 2026-06-13 12:28:56 +08:00
YuTengjing c4e4469083 🐛 fix: improve fallback trace error UI (#15746) 2026-06-13 12:18:56 +08:00
Arvin Xu 800b534741 🐛 fix(chat): track operation usage in status tray (#15736) 2026-06-13 11:55:39 +08:00
Arvin Xu 03b9d07d0b feat(topic): add selector topic actions (#15744) 2026-06-13 11:53:21 +08:00
Arvin Xu f60d1fe8dd 🐛 fix(codex): reuse Linear inspector for MCP calls (#15738)
* 🐛 fix(codex): reuse Linear inspector for MCP calls

* 🐛 fix(codex): gate generic Linear MCP labels
2026-06-13 11:46:16 +08:00
YuTengjing e5a27dc97c 🐛 fix: handle Kimi code thinking mode (#15725) 2026-06-13 11:21:25 +08:00
Arvin Xu c7e0c83174 ♻️ refactor(agent-runtime): clarify virtual sub-agent naming (#15737) 2026-06-13 11:10:14 +08:00
Arvin Xu ab958a0b98 🐛 fix(chat): compact operation metrics on narrow inputs (#15735)
* 🐛 fix: compact operation metrics on narrow inputs

* 📝 docs: improve agent testing report template
2026-06-13 02:28:38 +08:00
Arvin Xu 5362be4078 ♻️ refactor(agent): split virtual sub-agent entry (#15733) 2026-06-13 02:10:47 +08:00
Arvin Xu 6887930428 🐛 fix: resolve local markdown image assets (#15729)
* 🐛 fix: resolve local markdown image assets

* 🐛 fix: preserve UNC markdown asset paths

* 🔒️ fix: restrict markdown image previews to images

* ♻️ refactor: pass markdown image preview accept directly
2026-06-13 01:55:00 +08:00
Arvin Xu da94942d9c 🐛 fix(portal): scope local file tabs by working directory (#15732) 2026-06-13 01:54:44 +08:00
Arvin Xu a9141c8ade 🐛 fix(page): stabilize agent editor sync (#15730) 2026-06-13 01:36:38 +08:00
R3pl4c3r 8ab5ec5364 🐛 chore(workflow): fix Upstream Sync workflow running error (#15706)
fix(workflow): fix Upstream Sync workflow running error
2026-06-13 01:29:44 +08:00
Arvin Xu 222534dbe1 🐛 fix(agent): block recursive server sub-agents (#15731) 2026-06-13 01:24:41 +08:00
Neko f31c94490d ️ perf(app,database): derive topic activity from messages (#15726) 2026-06-13 00:57:45 +08:00
Rdmclin2 52eaf2702e 🐛 fix: workspace url sync (#15728)
* fix: workspace url sync

* chore: remove billing as personal
2026-06-13 00:15:48 +08:00
YuTengjing ce81ea44bf 🐛 fix: gate inbox unread count by login state (#15724) 2026-06-12 23:32:14 +08:00
Tsuki 29974d3ab9 🐛 fix(mobile): preserve authenticated legacy unregister cleanup (#15723)
Follow-up to #15719 addressing a Codex P2 review note.

After #15719, legacy v1.0.7 clients that only send `deviceId` were
silent-OKed unconditionally. But `publicProcedure` still receives
`ctx.userId` from `createLambdaContext` — and in the *active*
sign-out path (the user is still authenticated when logout fires)
that userId is valid. Skipping the delete in that case orphans the
existing `(userId, deviceId)` row, so `PushChannel.deliver` keeps
fanning notifications out to a signed-out device. Expo's
`DeviceNotRegistered` receipt only fires on uninstall, not on
logout, so the cron worker doesn't catch this either.

Fix: add a Path B fallback — when `ctx.userId` is available, run
the original `(userId, deviceId)` delete. Path A (expoToken pair)
still wins when present; Path C (silent OK) is now reserved for
the case the original PR was actually targeting: a v1.0.7 client
whose session is already gone, which is the source of the 401
storm.

Path matrix:
  expoToken present                  → Path A: precise delete by (expoToken, deviceId)
  no expoToken, ctx.userId present   → Path B: legacy (userId, deviceId) delete
  no expoToken, no session           → Path C: silent OK, cron cleans up

Tests added:
- legacy + valid session → falls back to (userId, deviceId)
- legacy + no session    → silent OK
- expoToken always takes precedence over userId fallback
2026-06-12 21:58:23 +08:00
Tsuki f4c431b028 🐛 fix(mobile): stop pushToken.unregister 401 storm (#15719)
Symptom: app.lobehub.com production logs show ~50+ TRPCError
UNAUTHORIZED traces per second on /trpc/mobile/pushToken.unregister,
starting from the v1.0.7 mobile release. Only `unregister` is hit
— `register` never appears in logs.

Root cause: the v1.0.7 client calls unregister *during* sign-out,
after the session is already invalid in practice (expired OIDC
token / cleared cookie). With authedProcedure gating, every logout
turns into a 401 that the client mistakes for an auth-expired
event and retries → a storm. Inside the client this also creates
a logout → 401 → authExpired.redirect → logout recursion.

Fix: change `unregister` to publicProcedure and authorize by the
(deviceId, expoToken) pair the client received at registration —
holding both is proof of ownership of that row, same trust model
as APNs/FCM unregister. Legacy v1.0.7 clients that only send
deviceId get a silent 200; the stale row is cleaned up by the
existing `process-push-receipts` worker via Expo's
DeviceNotRegistered receipts.

Returning 200 to those legacy calls also breaks the client-side
recursion at the source — the in-the-wild v1.0.7 fleet stops 401
flooding the moment this ships, before users update.

Tests:
- Router (mocked): expoToken path deletes by (expoToken, deviceId);
  no-expoToken path silently succeeds; unauthenticated caller
  succeeds; empty-string fields rejected.
- Model (integration): only the row matching both fields is
  removed; mismatched expoToken is preserved (defense against
  callers who only guess deviceId).

Fixes LOBE-10174
2026-06-12 21:47:19 +08:00
Innei 34fbd9ffd3 feat(document): coalesce autosave history versions into 10-minute windows (#15716)
*  feat(document): coalesce autosave history versions into 10-minute windows

*  feat(document): break autosave history window on new page load session
2026-06-12 20:55:28 +08:00
Arvin Xu 09b5e926bf feat(conversation): add op status tray above chat input (#14737)
*  feat(conversation): add op status tray above chat input

Show elapsed time, total tokens, and total cost while an AI-runtime
operation is running in the current conversation. Lives in the floating
overlay above the chat input alongside QueueTray and TodoProgress,
attaches flush to the input panel below.

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

* 🐛 fix(conversation): read top-level message.usage in op status tray

Token totals stayed at 0 during regular agent runs because the standard
agent path writes usage to `message.usage` (top-level) while the
heterogeneous executor writes `metadata.usage`. Read both. Also drop the
fragile createdAt window — assistant messages can be created before the
AI_RUNTIME op's startTime, which excluded otherwise-valid rows — and
aggregate across the whole conversation instead.

UI: a little more padding, a pulsing dot to mark the running state, a
tokens label, and a divider between tokens and cost.

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

*  feat(conversation): streaming phase, ping dot, and richer metrics in op status tray

- Left side now shows the current streaming phase (thinking / calling tools /
  searching / compressing / generating) derived from the most recent running
  sub-operation; server runtimes surface no sub-ops on the client and fall
  back to 'generating'.
- Pulse dot upgraded to an expanding ping ring animation.
- Zero-valued metrics are hidden entirely (no more '0 tokens / $0').
- Long-running tasks additionally surface turns and tool-call counts next to
  tokens and total cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 💄 style(conversation): polish op status tray display

* 💄 style(conversation): unify op status tray glyph to a single hue

The activity glyph mixed purple and cyan accents into the primary color;
all layers now derive from colorPrimary alone (opacity-only variation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 💄 style(conversation): strip glyph halo fill and drop-shadow

The halo's tinted fill plus the drop-shadow rendered as a muddy disc
behind the glyph (worst in light theme). Reduce to a breathing core dot
plus a single rotating dashed orbit, primary hue only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 💄 style(conversation): drop dollar prefix and code font in op status tray

The dollar icon already conveys currency, and the code font made the
numbers feel out of place next to the body text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

*  feat(conversation): show per-message cost next to the token chip

Renders usage.cost beside the token count in the assistant message
footer; hidden in credit mode (credits already express cost) and when
the value is zero/absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 💄 style(conversation): hide per-message cost below $0.20

Cheap messages don't need a cost callout — the chip only surfaces once
the cost is large enough to matter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(conversation): anchor reconnected op timer to real run start, surface steps

- Page-refresh reconnect recreated the gateway operation with
  startTime=Date.now(), resetting the tray timer to 00:00 mid-run.
  Anchor it to the assistant message's createdAt instead.
- Mirror the server's authoritative stepIndex onto op.metadata.stepCount
  at every step_start event, so the steps metric shows for real
  server-side runs (and survives reconnects).
- Drop the tool-call count metric from the tray.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

*  test(conversation): stub updateOperationMetadata in gateway event handler mock store

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 18:10:29 +08:00
Innei d3e8e7cb65 🐛 fix(locale): support eager dayjs locale modules (#15711) 2026-06-12 16:57:42 +08:00
Rdmclin2 60bed5782f chore: update i18n (#15712)
chore: update i18n files
2026-06-12 16:21:34 +08:00
Rdmclin2 35b6bc55b8 🐛 fix: workspace error (#15701)
feat: support workspace (page author, copyTo/transferTo, notifications, i18n & fixes)

Squashed 13 commits from fix/workspace-error for clean rebase onto main's submodule base.
2026-06-12 16:08:31 +08:00
Innei 365dd1ff64 ️ perf(build): remove sitemap generation to cut static export time (#15702)
* ️ perf(build): remove sitemap generation to cut static export time

The sitemap accounted for 772 of 827 prerendered pages, each fetching
marketplace data at build time. Static generation drops from 28.2s to
0.3s and total next build from ~59s to ~32s.

* Redirect legacy sitemap URLs to the landing site

* Redirect sitemap index to landing sitemap
2026-06-12 15:17:52 +08:00
Innei 7633c0e83f 🐛 fix(share): always serve desktop bundle for share routes (#15710) 2026-06-12 14:54:18 +08:00
LiJian 87b1f39c0f feat(skill): add delete/remove actions to settings/skill items (#15708)
*  feat: add delete/uninstall actions to settings/skill items

- LobehubSkillItem: show compact `...` dropdown in list mode for connected items with Disconnect action (revokes OAuth)
- KlavisSkillItem: show compact `...` dropdown in list mode for connected/pending servers with Remove action (true delete via removeKlavisServer)
- ConnectorDetail: add Delete button for custom (mcp) connectors; calls deleteConnector + notifies parent via onDelete
- SkillDetail / Page: thread onDelete callback so selecting null after deletion triggers auto-select of next item
- Locales: add tools.klavis.remove / removeConfirm.title / removeConfirm.desc in en-US, zh-CN, and default source

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

* fix(skill): gate Klavis remove by canEdit and clear selected after removal

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

* fix(skill): show dropdown for all Klavis/Lobehub items in list mode

Previously, the ... button was gated behind `server` (Klavis) and
`isConnected` (LobehubSkill), so disconnected/never-connected items
showed no actions. Remove those guards so the dropdown always renders
in list mode. handleRemove/handleDisconnect now skip the server call
when no server instance exists and instead clear the selected item.

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

* fix(skill): move delete/uninstall actions from list dropdown to detail panel

- Remove heavy ... dropdown from KlavisSkillItem / LobehubSkillItem list items
- Add danger Uninstall button to builtin-skill detail header (matches ConnectorDetail style)
- Add slim action bar with Uninstall to agent-skill detail panel
- All actions respect canEdit / canCreate permissions with confirmModal gating

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 12:38:22 +08:00
LiJian ca91d2d756 refactor: replace Segmented tabs with SearchBar in ProfileEditor; gate local-system injection (#15593)
* 🐛 fix: activator tool discovery for cloud-sandbox and local-system

- P0: Explicitly inject LocalSystemManifest when device gateway is configured
  (discoverable: isDesktop is always false on server, so it never enters
  the discovery loop. The explicit injection mirrors the canUseDevice guard.)

- P1: Skip CloudSandboxManifest when runtimeMode is not 'cloud'
  (resolveRuntimeMode unifies executionTarget='sandbox' and legacy
  chatConfig.runtimeEnv.runtimeMode paths, so agents with sandbox
  disabled correctly exclude the cloud-sandbox tool.)

Both fixes operate at the manifest-map build stage, consistently affecting
all downstream consumers (activator discovery, availableTools, etc.)

* 🐛 fix: remove cloud-sandbox manifest when runtime is not sandbox

The initial manifest seed via getEnabledPluginManifests includes
defaultToolIds (which contains lobe-cloud-sandbox), so the manifest
was already in toolManifestMap before the allowedBuiltinTools loop's
continue guard. This made lobe-cloud-sandbox activatable even when
sandbox was disabled.

Add a delete right after resolveRuntimeMode to cover both the
manifestMap seed and the allowedBuiltinTools loop in one place.

Co-authored-by: chatgpt-codex-connector[bot]

* ♻️ refactor: replace Segmented tabs with SearchBar in ProfileEditor tool dropdown

- PopoverContent: replace Segmented with SearchBar + internal client-side filtering (same pattern as ChatInput ActionBar)
- AgentTool: remove ~270 lines of duplicated installedTabItems useMemo; pass unified items
- AgentTool: add auto-cleanup for stale plugin identifiers in agent config
2026-06-12 11:18:44 +08:00
Arvin Xu 61586b9377 🐛 fix(agent): persist & deliver image attachments for device/sandbox hetero runs (#15685)
* 🐛 fix(agent): persist file attachments in hetero early-exit user message

The hetero-agent early exit in execAgent created the user message without
the `files` relation, so attachments sent from the SPA gateway path
(executionTarget=device / sandbox) were never linked via messagesFiles and
disappeared once the optimistic client message was replaced by the server
snapshot. Attach the deduped `fileIds` the same way sendMessageInServer
does on the local-mode path.

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

*  feat(agent): deliver image attachments to device/sandbox hetero runs

Persisting the messagesFiles relation fixed display, but the dispatched
CLI still never saw the image — local mode feeds the persisted imageList
into sendPrompt for vision, while the device/sandbox dispatch protocols
(agent_run_request / sandbox runner) only carried a text prompt.

- resolve attached images into signed URLs in the hetero early exit
  (metadata-only, non-fatal) and carry them through heteroParams
- add imageList to the agent_run_request wire type and dispatchAgentRun
  params (gateway client + server service)
- extract buildHeteroExecStdinPayload into @lobechat/heterogeneous-agents
  so the three dispatch sites (desktop spawnLhHeteroExec, lh connect
  daemon, server sandbox runner) build the same content-block payload:
  systemContext, prompt, then image blocks
- lh hetero exec already coerces image blocks via coerceJsonPrompt and
  normalizeImage (url → base64 for Claude Code, materialized path for
  Codex), so no CLI consumer changes are needed

openclaw/hermes (runHeteroTask) keep text-only prompts — their dispatch
goes through a separate one-shot tool protocol.

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

* ♻️ refactor(heterogeneous-agents): move exec stdin wire contract to a pure /protocol entry

The server sandbox runner imported `buildHeteroExecStdinPayload` through the
`/spawn` barrel, which (with no `sideEffects` hint) bundles the whole spawn
machinery into the Next.js server chunk. Its `process.cwd()`-rooted dynamic
fs calls then make Vercel's output file tracing glob the entire repo source
tree into every serverless function (+~69 MB each), pushing the 4 largest
functions past the 250 MB uncompressed limit and failing the deployment.

Split the dispatch wire contract (stdin payload builder + content-block
types) into a new pure, isomorphic `/protocol` export and point all three
dispatch sites (server sandbox runner, desktop main, `lh connect` daemon) at
it. `/spawn` re-exports the moved symbols so executor-side callers are
unaffected. Also declare `sideEffects: false` for the package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:02:51 +08:00
Arvin Xu eca449e4e2 feat(skills): agent-testing iteration after first real-world run (#15700)
* 📝 docs(skills): make agent-testing Step 0 an env-setup + auth checklist

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

*  feat(skills): agent-testing probes, GIF evidence, and report-language rule

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:52:25 +08:00
renovate[bot] 6c8976b641 Update dependency vitest to v3.2.6 [SECURITY] (#15698)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 23:34:38 +08:00
Arvin Xu 60d9d3c3c7 ♻️ refactor(skills): merge local-testing and cli-backend-testing into agent-testing (#15699)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:14:45 +08:00
Arvin Xu 2dd4cf7a1d fix(agentDocument): replace getDocuments with listDocuments in useFetchAgentDocuments to avoid over-fetching (#15301)
* fix(agentDocument): listDocuments returns templateId and derived fields

* fix(agentDocument): useFetchAgentDocuments use listDocuments instead of getDocuments

* fix(agentDocument): derive AgentDocumentItem from listDocuments return type

* fix(agentDocument): export AgentDocumentListItem type

* 🐛 fix(agentDocument): align list projections and consumers after rebase onto canary

- listDocumentsForTopic now returns the same projection as listDocuments
  (derived fields + templateId), so the tRPC union no longer collapses
  the inferred client type to the old 8-field shape
- add description/updatedAt to both projections for sidebar consumers
- AgentDocumentsGroup switches getDocuments -> listDocuments (it already
  shared the documentsList SWR key)
- makePendingDocument trimmed to the lean list item shape
- update useFetchAgentDocuments test to the listDocuments behavior

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(agentDocument): migrate agentDocumentSkills sync to slim listDocuments

The tool store's skill registry sync shared agentDocumentSWRKeys.documentsList
with the working sidebar and the new useFetchAgentDocuments hook, but still
fetched the full getDocuments payload. Sharing one SWR key across different
payload shapes made the cached result order-dependent: whichever consumer
mounted first decided whether the cache held the heavy full documents or the
slim list items. Migrate the skills sync to listDocuments, whose projection
covers every field mapDocsToSkills reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:41:24 +08:00
Arvin Xu 575ef1e8ee ♻️ refactor(agent): single-track device-tool injection via execution plan (#15683)
* ♻️ refactor(agent): single-track device-tool injection via execution plan

P3 follow-up to #15669 — downstream layers now consume the resolved
ExecutionPlan instead of re-deriving device capability:

- ExecutionPlan carries the effective `target`; persisted into
  state.metadata.executionPlan via createOperation
- call_llm executor gates buildStepToolDelta's activeDeviceId signal on
  the plan (none/sandbox can never re-inject local-system mid-run)
- AgentToolsEngine consumes the plan's target; redundant rule-level
  canUseDevice checks removed (physical manifest walls remain)
- builtin agent runtime config can now override agencyConfig
  (web-onboarding pins executionTarget=none)
- hetero desktop 'local' selection persists this desktop's deviceId so
  opening the agent from web dispatches to the same machine via gateway
- 'local' vs 'device' stay distinct user choices even for the same
  machine: gateway dispatch streams progress to all clients (mobile),
  IPC is faster but desktop-session-only — guarded by a regression test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(agent): enforce device access policy on hetero dispatch

resolveDeviceAccessPolicy now runs BEFORE the hetero early exit and feeds
canUseDevice into the hetero execution plan: a denied sender (external
bot user) degrades local/device-bound CLI hetero runs to the cloud
sandbox instead of dispatching to the owner's machine, and requestedDeviceId
cannot bypass the policy. Remote hetero agents (openclaw/hermes) are
device-only with no sandbox fallback, so denied senders are refused
outright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 💄 style(agent): fix interface field order in RuntimeSelectionContext

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:39:11 +08:00
YuTengjing ba6976c063 🐛 fix: pause input completion after errors (#15692) 2026-06-11 22:05:45 +08:00
Innei bfdfd3bca3 🐛 fix(desktop): adjust mac fullscreen titlebar spacing (#15693) 2026-06-11 22:02:48 +08:00
YuTengjing f6c23e3654 🐛 fix(agent-runtime): persist assistant reasoning to DB (#15690) 2026-06-11 21:05:23 +08:00
Arvin Xu 813d756b9c 🐛 fix(editor-canvas): re-check editor init state before subscribing (#15686)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:42:28 +08:00
renovate[bot] 671bc26e0d Update opentelemetry-js-contrib monorepo (#13582)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 20:41:48 +08:00
renovate[bot] 309c25cb44 Update dependency code-inspector-plugin to v1.3.6 (#14612)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 20:41:40 +08:00
Arvin Xu a810bf3dcd 🐛 fix(agent-runtime): always persist assistant reasoning to DB (#15687)
* 🐛 fix(agent-runtime): always persist assistant reasoning to DB

PR #13494 gated message reasoning persistence behind preserveThinking
(agent chatConfig + model extendParams / qwen|zhipu fallback). That gate
is only meant to control whether reasoning is replayed into the next LLM
payload — applying it to the DB write dropped thinking content for every
non-qwen/zhipu reasoning model in server-side agent mode: reasoning
streamed live via stream_end but vanished after refresh.

Restore unconditional reasoning persistence in messageModel.update and
keep the preserveThinking gate only for state.messages payload replay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 💄 style(i18n): localize callSubAgent tool labels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:41:29 +08:00
Arvin Xu 7d6be512b8 🐛 fix(model-runtime): align tool-calling fallback tests & surface missing tool call as error (#15691)
*  test(model-runtime): align tool-calling fallback tests with new return shape

#15680 changed generateObject's tool-calling fallback to return the parsed
schema object (same shape as the json_schema path) instead of an array of
tool calls, and reworked its error handling, but left the pre-existing
"tool calling fallback" block in index.test.ts asserting the old behavior,
breaking CI on canary:

- result is now the parsed object, not [{ name, arguments }]
- the no-tool-call path returns undefined via debug log without console.error
- the parse-failure path logs the single matched tool call, not the array

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(model-runtime): surface missing tool call in generateObject fallback as error

tool_choice forces the structured-output function, so a response without a
tool call means the provider misbehaved. #15680 routed this branch to a
debug-namespace log that is invisible in production, leaving callers with
an unexplained undefined. Log it via console.error with the response
message as context, matching the parse-failure branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:40:12 +08:00
LiJian 1130f7df32 feat(devices): add browser device pairing flow (#15678)
*  feat: add browser device pairing flow to /settings/devices

- Add "Via Browser" tab to ConnectDeviceModal with pairing code display and input
- Add "Register this browser as a device" callout card above DeviceList
- Support ?pair=<code> URL param to auto-open browser pairing modal with pre-filled code
- Improve DeviceList empty state with method cards (Desktop + CLI)
- Ship en-US and zh-CN i18n keys for all new browser/sync strings

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

* 🔨 fix(devices): fix lint warnings — import sort order and empty catch block

* fix(devices): add pair API route and invalidate device list cache

- Create /api/devices/pair POST handler that authenticates the user via
  Better Auth session, validates the code against the user's registered
  devices via DeviceModel.findByDeviceId, and returns JSON.
- Replace the setListKey/key-prop re-mount trick with
  lambdaQuery.useUtils().device.listDevices.invalidate() so the tRPC
  React Query cache is properly busted after a successful pair (fixes
  staleTime: 30s preventing the new device from appearing).

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

* ♻️ refactor(devices): drop browser pairing, fix modal close, redesign UI

- Remove the "Via Browser" pairing flow entirely: browser tab in
  ConnectDeviceModal, the "register this browser" callout card, the
  ?pair=<code> deep-link, and the /api/devices/pair stub route. Only the
  real Desktop and CLI connection methods remain.
- Fix the modal that couldn't be closed: @lobehub/ui Modal closes via
  onCancel (antd), not onClose — the X button was a no-op.
- Redesign the connect modal (segmented tabs, numbered steps, command
  blocks with copy, security footer) and the empty state (onboarding
  hero with Desktop/CLI options + capability cards).
- Clean up browser/sync i18n keys; add capabilities + footer keys for
  en-US and zh-CN.

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

* 💄 fix(devices): apply card radius — cssVar.borderRadius already has unit

The radius tokens (cssVar.borderRadius / borderRadiusLG) already include
their unit, so the trailing `px` produced `var(--…)px`, which browsers
drop — leaving the cards with sharp corners. Drop the `px` so the cards
pick up the same rounded radius as the appearance settings FormGroup.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 19:50:28 +08:00
Arvin Xu e20496e444 🐛 fix(codex): persist model metadata and file diffs (#15672)
* 🐛 fix(codex): persist model metadata

* 🐛 fix(codex): show file change diffs
2026-06-11 19:15:45 +08:00
Innei dbc8d76c8d feat(desktop): restore cloud desktop builds (#15666) 2026-06-11 19:14:26 +08:00
renovate[bot] ecfdac5395 Update dependency @opentelemetry/sdk-node to ^0.217.0 [SECURITY] (#14687)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 18:51:20 +08:00
YuTengjing 5f4bec347b 🐛 fix(model-runtime): improve DeepSeek structured output (#15680) 2026-06-11 16:57:57 +08:00
Arvin Xu 77e4d0492b ♻️ refactor(agent): resolve device routing via a single execution plan (#15669)
- add resolveExecutionPlan as THE device decision (none/sandbox never
  route to a device; offline bindings stay unrouted; single-online-device
  auto-activation only for device-capable targets)
- fix executionTarget=none being bypassed by single-device auto-activation
  (background runs executed device tools despite 无设备)
- stop exposing the remote-device proxy in none/sandbox sessions
- converge native execAgent, hetero dispatch fork and client
  selectRuntimeType onto the shared resolution
- drop the legacy per-platform chatConfig.runtimeEnv.runtimeMode fallback
  entirely (no migration: unset targets resolve to platform defaults)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:29:37 +08:00
Neko a60d11df48 🐛 fix(chat): preserve message order after tool results (#15657) 2026-06-11 16:18:18 +08:00
YuTengjing 14501ea69a 🐛 fix: keep model guard in provider grouping (#15681) 2026-06-11 15:35:15 +08:00
Arvin Xu b76992e581 feat(file-preview): support remote read-only local previews (#15673)
*  feat(file-preview): support remote read-only local previews

*  feat(local-file): identify tabs by context

* ♻️ refactor(file-preview): route previews through project file service

* 🐛 fix(desktop): clamp nav panel width

*  feat(file-preview): improve local preview controls

* 🐛 fix(file-preview): reload html after refresh completes
2026-06-11 15:10:25 +08:00
Arvin Xu 97e4e345d1 🔨 chore(codecov): update coverage grouping (#15650)
🔨 chore: update codecov coverage grouping
2026-06-11 14:40:06 +08:00
cokeSEE1 c609a60f0e 🔨 chore(ci): bump outdated action versions to latest (#15655)
- actions/checkout@v4 -> @v6 in issue-auto-comments.yml
  (last remaining @v4 usage; all other 48 uses are already @v6)
- actions/github-script@v7 -> @v8 in release-desktop-canary.yml
  (last remaining @v7 usage; all other 4 uses are already @v8)

Co-authored-by: 章岚 <zhanglan@datagrand.com>
2026-06-11 09:54:53 +08:00
renovate[bot] 06bf82f3e0 Update dependency node to v24.16.0 (#14621) 2026-06-11 09:24:21 +08:00
Zhijie He 3ccc23152c 💄 style: add sensenova-6.7-flash-lite & sensenova-u1-fastsupport (#15306) 2026-06-11 09:22:49 +08:00
Zhijie He 3a780a62f6 feat: add AntGroup (蚂蚁百灵) provider support (#13713) 2026-06-11 09:21:54 +08:00
Zhijie He e98ad7edca 💄 style: update models for Longcat, support api fetch model list (#15134) 2026-06-11 09:20:55 +08:00
Arvin Xu 686778fe51 feat(file-preview): render HTML files inline (#15671)
 feat(file-preview): render html files inline
2026-06-11 02:39:05 +08:00
Arvin Xu 914976a52f feat(model-bank): knowledgeCutoff batch 2, metadata skill & always-visible tab bar (#15663)
*  feat(model-bank): backfill knowledgeCutoff batch 2 and restore lost Anthropic values

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 📝 docs(skills): add model-bank-metadata skill for cutoff/family backfill

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(model-bank): Claude Fable 5 belongs to the claude-mythos family

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 💄 style(desktop): always surface the tab bar by creating a tab on first navigation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ♻️ refactor(model-bank): family is the product lineage (claude-opus/sonnet/haiku), not the brand

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(agent): backfill activeAgentId before paint on tab/route switches

Tab switches are plain route navigations, so leaving an agent page cleared
activeAgentId via a passive useUnmount and the next page re-set it in a
passive useEffect — the first painted frame always had no active id, flashing
a skeleton even when agentMap already cached the config. Move both the
backfill and the unmount clear to layout effects: removed-tree layout
cleanups run before new-tree layout effects in one commit, so the clear can
never wipe a freshly synced id and the id is in place before paint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

*  feat(agent): surface agent config fetch errors with a retry action

isAgentConfigLoading only knows "no data yet", so a failed fetch (e.g. a 401
that SWR deliberately does not retry, with no focus revalidation inside a
single Electron window) left the agent page on a skeleton forever — only a
manual reload recovered. Record per-agent fetch errors in
agentConfigErrorMap (set by onError, cleared on data / retry), expose
currentAgentConfigError / isAgentConfigError selectors, add a
retryAgentConfigFetch action that revalidates the agent's SWR entries, and
show an error alert with a retry button above the main chat input while the
config is still missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(ci): sync model metadata test expectations

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:29:17 +08:00
Arvin Xu fdd955404d feat(codex): add collab tool render (#15662)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:15:29 +08:00
LiJian 6d47c1d07e feat(connector): fold OAuth into the custom MCP (PluginDevModal) form (#15661)
*  feat(connector): support API key / custom header / OAuth auth in custom connector

Make the connector backend a full replacement for the legacy custom-MCP plugin form:

- connector create/update now accept bearer/apikey/header credentials (encrypted at rest);
  oauth2 stays callback-only
- map apikey → bearer auth and header → request headers in both the sync path
  (syncTools + callTool) and the agent-runtime manifest path
- pass custom HTTP headers through to the MCP client
- AddConnectorModal becomes a rich form: MCP type (HTTP/STDIO), auth type
  (None / API Key / Custom Headers / OAuth), reusing the plugin form inputs;
  OAuth keeps the existing popup authorize flow, others create + sync directly

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

* ♻️ refactor(connector): fold OAuth into the PluginDevModal MCP form

Pivot the custom-MCP entry to reuse the rich PluginDevModal / MCPManifestForm
instead of a bespoke connector modal, and add OAuth as an auth type inside it:

- MCPManifestForm: gated `enableOAuth` adds an "OAuth" auth type with
  Client ID / Secret (optional) + redirect-URI hint. Only the custom-connector
  entry enables it, so plain custom-plugin DevModal callers (editing plugins,
  agent tools, …) are unaffected.
- DevModal: opens the OAuth popup synchronously on the save click (browsers
  block window.open once an async boundary is crossed), validates, then hands
  the popup to onSave which navigates it to the authorize URL.
- New CustomConnectorModal wraps DevModal and persists every auth type onto the
  connector backend (none / bearer / custom headers → create + sync; OAuth →
  create with OIDC config + run the authorize popup).
- settings/skill entry now opens CustomConnectorModal; the standalone
  AddConnectorModal rich rewrite from the previous commit is reverted to the
  canary original (it is only referenced by the unused ConnectorList).
- i18n: dev.mcp.auth.oauth* keys (default + en-US + zh-CN).

Backend stays as in the prior commit (connector create/update accept
bearer/apikey/header credentials; sync + manifest paths apply them).

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

* 🐛 fix(connector): route the OAuth auth type through the authorize flow, not the token-less manifest test

Selecting OAuth and clicking "Test connection" called the plugin manifest test
(getStreamableMcpServerManifest), which connects with no token and 401s on any
OAuth-gated server (e.g. Linear MCP / DCR). For OAuth there is nothing to test
without authorizing first, so the button now becomes "Authorize & Connect" and
runs the connector OAuth flow (discovery + DCR + authorize popup), shared with
the footer save button via DevModal.runOAuthFlow.

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

* 🐛 fix(connector): make connector.create idempotent on (user, identifier)

Re-adding or re-authorizing a custom connector with an existing identifier hit
the user_connectors unique constraint and 500'd. Now an existing row is updated
(reset to disconnected, refreshed name/url/oidcConfig/credentials) and its id
reused, instead of inserting a duplicate.

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

* ♻️ refactor(skill-store): route Add Custom MCP through the connector modal, drop the Custom tab

- Skill Store "Add → Add Custom MCP Skill" now opens CustomConnectorModal
  (connector backend + OAuth), matching the settings/skill entry, instead of
  the legacy plugin DevModal (installCustomPlugin + togglePlugin).
- Remove the now-redundant "Custom" tab from the Skill Store (custom MCP lives
  in the connector list now): drop SkillStoreTab.Custom, its tab option,
  CustomList render, and the matching search branch.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:00:38 +08:00
renovate[bot] c65cf8c2a0 Update dependency @opentelemetry/auto-instrumentations-node to ^0.76.0 [SECURITY] (#14686)
Update dependency @opentelemetry/auto-instrumentations-node to ^0.75.0 [SECURITY]

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-11 00:09:31 +08:00
Arvin Xu 981c57d6f9 🐛 fix(codex): scope repeated tool results (#15659)
* 🐛 fix(codex): scope repeated tool results

* 💄 style(codex): refine local file link states
2026-06-10 23:22:56 +08:00
Arvin Xu 87eba86514 chore(model-bank): backfill knowledgeCutoff + family/generation data (#15642)
*  feat(model-bank): backfill knowledgeCutoff for OpenAI/Claude/Llama/Phi families (batch 1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

*  feat(model-bank): add family/generation fields with rule-derived data for chat models

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

*  feat(model-bank): add canonical knowledge-cutoff map with build-time backfill

Adds MODEL_KNOWLEDGE_CUTOFFS (canonical id → YYYY-MM, all values verified
against official provider docs) plus normalizeModelIdForCutoff, which reduces
provider-specific spellings (openrouter/bedrock prefixes, dated snapshots,
-thinking/-fast/-latest/-preview variants, claude dot-versions) to canonical
ids. buildDefaultModelList backfills knowledgeCutoff from the map when a model
card has no inline value, so all aggregator providers inherit cutoffs
automatically; inline values always win.

Covers Anthropic (incl. legacy 3.x), OpenAI, Google Gemini/Gemma, xAI Grok,
Meta Llama, Amazon Nova, and Cohere. DeepSeek/Qwen/GLM/Kimi/MiniMax/Mistral
publish no official cutoffs and are intentionally absent. Anthropic inline
PoC entries migrate into the map (single source of truth).

Cross-checked against the batch-1 inline backfill: 0 value mismatches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(model-bank): correct Claude Sonnet 4.6 cutoff

*  test(model-bank): sync metadata expectations

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:59:36 +08:00
Rdmclin2 09e6f02e45 🔨 chore: modify workspace sidebar (#15658)
* chore: change back to user style sidebar panel

* chore: optimize personal menu

* chore: update i18n files
2026-06-10 22:21:27 +08:00
Arvin Xu a2ea314cd8 feat(codex): refine Codex tool renders (#15651)
* 💄 style(codex): refine file change tool render

*  feat(codex): add web search tool render

*  feat(codex): add mcp tool render

*  feat(codex): improve tool command display

* 💄 style(files): refine explorer tree icons

*  test: fix local file link render props
2026-06-10 22:13:56 +08:00
Arvin Xu e2be720726 🐛 fix(agent-runtime): keep async sub-agent stream alive (#15646)
* 🐛 fix: keep async sub-agent stream alive

* 🐛 fix: preserve async tool resume parent chain
2026-06-10 22:12:22 +08:00
Arvin Xu 8b6905ec7e 💄 style(desktop): tighten tab close button right padding (#15636)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:12:02 +08:00
Arvin Xu e4830943cf 🔨 chore(model-bank): add knowledgeCutoff field to model cards (#15640)
*  feat(model-bank): add knowledgeCutoff field with Anthropic models as PoC

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

*  feat(model-bank): add family/generation fields to model card types

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:02:34 +08:00
Arvin Xu 5dfb6fc288 chore: clean [LOBE-XXX] code annotations (2026-06-10) (#15623)
chore: clean up [LOBE-XXX] code annotations (2026-06-10)

Remove LOBE-XXX markers from comments and URLs across 7 files:
- apps/cli/hetero.ts & hetero.test.ts: Remove LOBE-10157 markers, keep context
- apps/server/ModelRuntime: Remove LOBE-10056, keep PK migration note
- packages/database/rbac.ts: Remove LOBE-9193, keep API doc
- scripts/codemodWorkspaceNav.ts: Remove LOBE-9024 from description
- parse.ts & parse.test.ts: Replace LOBE-10141/LOBE-123 with generic IDs

Co-authored-by: lobehub-bot <lobehub-bot@users.noreply.github.com>
2026-06-10 19:59:54 +08:00
Arvin Xu 94ea3f6a34 🚀 release: 20260610 (#15647)
# 🚀 LobeHub Release (20260610)

**Release Date:** June 10, 2026  
**Since v2.2.2:** 131 merged PRs · 13 contributors

> This weekly release strengthens agent collaboration across cloud,
desktop, CLI, and workspace flows, with steadier runtime behavior and a
broader foundation for workspace-scoped data.

---

##  Highlights

- **Agent execution across devices** — Unifies per-device working
directories, project skill discovery, and sub-agent suspend/resume
behavior across server, QStash, and device RPC flows. (#15543, #15566,
#15481, #15620, #15591)
- **Connector and sandbox platform** — Expands connector permissions,
custom OAuth MCP connector onboarding, sandbox provider support, and
user-uploaded file sync into cloud sandbox runs. (#15463, #15546,
#15184, #15550)
- **Desktop and CLI reliability** — Fixes desktop cold-start,
auto-update, Windows build, CLI skill discovery, and `lh connect` agent
dispatch paths. (#15547, #15525, #15527, #15562, #15632, #15634)
- **Pages and sharing** — Refreshes topic sharing, improves Page Editor
layout behavior, and routes Page Agent tool execution through the
server-side editor path. (#15581, #15556, #15588, #15023, #15610)
- **Model availability and provider updates** — Adds user-scoped LobeHub
model availability, Claude Fable 5, Qwen thinking preservation, and
MiniMax M3 updates. (#15590, #15639, #13494, #15376)

---

## 🏗️ Core Product & Architecture

### Agent Runtime & Heterogeneous Agents

- Improves sub-agent lifecycle handling, including async suspend/resume,
queue-mode QStash resume delivery, and blocking nested sub-agent calls.
(#15481, #15620, #15575)
- Stabilizes heterogeneous agent ingestion and streaming with raw stream
dumps, per-turn usage, image forwarding on regenerate, and
duplicate-text fixes. (#15602, #15577, #15592, #15585)
- Adds execution-device and working-directory controls across device
RPC, legacy defaults, and remote-spawned Claude Code sessions. (#15543,
#15566, #15591, #15572)
- Improves runtime diagnostics and compatibility, including Gemini
multimodal output capture, abort stream semantics, and trace quality
analysis. (#15535, #13677, #15508)

---

## 📱 Platforms, Integrations & UX

### Connectors, Sandbox & Tools

- Ships API-level connector tool permissions, custom OAuth MCP connector
onboarding, and connector-first runtime execution. (#15463, #15546)
- Adds sandbox provider support, cloud sandbox file sync, and safer
external URL file input handling with SSRF validation. (#15184, #15550,
#12657)
- Improves tool visibility and execution with pinned app-fixed tools,
ANSI output rendering, gateway-tunneled MCP calls, and automatic
headless tool runs. (#15509, #15516, #15469, #15492)

### Desktop, CLI & Web UX

- Restores desktop startup and reload behavior, preserves IPC error
causes, and keeps the tab bar new-tab action visible across routes.
(#15547, #15597, #15638)
- Fixes desktop update and build stability for browser quit guards,
macOS update signing, and Windows Visual Studio detection. (#15525,
#15527, #15562)
- Shows the plan-limit upgrade UI on desktop builds. (#15628)
- Adds the Agent Run delivery checker and fixes CLI device dispatch plus
skill list/search output. (#15489, #15634, #15632)
- Refreshes onboarding, auth source preservation, topic UI states,
referral/Fable campaign copy, and chat-input control bar behavior.
(#15629, #15544, #15573, #15614, #15616, #15617, #15622, #15643)

---

## 🔒 Security, Reliability & Rollout Notes

- External URL file input now includes SSRF validation for safer Google
file handling. (#12657)
- Database workspace-scope migrations are part of this release;
self-hosted operators should run the normal migration path before
serving the updated app. (#15446, #15465, #15468, #15472)
- The release branch was re-cut from `canary` and includes the latest
`main` release-version commit so `v2.2.2` is the verified compare base.

---

## 👥 Contributors

@ONLY-yours, @sxjeru, @hardy-one, @xujingli, @hezhijie0327, @Coooolfan,
@arvinxx, @tjx666, @Innei, @rivertwilight, @rdmclin2, @cy948,
@AmAzing129

**Full Changelog**:
https://github.com/lobehub/lobehub/compare/v2.2.2...release/weekly-20260610-recut-3
2026-06-10 19:35:47 +08:00
YuTengjing b8339abc76 🐛 fix: show plan limit upgrade UI on desktop builds (#15628) 2026-06-10 18:19:25 +08:00
Innei c037609b8b 💄 style(chat-input): fix control bar height jump when TokenTag appears (#15643) 2026-06-10 17:43:13 +08:00
René Wang b8b37cffa3 feat: refresh topic sharing experience (share page + popover) (#15581) 2026-06-10 17:43:02 +08:00
Rdmclin2 e8e4b2e822 feat: support workspace lobehub (#13977)
feat: support workspace (full) — store→business-hook + workspace router
2026-06-10 17:34:12 +08:00
Arvin Xu c02e5720c2 feat(model-bank): add claude-fable-5 to Anthropic models (#15639)
*  feat(model-bank): add claude-fable-5 to Anthropic models

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(agent): allow adding directory topics on web when agent targets a bound device

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:57:57 +08:00
Arvin Xu 3fb732da66 💄 style(desktop): keep tab bar new-tab button visible on every route (#15638)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:01:38 +08:00
Arvin Xu fdb529d598 🐛 fix(agent): deliver sub-agent resume bridge via QStash webhook in queue mode (#15620)
* 🐛 fix(agent): deliver sub-agent resume bridge via QStash webhook in queue mode

The callSubAgent completion bridge was a handler-only hook, which lives in
process memory: in queue mode (AGENT_RUNTIME_MODE=queue) HookDispatcher only
delivers webhook-configured hooks, so the bridge never fired — the parent op
stayed parked in waiting_for_async_tool forever after all sub-agents finished.

- Give the bridge hook a webhook config (delivery: qstash) targeting the new
  /api/agent/webhooks/subagent-callback endpoint; local mode keeps the
  in-process handler. Both paths converge on
  AgentRuntimeService.completeSubAgentBridge (backfill + barrier/CAS resume).
- Park-time self-check: after the parked state and operation row are
  persisted, re-run the resume barrier once to recover children that
  completed before the parent finished parking.
- One-shot verify watchdog: when a completion finds the parent not yet
  resumable, schedule a delayed verifyAsyncToolBarrier re-check (no step
  lock, CAS-idempotent, never re-arms).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 📝 docs(agent): correct verify-watchdog rationale comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 📝 docs(agent): clarify eventFields trimming rationale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ♻️ refactor(agent): align subagent-callback with workspace-scoped step worker

Post-rebase adaptation to canary's runtime restructure (#15609):

- Route the webhook bridge through AiAgentService (like the /run step
  worker) so the runtime's models stay workspace-scoped — a bare
  AgentRuntimeService would be personal-scoped and the tool-message
  backfill / resume barrier could miss workspace-scoped rows.
- Extract SubAgentBridgeParams into agentRuntime/types and add the
  completeSubAgentBridge passthrough next to executeStep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 🐛 fix(agent): fail sub-agent callback loudly on backfill or delivery failure

Address two review findings on the resume bridge:

- completeSubAgentBridge now checks updateToolMessage's { success } result
  (it swallows transaction errors instead of throwing) and propagates all
  infrastructure failures. The webhook endpoint then returns non-2xx so
  QStash redelivers the whole bridge — previously a failed backfill was
  acked with 200 and the parent stayed parked forever, since the verify
  recheck only re-reads the barrier and cannot retry the backfill.
- New AgentHookWebhook.fallback: 'none' opts a qstash-delivered hook out of
  the unsigned plain-fetch fallback, which can never authenticate against a
  QStash-signed endpoint and only masked publish failures as silently
  dropped 401s. The bridge hook uses it; dispatch escalates such delivery
  failures to console.error instead of the debug namespace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:00:17 +08:00
Arvin Xu 4c5c8795ef 🐛 fix(model-runtime): emit stop:abort instead of error when stream is aborted (#13677)
* 🐛 fix(model-runtime): emit stop:abort instead of error when stream request is aborted

When user cancels a streaming request, the provider SDK throws abort errors
(e.g. "Request was aborted"). Previously these were propagated as error chunks,
causing the client to display a provider error message. Now abort errors emit
a stop:abort event through the SSE pipeline, allowing the client to handle
cancellation gracefully.

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

* 🐛 fix(model-runtime): fix type error in abort pipeline test

Use `as const` for type literal to satisfy StreamProtocolChunk union type.

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

*  test(fetch-sse): add planUpgradeAfterFinish to onFinish expectations

#15616 added planUpgradeAfterFinish to the onFinish context but missed
updating fetchSSE.test.ts, breaking 13 tests on canary.

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

* 🐛 fix(model-runtime): harden abort detection against non-Error throws

isAbortError assumed error.message is always a string, but catch
clauses receive unknown — a non-Error throw (string, object without
message) would make the abort check itself throw inside the stream
error handler, swallowing both ABORT_CHUNK and the first-chunk error.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-10 15:56:39 +08:00
YuTengjing 8b342c600f feat: land new signups directly on onboarding (#15629) 2026-06-10 15:31:32 +08:00
LiJian 723c4d6daa 🐛 fix(cli): handle agent_run_request in lh connect so device dispatch doesn't time out (#15634)
* 🐛 fix(cli): handle agent_run_request in `lh connect` so device dispatch doesn't time out

`lh connect` auto-registers the CLI as a device, so the gateway can pick it
as the dispatch target for a heterogeneous agent run (`agent_run_request`).
But the connect daemon only listened for `system_info_request` and
`tool_call_request` — it never handled `agent_run_request`, so it never sent
`agent_run_ack`. The gateway waited out its ack window and returned
`{error:'TIMEOUT',success:false}`, surfaced server-side as "Hetero agent
device dispatch failed".

Add an `agent_run_request` handler mirroring the desktop app: spawn
`lh hetero exec` fire-and-forget and ack `accepted` immediately. The spawned
process owns the full execution + server-ingest pipeline. It re-invokes the
current CLI entry (process.execPath + argv[1]) rather than relying on `lh`
being on PATH, so it works inside the detached daemon.

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

* fix: bump the cli version

* chore: bump the cli manifest

* 🐛 fix(cli): ack agent run only after spawn succeeds, reject on spawn error

`child_process.spawn` reports a missing/inaccessible cwd asynchronously via
the child's `error` event, after the handler had already sent an `accepted`
ack. The gateway/server then recorded dispatch success while no `lh hetero
exec` process existed to emit `heteroFinish`, leaving the assistant message
stuck instead of surfacing a failure.

`spawnHeteroAgentRun` now resolves on the child's outcome: `accepted` on the
`spawn` event (stdin is written only then), `rejected` on an early `error`. A
rejected ack returns the gateway 422 → execAgent writes a ServerAgentRuntimeError
onto the assistant message, so a failed dispatch is visible. Still resolves in
milliseconds, well within the gateway's 10s ack window.

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-10 15:19:01 +08:00
LiJian 5b02563659 🐛 fix(cli): skill list/search commands returning empty results (#15632)
🐛 fix: skill list/search commands returning empty results

tRPC endpoints return { data, total } but CLI was treating the result as
an array; switch to result?.data ?? [] and update mocks to match.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:20:35 +08:00
YuTengjing a5f16c1184 🐛 fix: import button from ui root (#15599) 2026-06-10 14:19:04 +08:00
YuTengjing 7641cda958 💄 style: update i18n locales (#15630) 2026-06-10 14:02:02 +08:00
Arvin Xu 9ef76475c2 💄 style: add fable promo locale keys for plans page (#15622) 2026-06-10 07:59:15 +08:00
YuTengjing 1ed93b6a24 🐛 fix: type fable starter config (#15618) 2026-06-10 06:05:49 +08:00
Arvin Xu 004027ffdd 💄 style: update free credit badge copy and add cta/dismiss keys (#15617)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:05:28 +08:00
Arvin Xu 0434953053 chore: add home free credit badge business slot (#15615)
 feat: add home free credit badge business slot

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 05:47:37 +08:00
YuTengjing 4b7ef28e46 🐛 fix: support fable campaign UI (#15616) 2026-06-10 05:46:31 +08:00
Arvin Xu 437b4c8968 💄 style: update referral copy for pay-to-unlock reward (#15614)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 05:14:49 +08:00
Arvin Xu fdb4f37053 ♻️ refactor(hetero-agent): shared subagent-run coordinator + fix device-mode subagent streaming (#15613)
* ♻️ refactor(hetero-agent): shared subagent-run coordinator + fix device-mode subagent streaming

Remote-device (gateway) hetero runs corrupted SubAgent text on the wire: the
CLI `SerialServerIngester`'s main-agent text-snapshot coalescing was subagent-
unaware, so subagent full-block text got mixed into the main accumulator and
re-`append`ed as `replace` snapshots server-side. Fix: exclude `data.subagent`
text from the coalescer so it forwards raw (the server appends it once).

The deeper cause was duplication: the renderer executor and the server
persistence handler each hand-wrote the SAME subagent-run state machine (lazy
thread create, turn-boundary cut, finalize, orphan drain, chain parenting) —
the epicenter of past hetero subagent bugs. Extract it into ONE pure,
transactional reducer (`reduceSubagentRuns`) in `@lobechat/heterogeneous-agents`
that emits declarative intents; each engine keeps a thin interpreter for its
own I/O (renderer: messageService + live store dispatch; server: messageModel).

The reducer pre-allocates ids so intents carry parentId chains with no
create→backfill round-trip; this needs `messageService.createMessage` to accept
a caller id (threaded through; the model already supported it). Also widened the
message nanoid 14→18 for the higher per-run id volume.

Behavior unifications (vs the two old copies):
- transactional commit-on-success subsumes the renderer's `pendingFlushTarget`
  (a failed flush leaves the run intact for the onComplete-drain retry; the
  renderer keeps a local pending-flush map pinned to the original assistant).
- finalize DELETES the run (server-style); a second finalize / orphan drain is
  a clean no-op with the same DB end-state.

Scoped to subagent runs only; main-agent persistence stays per-engine. A future
pass can absorb the main-agent path into a unified agent-event reducer.

Tests: reducer 13, CLI hetero 22, server hetero 84, renderer executor 58.

Refs: LOBE-10175

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

*  test(hetero-agent): strengthen subagent flush-retry assertion

The earlier rewrite of this assertion (caused by ids moving from server-
generated to caller-pre-allocated) weakened it to "all streamed writes share
one id", which would also pass if they all wrongly hit the terminal row. Pin it
back to the test's real intent: resolve the FIRST streaming-turn assistant by
its create payload and assert every streamed write targets it AND that it
differs from the terminal assistant's id — so `resultContent` is never clobbered.

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

* 🐛 fix(hetero-agent): honor commit-on-success for renderer subagent intents + fix stale id-length tests

- renderer interpreter: createThread / createMessage failures now rethrow so
  reduceAndApplySubagent skips the state commit — the next event retries the
  lazy create / turn boundary instead of orphaning the run (review P2)
- catch around the intent loop so a failed intent can't poison persistQueue
- regression test: transient createThread failure retries on next event
- update message id length assertions 18 → 22 (nanoid widened 14→18 + msg_)
- update messageService.createMessage spy assertions for the new (params, id) call

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 05:09:43 +08:00
Arvin Xu 1260756246 feat(agent): block nested sub-agent calls (#15575)
*  feat(agent): block nested sub-agent calls

Sub-agents must not recursively spawn further sub-agents. Plumb an
`isSubAgent` flag from the spawning thread through the conversation /
operation / tool-call metadata, and refuse nested dispatch at every layer:

- streamingExecutor marks the spawned sub-agent context with `isSubAgent`
- aiAgent strips the LobeAgent tool from a sub-agent's plugin config
- client builtin-tool executor + server tool runtime return a clear error
- RuntimeExecutors blocks both single and batch sub-agent dispatch

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

* 🐛 fix(test): align execSubAgentTask expectation with isSubAgent appContext

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

* 🐛 fix(agent): don't mark group sub-agent tasks as isSubAgent

Group sub-agents are real agent dispatches and must keep the ability to
spawn their own sub-agents; only the LobeAgent-tool virtual sub-agent
path should carry isSubAgent. Drop the flag from execSubAgentTask.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 04:00:23 +08:00
YuTengjing cb769534d3 ♻️ refactor: parse Claude model ids for runtime checks (#15601) 2026-06-10 02:55:34 +08:00
Arvin Xu de1a5c88e4 test(database): cover more model/repository gaps (client-db 95.4%→95.7%) (#15612)
Extend tests toward full coverage of PGlite-reachable code:
- agentEval/runTopic (batchMarkAborted, deleteByRunAndTestCase) → 100%
- agentEval/run (benchmarkId filter branch) → 100%
- verifyCheckResult (createMany empty, findById, update, backfillTracingId) → 100%
- asyncTask, document, systemBotProvider, dataImporter — additional branches

Remaining client-db gaps are BM25/pg_search paths (run only in server-db/CI)
and real-Postgres-error / defensive fallbacks not reachable under PGlite.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 02:48:58 +08:00
Arvin Xu 5b4b50e050 🐛 fix(page-agent): inject active documentId into context on send (#15610)
* 🐛 fix(page-agent): inject active documentId into context on send

Page-scoped conversations never carried the open document id to the
agent runtime. At send time `operationContext` only had agentId/scope/
topicId, so the gateway's `appContext.documentId` was undefined and the
server-side PageAgent runtime threw "received a tool call without
documentId in context".

Inject the live document id from the page editor runtime
(`pageAgentRuntime.getCurrentDocId()`) into `operationContext` when
scope is `page`, so it flows through `execAgentTask` → server
`state.metadata.documentId` → tool execution context.

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

* 🐛 fix(page-agent): pass new document id explicitly in sendAsWrite to avoid stale injection

The page-scoped documentId fallback reads the page editor runtime
singleton, which is only authoritative once the active page's editor has
mounted. `sendAsWrite` creates a document, navigates, and sends
immediately — before the new editor mounts — so the singleton may still
be bound to the previously open page, scoping server-side PageAgent
tools to the wrong document.

Thread the freshly created `newDoc.id` through the conversation context;
the existing `!context.documentId` guard then skips the singleton
fallback entirely. Document the constraint at the fallback site.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 02:30:33 +08:00
YuTengjing 1d619ad507 feat: add user-scoped LobeHub model availability (#15590) 2026-06-10 02:19:14 +08:00
Arvin Xu 3ce3b5388f test(database): raise model/repository coverage to 95%+ and document DB test conventions (#15611)
*  test(database): raise model/repository coverage to 95%+ and document DB test conventions

Raise @lobechat/database client-db coverage 89.11% -> 95.36%:
- New integration tests for connector, connectorTool, workspaceMember (were 0%)
- Extend task, workspace, rbac, notification, userMemory/query, file,
  agentSignal/reviewContext, verifyRubric, brief, taskTopic, dataImporter,
  messengerAccountLink, home

Fix client-db (PGlite) test failures: BM25 search lacks the pg_search
extension under PGlite, so wrap session.queryByKeyword and home.searchAgents
in describe.skipIf(!isServerDB), matching the existing convention.

Document DB model/repository testing conventions so new models ship with tests:
- Rewrite testing skill's db-model-test.md (getTestDB integration pattern,
  client-vs-server-db split, BM25 skipIf guard, schema gotchas, user isolation)
- Surface the rule in testing/SKILL.md, cross-link from drizzle/SKILL.md,
  review-checklist/SKILL.md, and models/_template.ts

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

*  test(database): make verifyRubric/brief ordering tests deterministic

These models order by `updatedAt`/`createdAt` desc with no id tiebreaker, and
the tests created rows back-to-back relying on default `now()` — when two rows
land in the same millisecond the order is non-deterministic, causing flaky CI
failures. Set explicit, well-separated timestamps instead.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:42:08 +08:00
Innei 991c2f79e8 🐛 fix(desktop): trace Session Expired cause and resume onboarding at Login (#15604)
- Carry a `reason` payload on the `authorizationRequired` IPC event so the
  cause behind the Session Expired modal (proxy 401, refresh non-retryable,
  startup proactive refresh exception, etc.) lands in `electron-log` and the
  renderer debug namespace for postmortem.
- On 401 + `X-Auth-Required`, enrich the reason with `hadToken`, the upstream
  `www-authenticate` header and a truncated body snippet so OAuth/tRPC error
  details are captured without consuming the forwarded stream.
- Fix returning users (token refresh failed -> active=false -> relaunch)
  landing on the Welcome screen of desktop onboarding. Persist an
  `everCompleted` flag in localStorage and resume at the Login screen for
  anyone who has already completed onboarding once.
- Extract the screen-resolution logic into a pure `resolveInitialScreen`
  helper with unit tests; cover the new storage flag and reason payload in
  AuthCtr / BackendProxy tests.
2026-06-10 01:06:00 +08:00
Arvin Xu c329696dc2 🐛 fix(hetero): chain step boundary off tool row when tools[] backfill is unseen (#15607)
* 🐛 fix(hetero): chain step boundary off tool row when tools[] backfill is unseen

On a warm replica that did not drain the prior step's `tools_calling` (or
before the assistant's `tools[]` JSONB has its `result_msg_id` backfilled),
the in-memory tool state is empty, so the step boundary falls back to the
previous assistant and forks the wire into two disconnected bubbles.

Fall back to the authoritative anchor — the `role:'tool'` rows themselves,
committed in Phase 2 independently of the JSONB mirror's Phase-3 backfill —
via a new `MessageModel.getLastChildToolMessageId`. Excludes subagent tool
rows (threadId set) so they never anchor the main-agent wire.

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

* 🐛 fix(hetero): write per-device cwd when adding topic from project group

The sidebar "+ new topic in this directory" action wrote the working
directory to the legacy per-agent slot (localAgentWorkingDirectoryMap),
which sits below agencyConfig.workingDirByDevice in the resolution
precedence. Once a directory had been picked via the ControlBar (which
writes workingDirByDevice), the "+" action was silently shadowed and the
new topic was created with the previously-picked directory instead.

Route the action through useCommitWorkingDirectory.commitAgentDefault so
it writes the same high-precedence per-device slot the picker uses,
keeping the two write paths from drifting again.

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

*  test(hetero): cover MessageModel.getLastChildToolMessageId

The fallback anchor query added in 599eea5bda had no DB-level test — the
persistence handler mocks it, so its real SQL was never exercised and
patch coverage dropped. Add direct PGlite tests covering all branches:
latest-tool ordering, no-tool → undefined (ignoring non-tool children),
subagent thread exclusion (threadId IS NULL), and ownership isolation.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:42:37 +08:00
Arvin Xu 4b5e001934 🐛 fix(server): restore sub-agent forking in QStash step worker (#15609)
* 🐛 fix(server): restore sub-agent forking in QStash step worker

In QStash mode every agent step runs in a fresh HTTP request via the
hono `runStep` handler, which built a bare AgentRuntimeService without
the `execSubAgent` fork callback. As a result `lobe-agent.callSubAgent`
failed with SUB_AGENT_UNAVAILABLE in cloud (the in-process callback
never survives the queue boundary).

Step through AiAgentService.executeStep instead, reusing its internal
runtime that is already wired with the fork callback — no second runtime,
no manual rebinding.

Also rename the internal `execSubAgentTask` → `execSubAgent` (method,
runtime/tool context fields, options, ExecSubAgent{Params,Result} types)
to separate the "task" concept from "sub-agent", and make the method an
auto-bound arrow field so it no longer needs `.bind(this)`. The external
lambda procedure name (`execSubAgentTask`) and the client service are
left unchanged.

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

* ♻️ refactor(server): group runtime upward-calls into an AgentRuntimeDelegate

`execSubAgent` was a loose top-level option on AgentRuntimeService, which
hid that it is not ordinary config but an upward call: the low-level
runtime, mid-step, triggering a high-level pipeline that lives in
AiAgentService (the layer above it).

Introduce `AgentRuntimeDelegate` as the single named home for these
upward-call capabilities, and inject it as `delegate: { execSubAgent }`.
The interface doc states the convention so future "runtime must trigger a
higher-layer pipeline" capabilities land in the same place instead of
sprawling as ad-hoc options.

Scope is deliberately the injection surface (options + service field +
AiAgentService wiring). The downstream executor/tool context keeps its
flat `execSubAgent` field — the tool runner wants the unpacked capability,
not the whole delegate.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:41:01 +08:00
Arvin Xu aa46864df6 ♻️ refactor(lobe-agent): remove callSubAgents in favor of parallel callSubAgent calls (#15608)
The lobe-agent manifest exposed `callSubAgents` (parallel multi-task
dispatch), but the server runtime only implemented `callSubAgent`. When an
agent run executed server-side and the model invoked `callSubAgents`, the
builtin executor threw "Builtin tool lobe-agent's callSubAgents is not
implemented".

The server already supports parallel sub-agents natively: a batch parks on
all deferred tools (`pendingToolsCalling`) and `tryResumeParentFromAsyncTool`
enforces a K=N barrier, resuming the parent only once every pending
tool_result is fulfilled. So emitting multiple `callSubAgent` calls in one
turn is equivalent to the old `callSubAgents` — making the plural API
redundant and the source of a server/client inconsistency.

Remove `callSubAgents` end to end (manifest, types, client executor,
Inspector/Render/Streaming components + registries, locale keys, display-name
map, dev fixture) and update the system prompt to guide the model to fan out
via multiple `callSubAgent` calls.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:59:20 +08:00
Arvin Xu af3f0ea171 🐛 fix(desktop): preserve Error cause across IPC so renderer sees real failure reason (#15597)
* 🐛 fix(desktop): preserve Error cause across IPC so renderer sees real failure reason

Electron's IPC error serialization carries an Error's message/stack/name plus
its enumerable own properties, but a standard `cause` (set via
`new Error(msg, { cause })`) is non-enumerable — so the real failure reason
(e.g. undici wrapping ENOTFOUND/ECONNREFUSED under a generic
`TypeError: fetch failed`) was dropped on the way to the renderer.

- IPC base: re-expose `cause` as an enumerable, clone-safe field in the central
  handler catch (nested Errors flattened to { name, message, code }) so every
  IPC method's error carries it.
- Heterogeneous agent executor: include `cause` in the ChatMessageError body so
  the surfaced error structure exposes the underlying reason alongside message.

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

* 🐛 fix(desktop): ferry IPC error cause via a serializable envelope

Making `cause` enumerable before rethrowing didn't actually reach the renderer:
Electron's `ipcRenderer.invoke` rebuilds a thrown handler error from its *string*
form (`Error invoking remote method '<channel>': <String(error)>`), so the
original error object — and any `cause` — never crosses the boundary.

Switch to an explicit serializable envelope:
- `~common/ipcError`: `toIpcErrorEnvelope` (clone-safe plain object, recursively
  captures name/message/stack/code/cause) + `isIpcErrorEnvelope` /
  `fromIpcErrorEnvelope` to rebuild a real Error.
- IPC base handler: return the envelope instead of throwing.
- preload `invoke`: detect the envelope and re-throw a rebuilt Error (with
  `cause`), preserving the "promise rejects on failure" contract.
- hetero executor: flatten the Error cause to a plain object for the
  DB-persisted `ChatMessageError.body`.

Adds unit tests for the envelope round-trip and the preload unwrap.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:58:50 +08:00
Arvin Xu 84a7b5c7c8 📝 docs(agents): replace device-gateway with server in apps tree (#15606) 2026-06-09 22:55:32 +08:00
Arvin Xu e01cadb779 feat(hetero): add --raw-dump to persist agent raw stream-json for debugging (#15602)
*  feat(hetero): add --raw-dump to persist agent raw stream-json for debugging

The remote-device path (`spawnLhHeteroExec`) leaves no local execution
record: `lh hetero exec` consumes the agent's stdout internally and only
POSTs adapted events to the server, so a misbehaving remote run can't be
inspected. The adapted/ingested view also can't distinguish a CC-side
empty `tool_result` from an adapter extraction bug.

Add `lh hetero exec --raw-dump <dir>`: spawnAgent gains an `onRawStdout`
tee that captures the child's untouched stdout BEFORE the adapter; the
CLI writes it (plus stderr + a meta.json) to
`<dir>/<timestamp>-<operationId>/`, one file pair per spawn attempt.
Fully best-effort — a dump failure never affects the run or exit code.

Wire the desktop device path to pass `--raw-dump` (gated by the existing
`shouldTraceCliOutput` toggle, into `resolveTraceRootDir`), so remote-device
CC runs now leave a raw stream on the device — the same toggle/location the
local trace path already uses. Reusable later for the server sandbox path.

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

* 🔖 chore(cli): bump version to 0.0.27

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:16:05 +08:00
Arvin Xu ce5833cb67 feat(file): persist image dimensions into file metadata (#15594)
*  feat(file): persist image dimensions into file metadata

Record intrinsic width/height for uploaded images so consumers can
reserve layout space (avoid CLS) without loading the file first.

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

*  test(file): assert persisted dimensions in upload createFile payload

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

* 🔖 chore(cli): bump version to 0.0.26 and regenerate man page

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

*  feat(file): record image aspect ratio alongside width/height

Compute intrinsic aspect ratio (width / height, rounded) at extraction
time and persist it into file metadata so consumers can group/reserve
layout by orientation without recomputing.

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-09 22:11:15 +08:00
Arvin Xu 5b534f45d1 ♻️ refactor(chat-input): rename RuntimeConfig→ControlBar, WorkingDirectoryBar→HeteroControlBar (#15545)
* ♻️ refactor(chat-input): rename RuntimeConfig to ControlBar

The bar below the chat input now composes mode switcher, execution
device + working directory, approval mode and context window — "runtime
config" no longer matches. Rename the directory, component, and the
showRuntimeConfig / runtimeConfigSlot props (→ showControlBar /
controlBarSlot) across all call sites. Reads as a sibling of ActionBar.

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

* ♻️ refactor(agent): rename WorkingDirectoryBar to HeteroControlBar

Make the heterogeneous chat-input bar a symmetric sibling of ControlBar:
both compose the shared WorkspaceControls, so naming should match. Rename
the file, component and displayName, and update the controlBarSlot usage.
2026-06-09 20:21:05 +08:00
Innei e692448346 🔨 chore(deps): pin @lobehub/editor to stable ^4.17.1 (#15600)
Switch from the pkg.pr.new preview snapshot back to the published 4.17.1 release.
2026-06-09 20:09:27 +08:00
Rylan Cai 3fe5b62cbe 🐛 fix: relax clear todo intervention (#15598)
🔒 Relax clear todo intervention
2026-06-09 19:55:20 +08:00
Arvin Xu 6c6c8698d3 🐛 fix(hetero): forward user images on regenerate so vision input isn't dropped (#15592)
* 🐛 fix(agent): resolve working directory by target device instead of legacy-only

The chat-input directory picker writes the selection to
`agencyConfig.workingDirByDevice[deviceId]`, but the send / regenerate /
streaming / placeholder paths resolved the agent working directory via
selectors that only read the legacy `localAgentWorkingDirectoryMap`. So a
freshly picked directory was silently dropped and execution fell back to a
default cwd (the app's own repo), losing the user's project and `--resume`.

Make both `getAgentWorkingDirectoryById` and `currentAgentWorkingDirectory`
device-aware: per-device choice > legacy > desktop/home, with the target
device resolved from a passed-in `currentDeviceId` (kept out of the selector
so hook callers stay reactive). Update all call sites to supply the device id.

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

* 🐛 fix(hetero): forward user images on regenerate so vision input isn't dropped

The hetero regenerate/resend path (`runHeterogeneousFromExistingMessage`)
only forwarded the text prompt to `executeHeterogeneousAgent`, never the
original user message's `imageList`. The send path reads imageList off the
persisted user message and passes it along; this path must too. Without it,
regenerating an image turn re-ran the CLI with no attachments (fully lost
when the session couldn't be resumed, e.g. cwd changed).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:50:55 +08:00
Arvin Xu cdbef3f72e 🐛 fix(agent): resolve working directory by target device instead of legacy-only (#15591)
The chat-input directory picker writes the selection to
`agencyConfig.workingDirByDevice[deviceId]`, but the send / regenerate /
streaming / placeholder paths resolved the agent working directory via
selectors that only read the legacy `localAgentWorkingDirectoryMap`. So a
freshly picked directory was silently dropped and execution fell back to a
default cwd (the app's own repo), losing the user's project and `--resume`.

Make both `getAgentWorkingDirectoryById` and `currentAgentWorkingDirectory`
device-aware: per-device choice > legacy > desktop/home, with the target
device resolved from a passed-in `currentDeviceId` (kept out of the selector
so hook callers stay reactive). Update all call sites to supply the device id.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:45:36 +08:00
YuTengjing 71030c6e21 ♻️ refactor(auth): remove email harmony plugin (#15589) 2026-06-09 19:18:56 +08:00
LiJian adf49db7c4 🐛 fix: activator tool discovery for cloud-sandbox and local-system (#15586)
* 🐛 fix: activator tool discovery for cloud-sandbox and local-system

- P0: Explicitly inject LocalSystemManifest when device gateway is configured
  (discoverable: isDesktop is always false on server, so it never enters
  the discovery loop. The explicit injection mirrors the canUseDevice guard.)

- P1: Skip CloudSandboxManifest when runtimeMode is not 'cloud'
  (resolveRuntimeMode unifies executionTarget='sandbox' and legacy
  chatConfig.runtimeEnv.runtimeMode paths, so agents with sandbox
  disabled correctly exclude the cloud-sandbox tool.)

Both fixes operate at the manifest-map build stage, consistently affecting
all downstream consumers (activator discovery, availableTools, etc.)

* 🐛 fix: remove cloud-sandbox manifest when runtime is not sandbox

The initial manifest seed via getEnabledPluginManifests includes
defaultToolIds (which contains lobe-cloud-sandbox), so the manifest
was already in toolManifestMap before the allowedBuiltinTools loop's
continue guard. This made lobe-cloud-sandbox activatable even when
sandbox was disabled.

Add a delete right after resolveRuntimeMode to cover both the
manifestMap seed and the allowedBuiltinTools loop in one place.

Co-authored-by: chatgpt-codex-connector[bot]

* 🐛 fix: gate local-system injection by runtimeMode === 'local'
2026-06-09 19:03:25 +08:00
Innei 69cefce3d9 🐛 fix(page-editor): align table bleed with controllers (#15588) 2026-06-09 19:02:47 +08:00
Arvin Xu b295265f25 🐛 fix(hetero): stop cross-message text duplication in server-ingest mode (#15585)
🐛 fix(hetero): reset per-message text accumulator at message boundaries

In server-ingest mode (remote-device CC and cloud sandbox both run
`lh hetero exec`), SerialServerIngester's `accumulatedText` spanned the
whole run and never reset across assistant-message boundaries. Combined
with `snapshotMode: 'replace'`, every later message's snapshot re-emitted
all prior messages' text verbatim, which the server persisted into the
new DB message — producing cross-message text duplication.

Reset `accumulatedText` on `stream_start` / `stream_end` (emitted by the
adapter's `openMainMessage`) after flushing the just-ended message's
snapshot, so each message snapshots only its own text.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:27:47 +08:00
Innei 1a4005c7b9 ♻️ refactor: extract server into apps/server + root namespaces into packages (#14949)
* ♻️ refactor(server-deps): extract envs/trpc/config/locales/business-server into packages

* ♻️ refactor: relocate src/server backend modules to apps/server package

Rebuilt on current canary: git mv the 8 server subtrees (services, routers,
modules, globalConfig, utils, runtimeConfig, workflows, featureFlags) into
@lobechat/server, with @/server/* dual-path alias, database vitest aliases,
and instrumentation import fixup.

* 📝 docs(skills): update src/server path refs to apps/server/src after relocation
2026-06-09 18:09:26 +08:00
sxjeru 64d3bdb978 💄 style: add preserve thinking feature for Qwen3.7 Max model (#13494)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-06-09 17:21:39 +08:00
Arvin Xu 434532ce36 🐛 fix(heterogeneous-agents): emit per-turn usage for batch-mode Claude Code (#15577)
* 🐛 fix(heterogeneous-agents): emit per-turn usage for batch-mode Claude Code

Device + sandbox runs spawn Claude Code via the `lh hetero exec` CLI in BATCH
mode (no `--include-partial-messages`), unlike the desktop driver which always
streams partial messages. In batch mode CC emits no `message_delta`, and the
adapter deliberately skipped usage on `assistant` events (assuming the stale
`message_start` echo that only exists in partial mode). The grand-total
`result_usage` is intentionally ignored to avoid double-counting, so batch runs
ended up persisting NO usage at all — the model tag showed no token count.

Track whether any `stream_event` was seen (partial mode); when none has been
(batch mode), emit per-turn usage from the `assistant` event as turn_metadata.
The assistant event's usage is authoritative in batch mode, not a stale echo.

This also fixes the model tag showing `claude-opus-4-8[1m]`: the `[1m]` 1M-context
beta marker only appears in the `system init` model field, while `assistant`
events report the canonical `claude-opus-4-8`. The new turn_metadata carries the
clean id, which supersedes the init-captured one (and matches the id ModelIcon /
pricing lookups expect).

Partial mode (desktop/local) is unchanged — `message_delta` still owns usage.

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

*  test(heterogeneous-agents): update batch-mode E2E for assistant usage

The multi-step E2E fixture has no `stream_event` records (batch mode) and 5
assistant events with `message.usage`, so the new batch-mode path now emits 5
turn_metadata events. Update the expectation from 0 — this validates the fix on
a realistic device/sandbox session: per-turn usage lands with the canonical
model id.

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

* 🐛 fix(heterogeneous-agents): stop leaking host Anthropic creds into spawned CLI

The local CLI spawn forwarded the entire `process.env` to `claude`, so a
developer with `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL`
exported in their shell had it inherited by the CLI — overriding its own
subscription login and surfacing as a baffling "Invalid API key" + non-zero
exit on every message.

Strip those three vars from the inherited env via `buildInheritedSpawnEnv`.
`session.env` is still spread last, so an agent that explicitly configures an
API key continues to win. Adds regression tests for both the strip and the
override.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:51:33 +08:00
YuTengjing 23120f26e4 💄 style: update referral backfill copy (#15583) 2026-06-09 16:40:35 +08:00
sxjeru 77dbe4b7b3 🔨 chore(google): Support External URL file input with SSRF validation to optimize transmission (#12657)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: yutengjing <ytj2713151713@gmail.com>
2026-06-09 16:13:54 +08:00
LiJian 1ccc86e589 🐛 fix(skill): consolidate add-skill button into header dropdown (#15578)
* 🐛 fix(skill): consolidate add-skill button into header dropdown

Move the standalone 'AddSkillButton' from SkillList sidebar into the
header '+' dropdown, providing a unified entry point for all add-skill
actions (import from URL/GitHub, upload zip, custom connector).
Replace legacy 'Add Custom MCP' with the new Connector flow.

* 🐛 fix(skill): fix lint - remove unused ChevronDown import, sort imports
2026-06-09 16:07:36 +08:00
Rdmclin2 ccb33fa48c feat: workspace backend service slice (#15560)
Backend-only slice of the workspace feature (server routers/services, database models with workspaceId threading, openapi middleware, business/server stubs, const/types). Excludes all UI (features/routes/store/hooks). Deploys dark behind the workspace feature flag.

Includes open-source stub fixes: workspaceCreds router stub, ChargeParams workspaceId, usage.ts null-coalesce, DBMessageItem.workspaceId.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:54:26 +08:00
YuTengjing 082481c35d 🔇 chore: silence noisy dev console logs (#15548) 2026-06-09 14:55:37 +08:00
Arvin Xu 441e0c5b7c 🐛 fix(heterogeneous-agents): refine execution target + topic sidebar attention grouping (#15574)
* 🐛 fix(heterogeneous-agents): hide "no device" execution target for hetero agents

Heterogeneous agents (Claude Code / Codex) bring their own toolchain and must
execute somewhere, so the 'none' (plain chat) execution target is invalid for
them. Hide the option in the device switcher and never resolve/display 'none'
for hetero agents — fall back to local (desktop) or sandbox (web) instead.

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

* 💄 style(topic): use colorText for titles and move "Needs attention" below favorites

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

* 💄 style(chat-input): improve runtime config bar layout on narrow screens

Keep chips on a single line (no per-character wrapping), truncate long
labels (working dir / branch / device name) with ellipsis, and let the
workspace cluster scroll horizontally instead of wrapping. On a narrow
bar the hetero "full access" badge collapses to its icon (hover tooltip
still explains it) via a container query.

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

*  feat(topic): show project directory under topic items in by-status mode

Surface each topic's working directory as a muted second line in the
by-status grouping, where rows otherwise carry no project context. Data
is already on the topic metadata, so no extra fetch.

- NavItem: add opt-in `description` slot (single-line layout unchanged)
- DirIcon: convert `renderDirIcon` function into a memo component, add
  `size` prop, rename file to PascalCase, migrate all call sites

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:18:18 +08:00
Arvin Xu 0a6b02ccb5 💄 style(topic): show error alert icon with tooltip on failed topics (#15573)
* 💄 style(topic): show error alert icon with tooltip on failed topics

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

*  feat(topic): merge attention-needing topics into one "Needs attention" group

Collapse the unread-completion, failed, and waitingForHuman states into a single
top "pending" status bucket (待处理 / Needs attention) so the sidebar surfaces
everything that needs the user's attention in one place.

- groupTopicsByStatus now buckets those three states into `pending`, taking a new
  `unreadTopicIds` set (unread completions are a client-only state).
- Server STATUS_SORT_RANK floats `failed` to the top alongside `waitingForHuman`
  so failed topics stay on the first page and don't drop out of the group.

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

* 💄 style(topic): pin the "Needs attention" group above favorites

The pending bucket already sorts above running, but the synthetic favorite group
was prepended ahead of it. Hoist pending to index 0 so attention-needing topics
sit at the very top of the sidebar, above both favorites and running.

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

* 🐛 fix(heterogeneous-agents): pin resolved cwd onto remote-CC new topics

Remote CC dispatched the run with the correct working directory (the
precedence chain falls back to the agent's per-device pick), but a
brand-new topic was created without `metadata.workingDirectory`, so the
sidebar grouped it under "No directory" / 无目录.

Unify the three drifting server-side cwd-precedence sites behind one
pure helper (`resolveDeviceWorkingDirectory`) and persist the resolved
cwd back onto a freshly-created topic so grouping, next-turn reuse, and
workspace-init scan all agree.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:24:42 +08:00
LiJian 5dd0f0c0c9 feat: specialize Market auth modal copy per capability scene (#15569)
Introduce a MarketAuthScene ('default' | 'sandbox' | 'mcp' | 'publish') so the
Market authorization modal can show capability-specific copy instead of the
generic "Create Community Profile" wording, while falling back to the generic
copy for unknown scenes.

- Reactive (401) path: infer scene from the tRPC procedure path in the error
  link and carry it on the market-unauthorized event.
- Proactive path: callers pass the scene to signIn() (publish buttons, MCP/skill
  install, in-chat market tool auth).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:39:33 +08:00
LiJian dfb70c1e87 🐛 fix(skills): inject pinned skill content into the system prompt (#15568)
* 🐛 fix(skills): inject pinned skill content into the system prompt

Pinned skills (ids in agentConfig.plugins) were marked activated by
SkillResolver but never carried their content, because resolveClientSkills
dropped the `content` field when mapping store skills to metas. As a result
SkillContextProvider's `s.activated && s.content` filter skipped them, so the
agent had to call activateSkill to use a pinned skill instead of it being
force-injected.

- builtin skill content is already in the store: carry it through.
- pinned DB skill content is fetched on demand (store cache first), only for
  pinned ids to avoid bulk network calls when auto mode exposes every skill;
  a failed fetch degrades gracefully to a content-less listing.
- resolveClientSkills becomes async; contextEngineering awaits it.
- add skillEngineering tests covering both paths.

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

* 🐛 fix(skills): mark pinned skills activated and fix test types

The MessagesEngine path passes skillsConfig.enabledSkills straight to
SkillContextProvider without running SkillResolver, so the metas must carry
`activated` themselves — content alone is not enough (the provider only injects
`s.activated && s.content`). Mark pinned skills activated in resolveClientSkills,
guarded by content presence so a content-less pinned skill still falls back to
the <available_skills> list instead of disappearing.

Also widen the test helper's param type so `content`/`activated` are accessible
(fixes TS2339 in CI).

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

* 🐛 fix(skills): don't pre-activate ZIP-bundled pinned skills

Server-side bundle mounting for execScript / readReference is keyed off
stepContext.activatedSkills, which is populated only by the activateSkill tool
call — operation-level pinning never seeds it. So pre-injecting the content of a
ZIP-bundled DB skill would tell the model to run scripts from an unmounted bundle.

Gate the content pre-injection on the absence of a zipFileHash: bundled skills
stay in <available_skills> and are activated via the tool (which mounts the
bundle), while pure-content skills (builtin Artifacts, bundle-free DB skills)
are still force-injected when pinned.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:38:59 +08:00
Arvin Xu 7ad6e2aa25 🐛 fix(agent): make working-directory Clear actually clear legacy / default-sourced cwd (#15571)
* 🐛 fix(agent): make working-directory Clear actually clear legacy / default-sourced cwd

The "Clear" action in the working-directory picker was a no-op whenever the
shown directory came from a precedence level that clear() never touched:

- clear() only removed the topic override and the agent's per-device choice
  (workingDirByDevice), but the button's visibility was gated on selectedDir,
  which also resolves from legacyAgentWorkingDirectory (pre-migration
  localStorage pick) and deviceDefaultCwd (device-wide default). When the cwd
  came from either, clear() deleted an already-empty higher level → nothing
  changed.

Fixes:
- useCommitWorkingDirectory: when clearing at the agent-default scope, also drop
  the legacy per-agent value (localStorage-only, no network round-trip).
- WorkingDirectoryPicker: gate the Clear button on hasClearableSelection
  (topic / agent choice / legacy) instead of selectedDir, so it no longer
  renders as a dead button when the cwd comes solely from the device default
  (which isn't clearable from the agent picker).

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

* 💄 style(claude-code): slow token count-up animation to 2000ms

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:22:40 +08:00
Arvin Xu 3986223b25 🐛 fix(heterogeneous-agents): show real CLI model on remote-spawned Claude Code (#15572)
Remote/device-spawned CC runs persist via the server-side
HeterogeneousPersistenceHandler (the executing device is not the viewing
client), and the assistant placeholder was created with the agent's
configured chat model/provider (e.g. deepseek-v4-pro). That value leaked
into the model tag and was re-applied at terminal, so the model tag showed
the wrong model instead of the real Claude Code model.

- Create the hetero placeholder with `provider: heteroType` for ALL hetero
  agents (not just remote openclaw/hermes) and no model, mirroring the
  client path. The real model is reported by the CLI and backfilled.
- Capture the CLI's authoritative model/provider from the first
  `stream_start` (CC system/init) and backfill the placeholder, so the real
  model lands from the first turn even without usage-bearing turn_metadata.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:08:00 +08:00
Arvin Xu ea246d6e17 feat(agent): list project skills over device RPC in the sidebar (#15566)
*  feat(agent): list project skills over device RPC in the sidebar

The right-sidebar 技能 (project skills) tab only read skills over local
Electron IPC, so in device mode (working dir on a bound remote device, or
the web client) the list was always empty — unlike the Files / Review tabs
which already branch on `deviceId`.

Add a `listProjectSkills` device RPC mirroring `getProjectFileIndex`:
- types: `DeviceProjectSkillItem` / `DeviceListProjectSkillsResult`
- `deviceGateway.listProjectSkills` via the generic `invokeRpc` relay
- TRPC `device.listProjectSkills` + `GatewayConnectionCtr` dispatch to
  `WorkspaceCtr.listProjectSkills`
- renderer chokepoint `projectSkillService` branches on `deviceId`
- `useProjectSkills(dir, deviceId?)`; remote mode lists but doesn't open
  previews (parity with the Files tab)
- thread `remoteDeviceId` through `SkillsGroup`

No device-gateway repo change needed — the RPC relay is method-agnostic.

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

*  feat(agent): list project skills over device RPC for homogeneous agents too

Thread `deviceId` through the homogeneous resources path
(`AgentDocumentsGroup` → `ProjectLevelSkills`) so a device-bound homogeneous
agent's 技能 tab populates over RPC, matching the heterogeneous `SkillsGroup`.
`useProjectSkills` already accepts `deviceId`; this just wires it in and
OR-s `deviceId` into the `showProjectSkills` gate.

(The large AgentDocumentsGroup diff is prettier re-indentation from wrapping
the outer memo() once the param list crossed the print width.)

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

* 🐛 fix(agent): resolve per-device cwd in ResourcesSection so device-mode skills load

ResourcesSection computed its working directory with the legacy
`topicCwd || agentCwd` selector, which misses `workingDirByDevice[deviceId]`
and `device.defaultCwd`. For a device-bound agent the cwd lives in that
per-device map, so it resolved to `undefined` — the project-skills SWR key
was null and the fetch never fired even though `deviceId` was set (the 技能
tab showed "暂无可用技能"). Switch to `useEffectiveWorkingDirectory`, the
same resolver the runtime bar / WorkingSidebar use. Fixes both the hetero
SkillsGroup and the homogeneous AgentDocumentsGroup paths.

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

* 💄 feat(agent): show loading state for project skills while switching path

On a working-directory switch the project-skills SWR key changes, so items
go empty while the new scan is in flight. The homogeneous skills panel was
flashing the empty placeholder instead of a loader. Surface
`useProjectSkills().isLoading` and render NeuralNetworkLoading when project
skills are the only source and still loading. (The hetero SkillsGroup already
shows it via SkillSection's isLoading.)

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:58:55 +08:00
Arvin Xu f5458e1ad9 ♻️ chore: replace LOBE-XXX markers with inline migration context (#15567)
♻️ chore: replace LOBE-XXX markers with inline migration context in 0110 SQL
2026-06-09 10:54:22 +08:00
LiJian 251e2ede5e feat(sandbox): sync user-uploaded files into the cloud sandbox (#15550)
*  feat(sandbox): sync user-uploaded files into the cloud sandbox

Pre-load the files a user attached in a conversation (topic message files +
session files) into the cloud sandbox the first time it is used, and tell the
agent they are available.

- FileModel.findFilesToInitInSandbox: merge messages_files (by topic) and
  files_to_sessions (by the topic's session), de-duped by file id
- SandboxMiddlewareService.ensureFilesInitialized: on first tool call, presign
  download URLs and run an idempotent curl bootstrap into /mnt/data; guarded by
  an in-sandbox marker and a short-lived Redis hint, best-effort so it never
  blocks the actual tool call (caps: 50 files / 100MB / 120s)
- Agent awareness via {{sandbox_uploaded_files}} in the cloud-sandbox systemRole,
  populated by both the server (RuntimeExecutors) and client (contextEngineering)
  placeholder generators

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

* 🐛 fix(sandbox): make file sync work on all server runtimes & keep prompt consistent

Address review feedback on the uploaded-files sync:

1. (high) The sync was a no-op on the cloudSandbox server runtime and the skills
   runtime because createSandboxService() was called without serverDB, so
   ensureFilesInitialized() returned early. Thread serverDB through both.
   (heterogeneous sandboxRunner is intentionally left out: it runs a coding agent
   in /workspace and does not use the cloud-sandbox systemRole.)

2. (medium) Drop the Redis "already initialized" hint. The in-sandbox marker is
   now the single source of truth for idempotency, so a recycled sandbox always
   re-syncs instead of being skipped by a stale 5-min Redis key.

3. (medium) Apply the 50-file / 100MB caps inside formatUploadedFilesPrompt (via
   the shared selectSandboxInitFiles), so the files the prompt advertises match
   exactly what the bootstrap downloads.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:40:34 +08:00
Innei 337e7f244c 🐛 fix(market-auth): skip auth flow when LobeChat session is missing (#15532)
Guard `signIn()` and the market.* 401 handlers on `isSignedIn` so the
Create Community Profile modal no longer pops up for unauthenticated
users. Routing the user back to LobeChat sign-in is not MarketAuth's
responsibility — callers handle that.
2026-06-09 10:16:44 +08:00
Arvin Xu eae47f527c feat(markdown): render GitHub / Linear / external links as rich chips (#15561)
*  feat(heterogeneous-agents): default Codex exec to bypass approvals/sandbox

Switch the default Codex execution mode from --full-auto to
--dangerously-bypass-approvals-and-sandbox, and share the execution-mode
constants from @lobechat/heterogeneous-agents/spawn so the desktop driver
and spawnAgent stay in sync. An explicit execution flag in extraArgs still
wins. Also fix the Codex adapter step tracking so consecutive agent_message
items stay in one step, stale tool completions don't start a new step, and
turn completion drains pending tools before emitting stream_end +
agent_runtime_end.

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

*  feat(shared-tool-ui): unwrap shell-wrapper commands in RunCommand UI

Codex execs commands wrapped as `/bin/zsh -lc '...'`; surface the inner
command in the RunCommand inspector and render. Also switch Unix glob
fallback from `find` to `fast-glob` to preserve globstar semantics.

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

*  feat(markdown): render GitHub / Linear / external links as rich chips

Add a markdown Link plugin that rewrites anchor elements into rich inline
chips: GitHub repo/PR/issue/commit/user, Linear issues, npm packages, Figma
files, mailto, and any other external link (favicon + full URL). Citation,
footnote, anchor and relative links keep the default renderer.

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

* ⬆️ chore(deps): bump @lobehub/editor to 4.17.0 and @lobehub/ui to 5.15.10

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:22:35 +08:00
Arvin Xu dfdf844761 🐛 fix(desktop): bump node-gyp to 12.x so Windows build finds Visual Studio 2026 (#15562)
GitHub redirects the `windows-2025` runner to the new `windows-2025-vs2026`
image, which ships Visual Studio 2026. node-gyp 11.5.0 only recognizes VS
2019/2022, so `electron-builder install-app-deps` fails to rebuild the native
`get-windows` module with "Could not find any Visual Studio installation".
node-gyp 12.x adds VS 2026 detection. Override it in both the root workspace
and the isolated apps/desktop install.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 02:36:10 +08:00
Arvin Xu cca01451f9 feat(heterogeneous-agents): default Codex exec to bypass approvals/sandbox (#15557)
*  feat(heterogeneous-agents): default Codex exec to bypass approvals/sandbox

Switch the default Codex execution mode from --full-auto to
--dangerously-bypass-approvals-and-sandbox, and share the execution-mode
constants from @lobechat/heterogeneous-agents/spawn so the desktop driver
and spawnAgent stay in sync. An explicit execution flag in extraArgs still
wins. Also fix the Codex adapter step tracking so consecutive agent_message
items stay in one step, stale tool completions don't start a new step, and
turn completion drains pending tools before emitting stream_end +
agent_runtime_end.

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

*  feat(shared-tool-ui): unwrap shell-wrapper commands in RunCommand UI

Codex execs commands wrapped as `/bin/zsh -lc '...'`; surface the inner
command in the RunCommand inspector and render. Also switch Unix glob
fallback from `find` to `fast-glob` to preserve globstar semantics.

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-09 02:03:35 +08:00
Innei d2cd9ef023 feat(page-editor): enable block plugin with shared inline padding (#15556)
*  feat(page-editor): enable block plugin with shared inline padding

Mount `ReactBlockPlugin` on the page editor with `anchorPadding={0}` so
the editor root no longer reserves its default 54 px gutters, and apply
`DEFAULT_BLOCK_ANCHOR_PADDING` as `paddingInline` on the `Flexbox`
wrapping `TitleSection` + `EditorCanvas`. This keeps the title and
editor content aligned while leaving the same 54 px of room for the
floating block menu / drag handle to render in.

Requires `@lobehub/editor` with `anchorPadding` support and the
exported `DEFAULT_BLOCK_ANCHOR_PADDING` constant.

* 🐛 fix(page-editor): drop redundant overflowY on editor content wrapper

`editorContent` previously declared `overflowY: 'auto'`, which created
a second scroll container nested inside `.contentWrapper` (already
`overflowY: 'auto'`). With the new inline padding from
`DEFAULT_BLOCK_ANCHOR_PADDING`, the nested scroller clipped the
floating block menu / drag handle that the editor renders in the
inline-padding gutter. Let the outer wrapper own scrolling so the
gutter overflow stays visible.
2026-06-09 01:04:10 +08:00
Arvin Xu ea3ae583d6 feat(agent): unified per-device working directory + execution-device UI (#15543)
*  feat(agent): unified per-device working directory + execution-device UI

Client UI consuming the backend contract (#15542). User-facing — validate
before merge.

- New `src/store/device` (SWR fetch + cwd writes) — single source of device data;
  `deviceCwd` helper moves here from the chat-input feature layer.
- One `WorkingDirectoryPicker` for local + remote (native dialog vs manual path).
- Shared `WorkspaceControls` strip composed by both chat-input bars.
- GitStatus reads remote git via `useDeviceGitInfo` (read-only).
- Execution-device switcher graduates out of labs → writes only executionTarget.
- One-time migration of legacy localStorage recents into device.workingDirs.

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

*  feat(agent): wire executionTarget→runtimeMode + workingDirByDevice cwd

The runtime-decision wiring, kept out of the backend contract PR so it's
reviewed/validated together with the UI that drives it.

- `helpers/executionTarget`: resolveRuntimeMode / executionTarget resolvers.
- server tool gate (AgentToolsEngine) derives runtimeMode from
  `agencyConfig.executionTarget`, with a no-regression fallback to the legacy
  per-platform runtimeMode.
- server cwd precedence (aiAgent resolveWorkspaceInit + hetero dispatch) now
  consumes `workingDirByDevice[targetDeviceId]`.

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

*  test(agent): cover executionTarget + workingDir helpers; drop dead lab key

- Unit-test resolveRuntimeMode / resolveExecutionTarget and the working-dir
  precedence (locks the web default→cloud graduation + legacy fallback)
- Remove the now-unused `executionDeviceSwitcher` lab i18n keys (toggle deleted)

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

* 💄 style(agent): guide web users to the desktop app in the device switcher

On web with no remote device, replace the muted "no devices" dead-end with a
prominent, clickable download-desktop card (and drop the now-duplicate header
link). Desktop keeps the muted hint since local execution is already available.

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

* 💄 style(agent): fix execution-device copy for desktop + web

- Desktop "no devices" hint no longer tells an already-on-desktop user to
  "install the desktop app" — just points at `lh connect`.
- Tighten the web download-card description to the desktop's real benefit
  (run on your computer with local file access).

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

* 💄 style(agent): flatten the web download card to a plain row

Drop the outer border/background so it reads as a normal menu row (like the
sandbox option), and shorten the description to a single line so the row stops
being taller than its neighbours.

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

* 💄 style(agent): reword download-card desc to "access to your computer"

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

*  feat(agent): add "no device" execution target (plain chat, no run tools)

Restores the option to run an agent with no execution environment, lost when
the per-platform runtimeMode was unified into executionTarget. Adds `none` to
HeteroExecutionTarget (→ runtimeMode `none`), surfaces it at the top of the
switcher on both web + desktop, and flips the web default back to `none` so an
unconfigured web agent is plain chat again (desktop still defaults to local).

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

* 💄 style(agent): rename HeteroExecutionTarget→DeviceExecutionTarget, reorder switcher

- Rename the type (it now carries `none`, so "device" target fits better than
  "hetero") across types + helpers + dispatcher + switcher.
- Move "no device" to the bottom of the list (real targets first, opt-out last).
- Reword the download card to "let agents connect directly to your computer".

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

* 💄 style(agent): move "no device" back to top, restore EN download copy

"No device" sits above the dynamic device rows; keep the EN download-card
wording as "Run agents with access to your computer".

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

* 💄 style(agent): swap switcher icons — MonitorOff for "no device", Box for sandbox

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

* 💄 style(agent): clarify execution-device info tooltip + "no device" desc

- Info tooltip now explains the cloud sandbox is provided by the centralized
  LobeHub Marketplace, and that picking a device makes it the agent's runtime
  for reading/writing files and operating the computer.
- "No device" description now conveys "no device enabled, can't operate a
  computer" instead of "plain chat".

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

* 💄 style(agent): move info icon beside the title, shorten "no device" desc

- Info tooltip trigger now sits next to the "Execution Device" title instead of
  right-aligned; the download link stays on the right.
- "No device" description trimmed to just "No device enabled".

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

* 💄 style(agent): zh tooltip wording — "提供服务"

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

* 💄 style(agent): reorder tooltip — device runtime first, marketplace last

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

* 💄 style(agent): trim tooltip — drop "设备"/devices and trailing period

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

* 💄 style(agent): tag the current machine's device row, drop duplicate "This device"

When the desktop's own machine appears in the device list, badge that real row
with a "This device" tag and hide the generic "This device" (local) option —
no more two entries for the same machine. The local option still shows as a
fallback when the machine isn't enrolled in the list yet.

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

* 💄 feat(agent): hoist this-machine device above sandbox + auto-bind on first run

Switcher-only (no routing/dispatch changes):
- Order is now: no device → this device → cloud sandbox → other devices.
- On desktop, when this machine is enrolled and online and the agent has no
  explicit target yet, default to it and persist the binding once.

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

* 💄 style(agent): widen gap between execution-device rows

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

* 💄 style(agent): hide "Get Desktop App" link on desktop

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

* 💄 style(agent): capitalize "Cloud Sandbox" label

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

* 💄 feat(agent): web working-dir entry via "Add folder" modal instead of inline input

The browser folder picker can't yield an absolute path (sandboxed handle), so
on web / a remote device the working directory is entered manually. Replace the
inline input with an "Add folder…" row that opens a modal for absolute-path
entry; the local desktop machine still opens the native folder dialog.

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

* ♻️ refactor(agent): split working-dir footer into local/remote row components

Replace the scattered `isLocalDevice ?` forks (icon, label, handler) with one
branch that picks between two self-contained rows: ChooseLocalFolderRow (native
dialog) and AddRemoteFolderRow (absolute-path modal).

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

* 💄 style(agent): use the device default cwd as the add-folder placeholder

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

*  feat(agent): validate manually-entered working dir via device statPath RPC

Web / remote clients can't browse the target device's filesystem, so the
"Add folder" modal now checks the typed path on the device before binding it.
New `statPath` device RPC mirrors gitInfo end-to-end:
- desktop WorkspaceCtr.statPath (fs.stat → exists / isDirectory) + RPC dispatch
- server deviceGateway.statPath + device.statPath tRPC (invokeRpc relay)
- modal blocks on a definitive negative (not found / not a directory); an
  unreachable device is treated as "can't verify" and allowed through

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

* ♻️ refactor(agent): route statPath through deviceService, not lambdaClient

Components shouldn't import lambdaClient directly — add a thin deviceService
wrapping device.statPath, and call it from the working-dir picker.

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

* ♻️ refactor(i18n): move working-directory strings from plugin to a device ns

The working-directory / git control-bar strings (53 keys) were lumped under the
`plugin` namespace. Move them to a dedicated `device` namespace and drop the
now-redundant `localSystem.` prefix (`plugin:localSystem.workingDirectory.X` →
`device:workingDirectory.X`). Updates the 4 consumer components; the `device`
ns auto-registers via defaultResources.

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

* ♻️ refactor(agent): route all device TRPC calls through deviceService

Components/hooks/stores shouldn't reach into lambdaClient.device.* directly.
Expand deviceService with listDevices/updateDevice/listGitBranches/
checkoutGitBranch/checkCapability/getAgentProfile and migrate every imperative
call site (device store, BranchSwitcher, CreatePlatformAgent, the remote-agent
guard, RemoteAgentConfigCard) + the DeviceListItem type. lambdaQuery.device.*
React-Query hooks are left as-is (a different pattern).

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

*  feat(agent): pull/push a remote device's branch over RPC

Wire git pull/push through the device's pullGitBranch/pushGitBranch RPC so the
web/remote GitStatus bar can sync, not just the local desktop over IPC. Shows
the pull/push affordances for remote devices too.

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

* ♻️ refactor(agent): route git pull/push through deviceService too

Add pullGitBranch/pushGitBranch to deviceService and switch GitStatus off the
direct lambdaClient.device.* calls, so no component reaches the device router
directly anymore.

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

* 🐛 fix(agent): detect repoType for manually-added working dirs

A directory added via the "Add folder" modal committed without a repoType, so a
GitHub repo showed a plain folder icon. statPath now also returns the git repo
type (detected on the target device); the modal threads it into the committed
entry. Collapses the modal's separate validate+submit into one onSubmit that
validates and enriches in a single round-trip.

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

* 💄 style(agent): create new branch via a modal instead of inline footer

"Checkout new branch…" now opens a focused modal (branch-name input + create)
rather than expanding an inline footer inside the branch dropdown. Always
creates + checks out the branch — no checkout/overwrite options. Errors show
inline in the modal; drops the dead inline-create state/styles.

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

* ♻️ refactor(agent): route all git ops through a unified gitService

Pick Electron IPC vs device RPC inside the service so UI / store / hooks
stay transport-agnostic. Replace the bundled `gitInfo` device RPC with
granular reads (branch / linked PR / working-tree / ahead-behind) that
mirror the local IPC methods one-to-one, and move the git read SWR hooks
into the device store (useFetchGitInfo / WorkingTreeStatus / AheadBehind).

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

*  feat(agent): route Review git ops through device RPC (remote-capable)

Extend the device-RPC git pipeline to the 4 ops the Review panel needs
(getGitWorkingTreePatches / getGitBranchDiff / listGitRemoteBranches /
revertGitFile), mirroring the listGitBranches pattern end-to-end: desktop RPC
dispatch → deviceGateway → device.* tRPC → gitService. Adds minimal DeviceGit*
mirror types to @lobechat/types. Review (useReviewPatches / useGitRemoteBranches
/ FileItem) now goes through gitService with a deviceId, dropping the isDesktop
gate so web/remote devices get the diff + revert too.

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

* 🐛 fix(agent): resolve repoType from device store so remote Review tab shows

useRepoType now reads the persisted workingDirs[].repoType from the device
store (keyed by deviceId), so a remote device's git/github type — and thus the
Review tab visibility — resolves without a local-only IPC probe. The IPC probe
+ localStorage fallback are kept only when the target is the local machine.

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

* 💄 feat(agent): optimistic branch switch in the branch switcher

Flip the displayed branch the instant a checkout is clicked (or a new branch
created) instead of waiting for the IPC/RPC round-trip + gitInfo refetch. The
git-info SWR cache is optimistically updated and reconciled on completion — a
failed checkout rolls the label back and toasts the error.

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

*  feat: support remote device files panel

* 💄 style: restore desktop this-device option

* 🐛 fix: keep files panel local for this device

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:27:52 +08:00
Arvin Xu a75eba5a4f 💄 style(chat-input): use compact stats footer for skill tools popover (#15552)
* 💄 style(chat-input): use compact stats footer for skill tools popover

- Replace the two full-width footer rows (store / management) with a
  compact stats footer: pinned / auto counts on the left, an
  "Add Skills / Connector" store button (icon + label) and a settings
  icon button on the right.
- Right-align each item's type tag (MCP / Skills / builtin) so badges sit
  flush next to the row action instead of trailing the name.

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

*  test(aiAgent): mock deviceGateway in connectorOverlap exec test

execAgent reads `deviceGateway.isConfigured`, which under the happy-dom
test environment hits real t3-env and throws "server-side env var on the
client". Mock `@/server/services/deviceGateway` like the sibling device
tests do so the connector/plugin overlap cases run in isolation.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:23:38 +08:00
Innei 9eff025787 💄 style(modal): use base-ui Button in custom modal footers (#15444)
💄 style(modal): use @lobehub/ui/base-ui Button in modal footers

Align custom-footer button padding/font with base-ui Modal's built-in
OkBtn/CancelBtn (32h / 14 / 13) for consistent visual rhythm. Affects
AuthRequiredModal footer and TaskTemplateDetailModal content button.
2026-06-08 21:31:34 +08:00
Innei 9b19ebb2c6 🐛 fix(desktop): unbreak dev cold-start + restore UI language across reloads (#15547)
* 🐛 fix(desktop): unbreak dev cold-start on non-default UI languages

`ViteRendererFallback` now proxies via globalThis `fetch` (Node undici) instead
of Electron `net.fetch`, and Vite dev server is pinned to IPv4 listen. The
main-process Chromium `net` pool is small and surfaces `ERR_INSUFFICIENT_RESOURCES`
under cold-start module bursts + ~50 i18n namespace fan-out under non-en-US
locales. undici queues internally and avoids that pool entirely; v4 listen avoids
happy-eyeballs dual-stack connect storms. A Semaphore(64) still caps in-flight
fetches so the OS socket layer never gets buried.

Fixes LOBE-10086

* 🐛 fix(desktop): restore persisted UI language across renderer reloads

The renderer's `<html lang>` was being computed from `?lng=` (injected by the
main process at `loadURL` time) with `navigator.language` as fallback. On
`Cmd+R` the webContents reload reuses the prior URL without rebuilding it
against `storeManager.locale`, so users who changed their language after
launch got dropped back to the OS locale on every reload (white screen, then
English). Read the i18next localStorage cache first — that's the actual
persisted user setting written by the language switcher — and fall back to the
URL param + navigator as before.

*  test: mock device gateway in connector overlap spec
2026-06-08 21:21:24 +08:00
YuTengjing a2fd98a2d1 🐛 fix: restore file URLs in context prompts (#15549) 2026-06-08 19:26:16 +08:00
Arvin Xu 235a16fc11 chore(agent): agencyConfig contract + git-over-RPC backend (#15542)
*  feat(agent): agencyConfig contract — workingDirByDevice + executionTarget

Type-only contract for the unified per-device working-directory work. Adds
`workingDirByDevice` (per-device cwd) and `executionTarget` to agencyConfig.
No runtime logic consumes them yet — the server/client wiring lands in the UI
PR so it can be validated as one unit.

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

*  feat(agent): device gitInfo over RPC + shared local-file-shell git impl

Backend/RPC capability for "git branch / changes / PR for remote devices".
Dormant — no client caller yet; merging changes no existing behavior.

- `@lobechat/local-file-shell/git`: repoType + branch / linked-PR / working-tree
  / ahead-behind + `gitInfo` aggregate + `DeviceGitInfo` type (desktop + CLI).
- desktop `GitCtr.gitInfo()` (@IpcMethod) delegates to it; registered in
  GatewayConnectionCtr's RPC dispatch. `utils/git` re-exports the helpers.
- server: `deviceGateway.gitInfo()` wrapper + `device.gitInfo` TRPC query.
- `@lobechat/types`: `DeviceGitInfo` shape.

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

*  test(desktop): fix stale mocks after git impl moved to local-file-shell

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

* ♻️ refactor(server): extract DeviceGateway into its own service dir

deviceGateway is a device-scoped gateway client (status/list/tool-call/git/
workspace RPC), not tool-execution-specific. Move it out of toolExecution/
into its own services/deviceGateway/ and update all import sites.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:09:09 +08:00
LiJian ee65cf2a0f feat(connector): custom OAuth MCP connectors — onboarding, runtime execution & connector-first (LOBE-9983) (#15546)
*  feat(connector): wire custom MCP OAuth — Pre-registration & DCR (LOBE-9983)

Connect the two OIDC schemes designed in LOBE-9736 (oidcConfig) end-to-end so
users can add a custom OAuth MCP server from /settings/skill. Until now the DB
schema, models, and tool-permission UI existed, but nothing ran the OAuth
authorization flow — syncTools only worked when a token already existed.

Flow (shared pipeline, branches only on where client_id comes from):
- Add modal (client_id present → Pre-registration; absent → DCR/RFC 7591)
- startOAuth: probe MCP URL → RFC 9728 protected-resource metadata → RFC 8414
  AS metadata; DCR-register the client when no client_id; persist resolved
  oidcConfig; build PKCE authorize URL, stash verifier in Redis keyed by state
- /oauth/connector/callback: consume state → exchange code → store encrypted
  tokens (KeyVaultsGateKeeper) + tokenExpiresAt + status=connected → postMessage
- syncTools lazily refreshes the access token before connecting

Built on @modelcontextprotocol/sdk OAuth helpers (discover/register/start/
exchange/refresh) — no hand-rolled protocol code.

Security:
- Wire KeyVaultsGateKeeper into ConnectorModel so OAuth tokens are encrypted at
  rest (previously the router passed no gatekeeper → plaintext)
- Strip decrypted credentials and oidcConfig.clientSecret from the list response

UI:
- "+" button in /settings/skill Connectors tab opens the Add modal
- SkillList surfaces custom connectors from the connector store
- Modal wires the client secret field, infers the scheme, and shows the
  redirect URI to register

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

* 🐛 fix(connector): request server-advertised scopes in OAuth flow

The authorize request sent an empty scope list, so providers that require a
scope (e.g. Linear MCP advertises scopes_supported ["read","write"]) issued a
useless token or rejected the flow. Default to the authorization server's
advertised scopes_supported when the user did not specify any, and use them for
both DCR registration and the authorize request.

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

* 🐛 fix(connector): let OAuth callback bypass SPA rewrite and auth gate

/oauth/connector/callback is a backend route handler reached via a cross-site
redirect from the OAuth provider, so the proxy middleware broke it two ways:

1. It was not in the backend passthrough list, so it got rewritten to the SPA /
   locale shell instead of running the route handler (307 → blank).
2. It was not in isPublicRoute, so BetterAuth treated it as protected; the
   cross-site top-level navigation doesn't reliably carry the SameSite session
   cookie, so it redirected to sign-in (307).

Add /oauth/connector to backendApiEndpoints and /oauth/connector/callback to
isPublicRoute (the handler validates its own single-use state, so it must not be
session-gated). Scoped so /oauth/callback/success|error SPA pages are unaffected.

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

*  feat(connector): execute connector tools server-side + agent-runtime wiring

Make custom OAuth MCP connectors actually callable, and sync their tools as
soon as authorization completes.

- callback: after token exchange, sync the tool list server-side via a shared
  syncConnectorToolsById — the connector is usable without a client round-trip
- sync.ts: extract buildConnectorMcpParams (http+auth / stdio), shared by
  syncTools and the new callTool
- connector router: add `callTool` (resolve connector, hard-block disabled
  tools, refresh token, call the remote MCP with decrypted credentials)
- aiAgent runtime: pass a KeyVaultsGateKeeper when resolving connectors so OAuth
  tokens decrypt (otherwise tool calls 401); surface connectors in the
  agent-management availablePlugins as a new 'connector' type
- AgentManagementContextInjector: render a <connector_plugins> section

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

*  feat(connector): wire connectors into the classic client chat path

The front-end chat orchestrates tools client-side (via /webapi/chat proxy),
separate from the server agent runtime. Connectors were invisible and
unexecutable there. Wire them in, connector-first.

- toolEngineering: build connector manifests from the store and inject them into
  createToolsEngine; drop plugins sharing a connector identifier (connector wins)
- buildClientConnectorManifests: store rows → type 'mcp' manifests (no token; the
  client has none) with permission → humanIntervention mapping
- mcpService.invokeMcpToolCall: route connector tool calls to connector.callTool
  before the plugin path (only connectors with a real MCP endpoint, so
  Lobehub/Klavis skills keep their executor)
- DeferredStoreInitialization: fetch connectors post-login so chat sees them
- AddConnectorModal: refresh after OAuth regardless of popup outcome
- chat-input skills picker: surface custom connectors in the auto group

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

* 🐛 fix(connector): open OAuth popup synchronously + escape callback HTML (codex P1)

- AddConnectorModal: open the OAuth popup synchronously inside the click handler
  (before any await), then navigate it to the authorize URL. Browsers block
  window.open once an async boundary is crossed, which left popup=null and the
  poll loop never resolving — the Add modal hung. Null popup now fails fast with
  a "allow popups" message.
- callback route: escape the postMessage payload for `<script>` context
  (`<`, `>`, `&`, U+2028/U+2029 → \uXXXX). A malicious OAuth server could put
  `</script>...` in the error param and execute script on the app origin.

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

* 🐛 fix(connector): tighten execution boundary + surface OAuth failures + tests

Address review: enforce the same constraints at the call site that the manifest
layer enforces, and stop swallowing OAuth failures.

- isEnabled on BOTH sides: invokeMcpToolCall only routes enabled connectors
  (a disabled connector no longer steals a same-name plugin's call), and the
  server rejects calls to a disabled connector. Matches buildClientConnectorManifests
  which only exposes enabled connectors.
- callTool requires the toolName to exist in the synced user_connector_tools
  list — unsynced / hand-crafted tool names are rejected instead of being
  forwarded blindly to the remote MCP.
- extract callConnectorToolById (typed ConnectorToolCallError → tRPC codes) so
  the gates are unit-testable.
- AddConnectorModal: distinguish success / provider-error (show the reason) /
  user-dismissed instead of collapsing every failure into a silent close.
- tests: exec gates (not-found / disabled connector / unknown tool / disabled
  tool / success / token-refresh) + buildClientConnectorManifests mapping.

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

* 🐛 fix(connector): align redirect URI, connector-override & partial-failure UX

Second review round.

- redirect URI: the modal showed a client-origin URI while the server sent an
  APP_URL one — register-vs-use mismatch broke the callback. Add a
  `connector.getRedirectUri` query (server source of truth) and show exactly
  that in the modal.
- execAgent: derive the plugin-override set from the connectors that ACTUALLY
  produce a manifest (enabled + with tools), not the raw endpoint-having set —
  a disabled / not-yet-synced same-named connector no longer evicts the plugin
  and leaves the runtime with no tools. Matches the client-chat behaviour.
- partial failure: when code exchange succeeds but the tool sync fails, the
  callback now reports `synced: false`; the modal shows "authorized but tools
  could not be synced" instead of a false "connected".

Tests: execAgent overlap regression (disabled / 0-tool keeps the plugin; real
tools replace it) + callback partial-failure (synced:false on sync error).

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

* ♻️ refactor(connector): name the availablePlugins source 'custom' not 'connector'

The agent-management availablePlugins types describe a tool's SOURCE
(builtin / klavis / lobehub-skill); 'connector' named the storage system
instead. Once plugins migrate to the connector table everything is a connector,
so the source-based label is what matters. Rename to 'custom' to align with
ConnectorSourceType.custom (single source of truth); section is <custom_plugins>.

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

* 🐛 fix(connector): enforce connector permissions for community MCP plugins

Community MCPs execute via the plugin path (not connector.callTool), so the
per-tool permissions a user sets in the new Connectors UI weren't surfaced:
needs_approval didn't trigger the approval prompt on either runtime. (disabled
was already hard-blocked at execution by ToolExecutionService and the mcp
router.)

- extract patchManifestWithPermissions into a pure, client-safe module
  (patchManifestPermissions.ts); connectorPermissionCheck.ts re-exports it.
- execAgent: also patch community-plugin manifests (pluginsWithoutConnectors)
  with their connector permissions, alongside lobehub/klavis.
- client createToolsEngine: patch community-plugin manifests with connector
  permissions from the store so needs_approval surfaces as humanIntervention
  in the classic chat path too.
- unit tests for the shared patch function.

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

*  fix(connector): tolerate uninitialized connectors slice in selectors

createToolsEngine now reads connectorSelectors.{customConnectors,connectorList};
toolEngineering/index.test.ts mocks getToolStoreState without `connectors`, so
the selectors hit `undefined.filter`. Guard with `?? []` (the real store always
seeds connectors:[] via initialState) and add connectors:[] to the test mock.

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

*  fix(connector): guard every connector selector against an uninitialized slice

mcp.test.ts mocks the tool store without `connectors`, and invokeMcpToolCall
calls connectorByIdentifier → `s.connectors.find` threw. The previous fix only
guarded connectorList/customConnectors; harden all of them (find/filter) so any
partial-store mock is safe. The real store always seeds connectors:[].

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-08 18:01:41 +08:00
Arvin Xu 0ac53b4e80 🐛 fix(agent-runtime): capture Gemini multimodal content_part/reasoning_part output (#15535)
Gemini 2.5+/3 thinking streams deliver assistant text and reasoning as
content_part/reasoning_part events instead of plain text/reasoning. The
runtime registered no onContentPart/onReasoningPart handlers, so the text
was silently dropped: onCompletion still reported usage tokens, the
empty-completion guard saw outputTokens > 0, and the turn finalized to a
blank `done` (lost in DB, client stream and trace alike).

Add the two handlers, mirroring onText/onThinking for text parts so
streaming, persistence and tracing all capture the content. Image parts
are uploaded to object storage and serialized as multimodal content
(text + image URLs, in order) — never persisting raw base64.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 14:55:37 +08:00
René Wang 91588bfdf8 📝 docs: add June 8 weekly changelog (#15537)
* 📝 docs: add June 8 weekly changelog

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

* 📝 docs: add June 8 changelog cover and register index entry

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:57:37 +08:00
LiJian 927a79c3fb feat(auth): preserve utm_source through the OIDC sign-in/sign-up flow (#15544)
When Market kicks off OIDC against LobeHub, unauthenticated users are
redirected by the auth middleware to /signin (and onward to /signup).
The utm_source param sent on the original /oidc/auth request was only
buried inside callbackUrl and never surfaced on the sign-up page.

Carry utm_source as a first-class query param through the auth detour,
mirroring how the `hl` locale param is already preserved:
- middleware lifts utm_source from the request onto the /signin URL
- sign-in forwards utm_source to /signup in both navigation paths

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:24:53 +08:00
Arvin Xu c5c047e4b5 🐛 fix(desktop): misc independent fixes (vite fetch cap, gateway loading, token animation) (#15541)
* 🐛 fix(desktop): bound concurrent Vite dev-server fetches

Since #15304 unified dev under app://, every renderer asset round-trips
through the main-process net stack. A cold start (thousands of module
requests) or a non-default UI language (~50 i18n namespaces over HTTP at
once) could exhaust the net request pool and surface as
ERR_INSUFFICIENT_RESOURCES. Gate Vite dev-server fetches behind a FIFO
semaphore (cap 64), holding each slot until the response body is fully
drained so streaming responses count for their whole lifetime.

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

* 💄 style(desktop): add trailing inset to tab title

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

* 🐛 fix: eliminate blank loading state during Gateway/ServerRuntime execution

When sending a message in Gateway (ServerRuntime) mode, the UI showed
a blank state between 'Sending message' and 'Task is running in server'
because the new execServerAgentRuntime operation was associated with the
server-created message ID, while the UI was still rendering the temp
message ID. The temp ID had no running operation, so ContentLoading
returned null.

Fix: pass temp message IDs to executeGatewayAgent and associate them
with the gateway operation alongside the server message ID. This ensures
ContentLoading finds a running operation regardless of which message ID
the UI is currently rendering.

*  feat(agent): animate subagent token count with count-up effect

Promote a shared AnimatedNumber into @lobechat/shared-tool-ui/components and
use it for the subagent metrics token total so it rolls up smoothly while
streaming instead of jumping.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:09:45 +08:00
LiJian 419aca2c59 🐛 fix(skill): stop OAuth connectors duplicating into the Skills tab (#15510)
The unified /settings/skill manager renders the Connectors and Skills
sub-tabs from one SkillList via viewMode. Lobehub/Klavis OAuth connectors
(type 'lobehub' | 'klavis') belong only in the Connectors view, but the
Skills view's "Community Skill" section still mapped them alongside the
market agent skills — so Gmail, Notion, Google Drive, etc. showed up in
both tabs.

Render only market agent skills in the Skills view; OAuth connectors stay
exclusively under the Connectors view's "OAuth Connectors" group.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:26:14 +08:00
Arvin Xu f0f8ecd64d 🧹 chore: clean LOBE marker comments from aiInfra schema (2026-06-08) (#15536)
🧹 chore: replace LOBE-10056 markers with inline context in aiInfra schema comments
2026-06-08 11:07:18 +08:00
René Wang b19008ed24 💄 style: bring various details for better experience (#15486) 2026-06-08 10:55:46 +08:00
Arvin Xu dbf743cc12 feat(verify): Agent Run delivery checker system (#15489)
* 🗃️ feat(database): add verify system tables for agent run delivery checker

Implement the database layer for the Agent Run delivery checker (Verify System).

Reuse / definition layer:
- verify_criteria: a single reusable pass/fail standard (atomic unit), carrying
  its verifier config + onFail default and bound to a document for judging
  guidance (iteration history reuses document_history; no version columns)
- verify_rubrics: a named group that aggregates criteria — the reusable unit
- verify_rubric_criteria: junction, which criteria a rubric aggregates
  (criteria are reusable across rubrics)

Mounted onto an agent via the existing agency config jsonb:
- agencyConfig.verifyRubricId: a reusable rubric (criteria template)
- agencyConfig.verifyCriteriaIds: ad-hoc one-off criteria
A run's plan instantiates the union of both. No dedicated bindings table.

Snapshot + result layer:
- agent_operations.verify_plan (jsonb) + verify_plan_confirmed_at: the per-run
  immutable check-item snapshot lives ON the operation (1:1 — auto-repair spawns
  a new operation), instead of a separate plans table
- agent_operations.verify_status: denormalized rollup for list-page badges
- verify_check_results: per-criterion result with the Toulmin model
  (verdict/confidence as columns, narrative in a typed toulmin jsonb), N:1
  verifier_tracing_id for batch judging, FP/FN flags for the data flywheel;
  relates to the plan via operation_id + stable check_item_id

Ref: LOBE-10019

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

*  feat(verify): add Agent Run delivery checker backend + frontend module

Implements the verify system on top of the schema (PR #15480):
- models: verifyCriterion / verifyRubric (+junction) / verifyCheckResult;
  agentOperation verify plan/status methods
- services/verify: AI plan generation (auto-create criteria), executor with
  LLM Toulmin judge (per-criterion + batch), program placeholder, agent &
  auto-repair spawner seams, rollup chokepoint, feedback fp/fn, completion
  lifecycle bridge
- lambda verify router (criteria/rubric CRUD, plan, results, feedback)
- frontend feature module: service, SWR hooks, CheckerDock state machine,
  RunArtifact, verify i18n namespace
- tracing scenarios: VerifyPlanGen / VerifyJudge

Live UI mount (dock/artifact into chat) pending server operationId source.

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

* 🐛 fix(verify): persist delivery-checker verdicts via async tracing backfill

The LLM judge produced valid verdicts but they were never persisted, leaving
every run stuck at `verifying`. Two root causes:

1. FK ordering: `writeVerdict` stamped `verifier_tracing_id` synchronously, but
   the `llm_generation_tracing` row is written asynchronously (best-effort,
   after the response) — so the hard FK was violated every time and the verdict
   write was rolled back. Now the verdict is written with a null link, and the
   tracing id is backfilled by an `onPersisted` callback that fires only after
   the tracing row commits (still non-blocking). If tracing is disabled the link
   simply stays null.

2. Verdict parse: the judge JSON schema is non-strict, so the provider returns
   optional Toulmin fields as explicit `null`. The Zod validator used
   `.optional()` (accepts undefined, not null), so any null failed the whole
   `safeParse` and discarded the batch. Switched to `.nullish()`.

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

*  feat(cli): add `verify` command for the delivery checker

Adds `lh verify` covering the full delivery-checker chain — criteria & rubric
CRUD, per-run plan (generate/state/confirm/skip), execute (LLM judge), results,
and feedback — calling the `verify` lambda router. Enables end-to-end backend
testing of the verify system.

Also adds the missing `tool-runtime` / `prompts` / `const` workspace entries to
the CLI's `pnpm-workspace.yaml` so the standalone package installs.

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

* 💄 feat(verify): add verify message role + delivery-checker card UI

Make the delivery-checker renderable in chat:

- Fix the `features/Verify` components so they compile: flatten the `verify`
  locale to the repo's flat-dotted-key convention (keySeparator: false), import
  `Flexbox`/`TextArea` from `@lobehub/ui` (react-layout-kit is no longer a dep),
  and the token cast.
- Add a `verify` UI message role + a `VerifyMessage` card that renders the
  Run Artifact + checker dock from `metadata.verifyOperationId`, wired into the
  message renderer switch.

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

*  feat(verify): add lobe-agent `generateVerifyPlan` tool (server runtime)

Lets an agent set up the delivery checker for its run: the agent calls
`generateVerifyPlan` early (per the new `<delivery_checker>` system-role
guidance), which instantiates the rubric / ad-hoc criteria into a frozen plan on
the current `agent_operations` row. Executed server-side only — the executor is
dispatched via `runtime[apiName]` with `operationId` threaded through the tool
execution context; the client `BaseExecutor` gracefully no-ops it.

Also registers the metadata fields (`verifyOperationId`/`verifyRound`) on the
message metadata zod schema so the role='verify' card can carry its operation id.

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

*  feat(verify): surface role=verify card on run completion (LOBE-10051)

Connect the delivery checker to the conversation: when an Agent Run with a
verify plan completes, `CompletionLifecycle` inserts a persisted `role='verify'`
message (parented to the assistant, carrying `metadata.verifyOperationId`) that
renders the checker card. Self-guarded — no plan → no card, failures never
affect the run.

`role='verify'` behaves like a `user` leaf message everywhere it flows
(persistence + conversation-flow pass it through unchanged); only the
context-engine treats it specially: a new `VerifyMessageProcessor` drops it from
the model context (UI-only card, not a valid model role). Adds `verify` to
`CreateMessageRoleType`.

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

* 💄 feat(verify): merge run-artifact + checker into one card

The role=verify message rendered two stacked cards (Run Artifact summary +
Delivery Checker) that duplicated the check-item list. Merge into a single card:
the `Run Artifact · Round N` header, then the checker results + actions, then the
snapshot note. RunArtifact/CheckerDock gain an `embedded` prop (header-only /
body-only, no card chrome) and VerifyMessage composes them under one border.

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

*  feat(verify): derive generateVerifyPlan rubric from agencyConfig

A real agent calls `generateVerifyPlan` with just a `goal` and doesn't know
rubric ids. When `rubricId`/`criteriaIds` params are absent, derive the mounted
rubric + ad-hoc criteria from the executing agent's
`agencyConfig.verifyRubricId / verifyCriteriaIds`. Params still win when given.

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

* 🐛 fix(cli): surface agent gateway WebSocket close code + reason

The `onclose` handler logged `String(event)` → the useless "[object
CloseEvent]". Surface `event.code` (+ `event.reason` when present) so a gateway
disconnect before completion is actually diagnosable.

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

* 💄 fix(verify): rename "Run Artifact" → "Verification", drop failed red border

- The kicker said "Run Artifact" — it's automated verification, not an artifact.
  Renamed to "Verification · Round N".
- Removed the red error border on a failed check — a normal card reads better.
- Fixes a render crash (`useVerifyState is not defined`): the border removal left
  a dangling reference after the import was dropped.

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

*  feat(cli): poll run status when the agent stream drops

When the live stream (gateway WebSocket / SSE) closes before the run finishes,
the run is still executing server-side — so instead of hard-exiting, fall back to
polling `aiAgent.getOperationStatus` every 10s until the run reaches a terminal
state (or is no longer tracked). Pairs with surfacing the WS close code/reason.

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

* 💄 feat(verify): add Render for generateVerifyPlan tool call

The generateVerifyPlan tool call rendered as the default param/result dump. Add a
Render that lists the generated delivery checks (title + gate/auto-fill tag), and
surface the items on the tool state so the Render can read them.

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

*  feat(verify): auto-confirm generated plan so checks run on completion

The agent generated a plan but it stayed `planned`/unconfirmed, so the completion
hook (which gates on a confirmed plan) never ran the checks — the card was stuck
at "awaiting confirmation" with no pass/fail. In the headless agent flow there's
no one to click Confirm, so `generateVerifyPlan` now auto-confirms the plan it
generates; the checks then run automatically on completion. (An interactive
"review before run" gate is a future enhancement.)

Also: the verify card header disappeared in the draft/planned phase
(`phaseToArtifact.draft` was null). Give it a header so the card always shows its
"Verification · Round N" heading.

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

* 🐛 fix(agent-tracing): only count opaque/presentational attrs as structural noise

The first structuralNoiseRatio charged ALL markup (every <...> tag) as noise,
which over-penalized legitimately structured results 3x. Grounding against real
web-search output (`<item title="…" url="…">snippet</item>`) showed the tags and
the title=/url= attributes ARE the signal the model reads.

Now only opaque/presentational attribute names (id, class, style, data-*, aria-*,
role, on*) count as noise; semantic element tags and content-bearing attributes
(title, url, href, name…) are kept. On a 57-op user-interrupted sample this drops
web-search noise 42%→0% and overall estimated waste 16%→5%, leaving large-payload
(readDocument) and high error-rate tools as the real signal.

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

*  feat(verify): model-authored criteria with name/description/instruction-in-document + agent verifier

Restructure the generateVerifyPlan tool to a createDocument-style full-create flow
and wire up the agent verifier path:

- criteria now = title + description (required one-liner) + instruction (required
  detailed rubric); instruction lives in a linked document (verify_criteria.documentId),
  description is a new verify_criteria column (migration 0111). verifierConfig no
  longer holds description/instruction.
- generateVerifyPlan creates verify_criteria + a rubric, snapshots the plan onto
  the operation and confirms it; judge resolves the instruction from the document.
- agent-type checks run as verifier sub-agents (execAgent + isolated thread) whose
  onComplete hook parses a VERDICT and writes it back to verify_check_results
  (renamed AgentVerifierSpawner → VerifierAgentRunner).
- UI: custom Inspector for the tool header; check list shows per-verifier-type icons
  (llm/agent/program) + description + required/optional tag; i18n en/zh.

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

* ️ perf(verify): run program/llm/agent checks concurrently on completion

The three verifier kinds are independent; previously the agent spawn waited for
the batched LLM judge to finish. Run them via Promise.all so agent sub-agents
start immediately alongside the LLM batch.

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

*  feat(verify): dedicated builtin verify-agent + writeback tool, role=verify message, portal check editor

- Add `@lobechat/builtin-tool-verify` (submitVerifyResult) + builtin `verify-agent`;
  agent-type checks now run as the dedicated verify agent (not the user's agent),
  which investigates and writes its verdict back via the tool during its run.
- Verifier inherits the parent run's model/provider (builtin default may be
  unconfigured locally).
- role=verify completion message no longer requires an assistantMessageId, so the
  delivery-checker card always surfaces when a plan exists.
- Portal editor for verify checks (title/description/instruction/verifier/onFail).

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

* 🐛 fix(verify): restrict verify-agent to its writeback tool; fix running loader icon

Root cause of stuck `running` agent checks: the verify-agent ran in agent mode and
inherited all default tools (web-browsing, cloud-sandbox, skills, activator), so it
went off web-searching/crawling to "investigate" and never called submitVerifyResult.

- Run the verify-agent in chat mode (enableAgentMode: false, searchMode: off) — the
  strict whitelist — and whitelist `lobe-verify` for chat mode so the verifier gets
  ONLY its writeback tool.
- Sharpen the verify systemRole: judge from the provided deliverable/instruction
  (no external tools), always reach a verdict, and always call submitVerifyResult.
- CheckerDock: running check now uses the standard RingLoadingIcon (warning ring),
  matching the app's loader instead of a blue spinner.

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

*  feat(verify): auto-repair loop — re-run the agent with failure feedback on failed checks

When required checks fail with onFail=auto_repair, automatically run a second
iteration instead of ending at `failed`:

- createRepairRunner: re-runs the SAME agent in the same topic with the failure
  feedback as the prompt, re-snapshots the plan onto the repair operation and
  confirms it so it re-verifies on completion (the next round). Capped at
  MAX_REPAIR_ROUNDS via parent-chain depth to prevent runaway loops.
- maybeAutoRepair: fires only once every required check has a terminal result, so
  it works for inline LLM checks (triggered from lifecycle) and async agent checks
  (triggered from the verify tool's writeback path).

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

*  feat(verify): open check result detail in portal & rename artifact→result

- add a VerifyResult portal view: clicking any check row opens that result's
  detail (verdict, confidence, Toulmin sections, suggestion) on the right; agent
  checks expose their execution trace from inside the panel
- CheckerDock rows are all clickable now (chevron affordance), status shown by
  icon only; verify card uses colorBgElevated
- rename the run-result surface from "artifact" to "result" everywhere: RunArtifact
  → RunResult, phaseToArtifact → phaseToResult, and all `artifact.*` i18n keys →
  `result.*`
- ship verify namespace zh-CN / en-US locales

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

*  feat(verify): enrich check result portal — criterion stepper, richer detail view

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

*  feat(verify): rubric run-policy config + repair feedback on the verify card

Auto-repair feedback now lives on the failed round's role=verify message
(content), and the VerifyMessageProcessor surfaces it into the repair run's
context as a tagged user turn — so the repair op runs off history via a new
execAgent `suppressUserMessage` path instead of injecting a synthetic user
message. createVerifyMessage is awaited before verification to avoid a race.

maxRepairRounds becomes a rubric-level config: new `verify_rubrics.config`
jsonb column, read live at repair time via the plan's sourceRubricId. Adds a
RubricConfig portal panel (reachable from the plan card's settings affordance)
to view/edit it, wired through the verify store + TRPC.

Verify domain types/vocab/config are extracted from the DB schema into
@lobechat/types as the single source of truth; schema and consumers import
from there.

Tests: VerifyMessageProcessor dual behavior; VerifyRubricModel config
round-trip; MessageModel.findVerifyMessageByOperationId.

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

* 🗃️ refactor(verify): squash the 3 verify migrations into one

Collapse 0110 (tables) + 0111 (criteria.description) + 0112 (rubrics.config)
into a single regenerated 0110_add_verify_tables so the PR ships one clean,
idempotent migration. No schema change vs the three combined.

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

*  feat(cli): verify rubric run-policy config commands + shrink judging-rule editor font

CLI: `verify rubric create --max-repair-rounds`, `verify rubric view`, and
`verify rubric update` exercise the rubric config endpoints end-to-end; adds a
mocked command test. UI: judging-rule editor font 16px → 14px.

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

*  feat(verify): editable rubric name in the config panel + default 3 repair rounds

Add a name (title) field to the RubricConfig portal, persisted via a new
updateRubricTitle store action + service (optimistic + debounced, alongside
the config write-back). Bump DEFAULT_MAX_REPAIR_ROUNDS 2 → 3.

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

* ♻️ refactor(verify): extract generateVerifyPlan into installable lobe-delivery-checker tool

Move the delivery-checker plan-creation flow out of the always-on lobe-agent
tool into a new standalone, installable builtin tool `lobe-delivery-checker`
(Skill Store, opt-in per agent — not loaded by default). lobe-agent no longer
ships generateVerifyPlan.

- new packages/builtin-tool-lobe-delivery-checker (manifest/types/systemRole +
  client Render/Inspector/Portal moved wholesale from lobe-agent)
- new serverRuntimes/lobeDeliveryChecker.ts (generateVerifyPlan moved out of
  lobeAgent.ts), registered alongside verifyResult
- registered installable in builtin-tools (no hidden/discoverable:false, not in
  defaultToolIds/alwaysOnToolIds/runtimeManagedToolIds); renders/inspectors/
  portals/identifiers wired; lobe-agent portal entries removed
- i18n keys moved builtins.lobe-agent.verifyPlan.* → builtins.lobe-delivery-checker.*

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

*  feat(agent): add `custom` tool mode; verify agent uses it instead of chat-mode

Chat mode's contract is to strip ALL user/agent plugins (strict KB/memory/web
allow-list) — so the verify sub-agent couldn't get its writeback tool without a
leaky blanket rule. Introduce a third tool mode `custom` where the toolset is
EXACTLY the agent's declared plugins (no always-on, no defaults, no activator),
for focused builtin sub-agents.

- chatConfig.toolMode: 'agent' | 'chat' | 'custom' (overrides enableAgentMode)
- AgentToolsEngine: custom branch (defaultToolIds = plugins, rules = plugins-on,
  allowExplicitActivation only in agent mode); chatModeRules restored to strict
- verify agent → toolMode: 'custom'; lobe-verify dropped from chatModeAllowedToolIds
- test: custom mode enables exactly the declared plugin, no always-on / defaults

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:16:35 +08:00
Arvin Xu fc0daa7604 💄 style(conversation): show running indicator after a settled inline tool while generating (#15528)
 feat(conversation): show running indicator after a settled inline tool while generating

Heterogeneous agent turns render a single tool call inline (no
WorkflowCollapse chrome). Once that tool settles but the run is still
generating the next step, the inline path showed nothing below it — a
blank gap that reads as "stuck". Render the same turn-start "running"
indicator at the segment tail for this case. Multi-tool segments keep
WorkflowCollapse's own streaming header; a tool still executing is
already covered by its loading placeholder.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 02:03:50 +08:00
Arvin Xu df72bc335e 🎨 refactor(local-system): preserve ANSI escape codes in command output (#15529)
* 🎨 refactor(local-system): preserve ANSI escape codes in command output

The client now renders ANSI sequences, so stripping color codes from
shell command output is no longer needed. Drop the stripAnsi helper and
let truncateOutput keep the raw colored output intact.

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

* 🐛 fix(local-system): drop dangling ANSI escape and reset open SGR state before truncation notice

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 01:37:34 +08:00
Innei e855fcc0b8 ♻️ refactor(desktop): move backend URL rewrite into main process (#15304)
* ♻️ refactor(desktop): move backend URL rewrite into main process

Renderer code no longer needs `withElectronProtocolIfElectron` to rewrite
backend URLs to `lobe-backend://`. The Electron main process now diverts
backend-prefixed paths (`/trpc`, `/webapi`, `/api/auth`, `/market`) to the
remote LobeHub server in two places:

- prod: `RendererProtocolManager` (`app://` handler) delegates to
  `BackendProxyProtocolManager.proxy(request, session)` after the existing
  hostname guard.
- dev: `Browser.setupRemoteServerRequestHook` registers a
  `webRequest.onBeforeRequest` listener that redirects
  `http://localhost(:*)/<backend-prefix>...` to `lobe-backend://lobe<path>`.

`BackendProxyProtocolManager` keeps a per-session `WeakMap<Session, Context>`
and exposes `proxy(request, session)` so the same OIDC token / Vercel cookie
/ 401 debounce / `X-Auth-Required` pipeline serves both entry points.

The helper and ~35 call sites in `src/services/_url.ts` and the three tRPC
clients are removed. `ELECTRON_BE_PROTOCOL_SCHEME` stays for the main
process; new `BACKEND_PATH_PREFIXES` + `isBackendPath` predicate live in
`apps/desktop/src/main/const/protocol.ts`.

* ♻️ refactor(desktop): decouple renderer protocol from backend proxy via interceptor pipeline

`RendererProtocolManager` no longer imports `BackendProxyProtocolManager` or
`isBackendPath`. It exposes a generic `addRequestInterceptor(fn)` hook and
runs interceptors in order inside the `app://` handler — first non-null
Response short-circuits the file pipeline.

`BackendProxyProtocolManager.createAppRequestInterceptor()` owns the
"what counts as a backend path" knowledge and returns a 502 for backend
prefixes when no proxy context is wired up (must not fall through to SPA
HTML).

Wiring happens in `App.ts` after `RendererUrlManager` construction —
composition root knows both modules so neither has to know the other.

* ♻️ refactor(desktop): unify dev/prod renderer under app:// and drop lobe-backend://

Dev mode no longer uses `http://localhost:<port>` as the renderer origin; the
BrowserWindow now loads `app://renderer/` in both dev and prod. Non-backend
requests fall through to a strategy:

- prod: `StaticRendererFallback` serves the static export from `rendererDir`
  (Range support, SPA HTML fallback, 404 handling)
- dev:  `ViteRendererFallback` proxies to the electron-vite dev server via
  `net.fetch('http://localhost:5173/<path>')`; HMR WebSocket connects
  directly (configured via `server.hmr.{host,clientPort}` + `strictPort`)

`lobe-backend://` is gone — the scheme, its privileged registration, the
`session.protocol.handle('lobe-backend', ...)` call, and the dev
`webRequest.onBeforeRequest` trampoline are all removed.
`BackendProxyProtocolManager` now only stores per-session context and
exposes `createAppRequestInterceptor()` for the `app://` pipeline.

Dev userData is pinned to `<appData>/lobehub-desktop-dev` via a new
`pre-app-init.ts` that runs before `@/const/dir` captures
`app.getPath('userData')` — necessary because dev and prod now share the
`app://renderer` origin and would otherwise collide on localStorage /
cookies / IndexedDB.

Also adds `stream: true` to the `app` scheme privilege so dev media Range
requests survive forwarding.
2026-06-08 00:49:33 +08:00
Arvin Xu ee6a74ba06 🗃️ feat(db): verify delivery-checker schema + ai_providers/ai_models _id column (#15526)
🗃️ feat(db): delivery-checker schema + ai_providers/ai_models surrogate `_id`

The DB layer, split out so it merges ahead of its callers (services / TRPC /
store / UI ship in a follow-up stacked PR). One consolidated, idempotent
migration (0110_add_verify_tables_and_ai_infra_id):

- verify delivery-checker: verify_criteria / verify_rubrics (+ config) /
  verify_rubric_criteria / verify_check_results tables + verify_status /
  verify_plan / verify_plan_confirmed_at columns on agent_operations; plus the
  verify domain types/vocab/config in @lobechat/types the schema imports.
  All four verify tables carry a workspace_id FK + index (cascade on workspace
  delete), matching documents / agent_operations. verify_check_results has a
  UNIQUE (operation_id, check_item_id) index — one lifecycle row per plan item
  per run, so a retry / concurrent worker can't create conflicting duplicates.
- ai-infra (LOBE-10072): nullable `_id uuid DEFAULT gen_random_uuid()` on
  ai_providers / ai_models, written as the safe two-step form (ADD nullable,
  then SET DEFAULT) to avoid a full-table rewrite + ACCESS EXCLUSIVE lock;
  backfill + NOT NULL are later manual steps (LOBE-10073 / LOBE-10074)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 23:03:50 +08:00
Arvin Xu 20cea3a6bf feat(page-agent): execute tools server-side via HeadlessEditor (#15023)
*  feat(page-agent): execute tools server-side via HeadlessEditor

Page-agent tools (initPage / editTitle / getPageContent / modifyNodes /
replaceText) now run on the server against a `@lobehub/editor/headless`
instance and persist through `DocumentService.updateDocument`, instead
of executing inside the renderer's Lexical instance. The renderer
applies the resulting snapshot via the builtin-tool `onAfterCall` hook,
so the document store stays in sync without an extra fetch.

This makes page-agent execution independent of the client lifecycle
(editor unmount, tab switch, network blip), gives us full server-side
tracing for free (OTel gen-ai + agent-signal + documentHistories), and
exposes a `silent-no-op` / `unexpected-mutation` invariant when the
exported editorData hash diverges from what the handler reported.

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

* 🐛 fix(page-agent): decouple EditorRuntime from @lobehub/editor side-effecting bundle

EditorRuntime statically imported LITEXML_*_COMMAND from @lobehub/editor,
which pulls ReactSlashPlugin and crashes Node (`document is not defined`)
in any server-side test that transitively touched the runtime. The same
import also dispatched the wrong command identity on HeadlessEditor's
kernel — pnpm resolves @lobehub/editor to a different module copy than
the headless bundle, so dispatchCommand would silently no-op server-side.

Introduce a LiteXMLAdapter strategy: renderer wires command dispatch
against the live editor; server wires HeadlessEditor.applyLiteXMLBatch
/ applyLiteXML so the correct headless-bundle symbols are used.

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

* 🐛 fix(page-agent): restore client-side mutate handlers on PageEditor mount

The main commit dropped `setBeforeMutateHandler`/`setAfterMutateHandler`
under the assumption that page-agent tools always execute server-side.
But the chat-store path (`invokeBuiltinTool` → `PageAgentExecutor.modifyNodes`
→ `EditorRuntime.modifyNodes`) still routes through the client-bound
runtime whenever the LLM dispatcher is the chat slice — it does not
consult `manifest.executors`. Without the handlers, that path mutates
the live editor but skips both `documentHistoryQueueService.enqueueEditorSnapshot`
(loses undo baseline) and `commitEditorMutation(saveSource: 'llm_call')`
(row never persists).

Re-wire both handlers. Server-runtime path is unaffected: it instantiates
its own `EditorRuntime` against `HeadlessEditor` and never sees the
client's StoreUpdater wiring, so the two paths can coexist without
double-writing.

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

* ♻️ refactor(editor-runtime): split client / server entries so renderer gets adapter for free

Renderer call sites shouldn't have to opt in to the obvious default
(dispatch LITEXML_*_COMMAND on the live editor). Split the package into
two entries:

- `@lobechat/editor-runtime` — renderer entry; constructor auto-wires
  the LiteXML adapter from `@lobehub/editor`. Static-importing this
  from Node still crashes (ReactSlashPlugin), so it's the right shape
  for the browser only.
- `@lobechat/editor-runtime/server` — server-safe entry; exports the
  bare class without touching `@lobehub/editor`. Callers (currently
  only the page-agent server runtime) supply their own HeadlessEditor-
  backed adapter.

Drops the renderer-side setLiteXMLAdapter patch and a stale comment
block in StoreUpdater.

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

* ♻️ refactor(page-agent): drop LiteXMLAdapter, dispatch commands directly

`@lobehub/editor` 4.16.1 ships the LiteXML command identities through the
side-effect-free `@lobehub/editor/litexml-commands` subpath, so a single command
object is shared across the browser and node bundles and can be imported in Node
without pulling the DOM-dependent editor bundle.

`EditorRuntime` now imports `LITEXML_MODIFY_COMMAND` / `LITEXML_APPLY_COMMAND`
from that subpath and dispatches them straight onto the editor kernel. This
removes the `LiteXMLAdapter` strategy object (`setLiteXMLAdapter` /
`getLiteXMLAdapter`) — a leaky abstraction whose only purpose was to keep the
crash-on-Node command import out of the shared base.

- editor-runtime: dispatch `LITEXML_*_COMMAND` directly; delete the adapter
  interface, field, setter and runtime-throw guard.
- Collapse the client/server entry split (its sole reason — isolating the
  DOM-crashing import — is gone); both entries now re-export the isomorphic base.
- pageAgent server runtime: drop the HeadlessEditor-backed adapter wiring.
- Bump `@lobehub/editor` to ^4.16.1.

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

* ♻️ refactor(editor-runtime): drop redundant /server entry

Now that `EditorRuntime` is isomorphic (LiteXML commands come from the DOM-free
`@lobehub/editor/litexml-commands` subpath), the `./server` entry is byte-for-byte
identical to the root `.` entry. Remove it and point the only consumer
(pageAgent server runtime) at the root entry.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 22:33:41 +08:00
Arvin Xu 78657d496e 🐛 fix(desktop): pin electron-builder to 26.14.0 to fix broken macOS update signing (#15527)
electron-builder was floating on `^26.8.1` and the repo commits no lockfile,
so each CI build resolved a fresh version. The canary.12 build (2026-06-07)
picked up 26.15.0, which regressed macOS .app bundle signing: codesign reports
"bundle format is ambiguous (could be app or framework)" and Squirrel.Mac
rejects the update during code-signature validation, so the app never quits
to install — surfacing as "auto-update does nothing".

26.15.0 introduced the two suspect changes (mac signing rework #9822 and the
full app-builder-bin Go→TS replacement #9829). 26.14.0 predates both and does
not touch macOS app-bundle signing/layout. Pinning the exact version cascades
to app-builder-lib / dmg-builder / builder-util (electron-builder pins those
exactly), stopping the toolchain from floating across CI installs.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:20:26 +08:00
Arvin Xu 2453fc3515 🐛 fix(desktop): skip browser beforeunload guard so auto-update can quit (#15525)
On desktop the chat-loading beforeunload guard (preventLeavingFn) blocks
window.close() during quitAndInstall, so the app fails to quit & install
the update. The main process already manages close/quit via keepAlive +
isQuiting, so short-circuit the guard on desktop.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 17:42:33 +08:00
Coooolfan a28fd30719 feat: suppport sandbox provider (#15184)
*  feat(cloud-sandbox): add Onlyboxes provider support for self-hosted sandbox (#15136)

- Add `SANDBOX_PROVIDER` env var (market | onlyboxes) to select sandbox backend
- Add Onlyboxes-specific env vars: `ONLYBOXES_BASE_URL`, `ONLYBOXES_API_TOKEN`, `ONLYBOXES_LEASE_TTL_SEC`
- Create `SandboxService` abstraction layer with `MarketSandboxService` and `OnlyboxesSandboxService` implementations
- Add `createSandboxService` factory that routes to configured provider
- Migrate `execInSandbox` and `exportFile` t

*  feat(sandbox): improve Onlyboxes export flow

* 🐛 fix(sandbox): pass presigned upload headers to Onlyboxes

*  test(sandbox): import tool runtime package

* 🐛 fix(sandbox): preserve Market export errors

* 🐛 fix(sandbox): allow empty docker env defaults

* 🔒 fix: redact sandbox auth params in logs

* 🐛 fix: address sandbox provider review comments

* 🔐 feat: use onlyboxes jit tokens

* 📝 docs: clarify cloud sandbox provider config

* 🐛 fix: align cloud sandbox timeout defaults

* 🐛 fix(sandbox): lower default Onlyboxes lease TTL to 15 minutes

* 🐛 fix(sandbox): cap Onlyboxes task wait time

* ♻️ refactor: split sandbox env config
2026-06-07 12:18:39 +08:00
Arvin Xu c711279edf feat(tools): show app-fixed tools in the chat-input Pinned section (#15509)
*  feat(tools): show app-fixed tools in the chat-input Pinned section

Surface always-on, runtime-owned tools (lobe-agent + always-on infra) read-only
at the top of the Tools popover "Pinned" group, so users can see what the app
keeps active for every conversation. These have no toggle — a Pin indicator with
a hint replaces the per-tool policy menu.

- builtin-tools: add `fixedDisplayToolIds` ([lobe-agent, ...alwaysOnToolIds])
- builtin selectors: add `fixedDisplayMetaList` (reads hidden tools by id)
- useControls: render read-only fixed items, prepend to Pinned, fold into counts
- i18n: add `tools.activation.fixed.hint` + `tools.builtins.lobe-agent.*`

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

* 🐛 fix(tools): make lobe-agent actually always-on; gate fixed display to runtime

The Pinned section was rendering tools that aren't enabled every turn:
- lobe-agent was only enabled when injected into plugins/runtime ids (it has no
  rule in the engine, so it defaulted to disabled) — showing it as "always on"
  was a UI lie.
- manual skill-activate mode strips manualModeExcludeToolIds (activator,
  skill-store) from the defaults, so they're off — but they still showed as fixed.

Fixes:
- Add lobe-agent to alwaysOnToolIds so its core capabilities (plan/todo, sub-agent
  dispatch, visual-media fallback) are genuinely on every agent-mode turn. Chat
  mode still drops alwaysOn entirely.
- Derive fixedDisplayToolIds from alwaysOnToolIds (single source of truth, no drift).
- Make fixedDisplayMetaList mode-aware: drop manualModeExcludeToolIds in manual mode
  so the Pinned list matches what the engine actually enables.
- Update engine tests that asserted the old "lobe-agent off by default" behavior.

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

* ♻️ refactor(tools): drop fixedDisplayToolIds alias, use alwaysOnToolIds directly

fixedDisplayToolIds was just `= alwaysOnToolIds`; collapse it. The selector now
reads alwaysOnToolIds directly and still applies the manual-mode exclusion.

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-07 12:10:32 +08:00
Arvin Xu e7c73bd4ce 💄 style: support show CC subagent metrics chip (#15217)
*  feat(cc): show tool count + token + model metrics on Agent inspector chip

Surface per-subagent progress on the inline Agent inspector row so users can
see how much work has happened without expanding the thread:

- Inspector chip renders `[count] tools · [tokens]` after the description
  chip, with the model name in a Tooltip. Tool count = count of `role==='tool'`
  child messages; tokens = LAST subagent assistant's `metadata.usage.totalTokens`
  (CC's per-turn `message.usage` already includes the full prior context,
  so summing would double-count the shared history — the final turn's value
  matches the main-agent message-footer convention).
- New `threadSelectors.getThreadDbMessages` reads the raw DB-shape child
  messages from `dbMessagesMap[thread_*]` (the display-bound `messagesMap`
  bucket only holds the parent + a virtual `assistantGroup`).
- `BuiltinInspectorProps` carries `toolCallId` so the chip can join to its
  subagent Thread via `metadata.sourceToolCallId`; propagated from both the
  chat Inspector caller and the DevPanel `ToolInspectorSlot`.

Adapter / executor changes so subagent token usage actually flows in:
- `claudeCode.ts` `handleSubagentAssistant` emits a
  `step_complete{phase:turn_metadata, subagent}` event when
  `raw.message.usage` is present. Subagent assistant events are not
  partial-streamed (unlike main-agent), so `message.usage` is
  authoritative — no de-stale logic needed. The subagent ctx tag lets
  the executor route the usage write onto the in-thread assistant
  instead of the main agent's, so CC's `result_usage` grand-total
  semantics aren't double-counted.
- Renderer + server `step_complete{turn_metadata}` branches check for
  `event.data.subagent` and route to the run's `currentAssistantMsgId`.
  Renderer mirrors the write into `dbMessagesMap` via `run.stream.update`
  so the chip's selector picks up usage as it lands.

Server-side finalize rolls totals onto `thread.metadata` for the
historical-view cold-load path: tool count from `lifetimeToolCallIds.size`,
tokens from the last in-thread assistant's `metadata.usage.totalTokens`,
plus `completedAt` / `duration`. Done via the existing `threadModel.update`
with an inline metadata read-merge — no new `ThreadModel.updateMetadata`
method or `threadRouter.updateThreadMetadata` endpoint introduced.

i18n: 5 keys under `chat.thread.subagentMetrics.*` in `chat.ts` + zh-CN +
en-US.

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

* 🐛 fix(cc): persist subagent metrics so the inspector chip survives cold-load

The metrics chip (tool count · tokens, model in tooltip) only rendered while
the run streamed — after a reload it vanished on desktop. Two gaps:

- The renderer `heterogeneousAgentExecutor.finalizeSubagentRun` never rolled
  totals onto `thread.metadata` (only the server `HeterogeneousPersistenceHandler`
  did). On cold-load the child messages aren't hydrated, so the live selector
  had nothing to read and the chip's `hasAny` went false. Added the symmetric
  rollup (`totalToolCalls` / `totalTokens` / `completedAt` / `duration`),
  re-sending the create-time `sourceToolCallId` / `subagentType` / `startedAt`
  since `updateThread` replaces the whole metadata column.
- Subagent assistant messages carried no `model`, so the tooltip's model line
  never showed. The subagent `turn_metadata` branch now writes `model` /
  `provider` onto the in-thread assistant (live tooltip) and persists `model`
  onto `thread.metadata.model` (cold-load tooltip); the chip selector falls
  back to `thread.metadata.model`.

Also fixes a latent bug both paths shared: finalize read `totalTokens` off
`currentAssistantMsgId`, which by then points at the freshly-created terminal
assistant (no usage), so it always resolved `undefined`. Now tracks the last
non-zero per-turn `totalTokens` on the run — matching the live selector's
"last turn, not a sum" convention.

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

* ♻️ refactor(cc): derive subagent chip metrics on read, drop run-state tracking

The chip's tool-count / token / model metrics were captured incrementally on
the subagent run (`lastTurnTokens` / `subagentModel`) and denormalized onto
`thread.metadata` at finalize — in BOTH the renderer executor and the server
handler, so the rule lived in three places and the two finalize paths had to
be kept in sync by hand.

Derive them on read instead, from the child messages (the single source of
truth):

- `aggregateSubagentMetrics(messages)` (new, `src/utils`) is the one rule:
  COUNT `role='tool'`, SUM every assistant turn's `usage.totalTokens`, pin the
  model. SUM (not last-turn) matches the project's token-usage heatmap
  convention — "total tokens processed".
- The chip selector aggregates the in-memory child messages live, falling back
  to `thread.metadata.*` on cold-load.
- `threadModel.queryByTopicId` computes the SAME projection in SQL (LEFT JOIN +
  GROUP BY, reusing the `usage->totalTokens` index, with a legacy
  `metadata.usage` fallback) and folds it onto `metadata`, so cold-load reads a
  server-derived value without hydrating the child messages.

Both finalize paths drop the metadata rollup and now only flip thread status
Active; `lastTurnTokens` / `subagentModel` run-state fields are gone. Each
subagent turn still writes its `usage` + `model` onto the in-thread assistant —
those rows are what the read-time aggregation sums over.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 02:21:53 +08:00
Arvin Xu 28f0117932 💄 style(tool-ui): render ANSI escape codes in RunCommand output (#15516)
 feat(tool-ui): render ANSI escape codes in RunCommand output

Parse ANSI SGR sequences in shell stdout/stderr with anser and emit
styled spans for fg/bg colors, dim, bold, italic, underline, strikethrough.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 01:34:16 +08:00
Arvin Xu 573cc5b798 💄 style(desktop): move panel toggle into titlebar top-left (#15515)
*  feat(desktop): move panel toggle into titlebar top-left

Place a persistent collapse/expand toggle at the titlebar's top-left
corner on desktop, to the right of the macOS traffic lights. The
NavigationBar now splits into a left group (toggle) and a right group
(back / forward / clock) with space-between: expanded, the right group
hugs the sidebar's right edge; collapsed, the controls cluster at the
left edge like codex.

ToggleLeftPanelButton gains an optional `id` prop so the titlebar
instance can opt out of the shared TOGGLE_BUTTON_ID, avoiding a
duplicate DOM id and NavPanelDraggable's hover-reveal CSS.

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

* 🐛 fix(desktop): expand untracked directories in git status

`git status --porcelain` defaults to `--untracked-files=normal`, which
collapses whole untracked directories into a single `?? path/` entry.
That trailing-slash path then flowed into `readUntrackedAsPatch` as if
it were a file — `stat()` reported `isFile()=false`, an empty patch was
returned, and the Review panel rendered "无法加载该文件的 diff" against
a directory row. Pass `-u` so git expands those directories into their
individual files; each file then produces a real synthetic patch.

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

* 💄 style(desktop): scope titlebar toggle to macOS, hide in-page toggles there

The persistent titlebar toggle now renders only on macOS; Windows/Linux
keep the original right-aligned navigation controls and their in-page
toggles.

On macOS desktop, ToggleLeftPanelButton instances hide themselves (the
titlebar owns the control) unless `forceVisible` is set, removing the
now-redundant sidebar-header and content-header toggles. NavHeader also
skips rendering its empty toggle-only bar 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-07 00:42:57 +08:00
Rdmclin2 7b54edc665 🐛 fix(database): scope ai-infra upsert conflict targets to workspace (precursor for 0110) (#15507)
🐛 fix(database): scope ai-infra upsert conflict targets to personal partial index

The 0110 migration replaces the (id, user_id) / (id, provider_id, user_id)
primary keys with partial unique indexes (WHERE workspace_id IS NULL). A bare
ON CONFLICT target can no longer infer a partial index, so add
`targetWhere: isNull(workspaceId)` (and `where` for onConflictDoNothing) to
every personal-scope upsert. Keeps existing provider/model toggling, ordering
and batch upserts working after the migration.
2026-06-07 00:40:08 +08:00
Arvin Xu b6ae130c97 feat(agent): auto-scan project workspace (skills + AGENTS.md) for server agents (#15512)
*  feat(agent): auto-scan project workspace (skills + AGENTS.md) for server agents

When a server agent runs against a bound project directory, scan it server-side
at run start for project skills (.agents/skills + .claude/skills) and root
AGENTS.md/CLAUDE.md, cache the result on devices.workingDirs[].workspace (1h TTL),
surface skills in <available_skills>, and inject instructions into the system role.
Replaces the desktop-only client pre-scan so it works for any run initiator.

- Generic device RPC channel (invokeRpc / rpc_request) for server-internal device
  methods, separate from the LLM-facing tool-call path
- New desktop WorkspaceCtr owns project-skill / workspace scanning

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

* 🐛 fix(agent): preserve workspace-init cache on device cwd save

device.updateDevice validates workingDirs as { path, repoType } only, so zod
strips the server-written workspace / workspaceScannedAt cache — an ordinary cwd
pick wiped the 1h workspace-init cache (and web reuse), forcing every later run
to rescan. The cache is server-owned, so re-attach it by path from the stored
row instead of trusting the client to round-trip it.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:26:48 +08:00
Arvin Xu 5b5794baa4 ♻️ refactor(server): rename deviceProxy → deviceGateway (#15513)
Pure mechanical rename of the server device-relay module/class/singleton
(deviceProxy → deviceGateway, file included) to match the underlying
GatewayHttpClient naming. No behavior change. Split out of the workspace-init
feature PR (lobehub/lobehub#15512) to keep that diff reviewable.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:07:30 +08:00
Arvin Xu 04700bed52 feat(agent-runtime): server callSubAgent async suspend/resume (#15481)
*  feat(agent-runtime): add waiting_for_async_tool parked state for deferred tools

Add a dedicated `waiting_for_async_tool` operation status that mirrors
`waiting_for_human` as a non-terminal, resumable pause, and migrate the
client-tool execution pause off `interrupted` onto it — so `interrupted`
once again means only user-initiated cancellation.

Also add the AgentOperationModel primitives the upcoming server sub-agent
bridge needs: queryByParentOperationId (reconcile child ops) and
tryResumeFromAsyncTool (atomic single-fire CAS).

Foundation for the server sub-agent suspend/resume mechanism (LOBE-9763).

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

* ♻️ refactor(agent-runtime): extract isParkedStatus / isBlockedStatus predicates

Replace the repeated `status === 'waiting_for_human' || ... === 'waiting_for_async_tool' || ... === 'interrupted'`
chains with named predicates so the parked/blocked semantics live in one place
(runtime step-loop break, completion lifecycle completedAt, executeSync pause,
operation isActive).

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

* ♻️ refactor(aiAgent): rename execSubAgentTask -> execSubAgent

Full rename of the service method, its `ExecSubAgentTaskParams`/`ExecSubAgentTaskResult`
types, the tRPC endpoint, the injected `RuntimeExecutorContext`/`AgentRuntimeServiceOptions`
callback, and tests. Group-mode `execGroupSubAgent*` identifiers are intentionally left
untouched. Prep for the server sub-agent suspend/resume work (LOBE-9763).

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

* Revert "♻️ refactor(aiAgent): rename execSubAgentTask -> execSubAgent"

This reverts commit f1ea407d74.

*  feat(agent-runtime): add deferred-tool park infrastructure

Introduce a generic `deferred` result flag (BuiltinServerRuntimeOutput /
ToolExecutionResult). When a tool returns deferred, call_tool parks the
operation (waiting_for_async_tool + pendingToolsCalling) without writing a
tool_result — mirroring the client-tool pause — so the result can be
delivered out-of-band later by a completion bridge. Thread the existing
execSubAgentTask DI seam into ToolExecutionContext so async tools can spawn
a child op without a circular import.

Part of the server sub-agent suspend/resume mechanism (LOBE-9763).

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

*  feat(agent-runtime): park call_tools_batch on deferred tools

Mirror the call_tool deferred-park on the parallel path: deferred (async)
tools are collected during the concurrent batch and, once server tools
settle, the operation parks (waiting_for_async_tool + pendingToolsCalling)
alongside any client tools — so K parallel sub-agents in one round all
resolve before the parent resumes.

Part of the server sub-agent suspend/resume mechanism (LOBE-9763).

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

*  feat(agent-runtime): server callSubAgent async suspend/resume bridge

Turn the server `callSubAgent` path from fire-and-forget into a real
deferred-tool suspend/resume loop (LOBE-9763 Phase 2):

- lobeAgent server runtime: add `callSubAgent` executor returning a
  `deferred` result via an injected `ctx.subAgent` runner
- RuntimeExecutors: build a per-tool-call server sub-agent runner that
  creates the pending placeholder tool message (anchoring the isolation
  thread) and kicks off the child op
- aiAgent.execSubAgentTask: register an onComplete bridge hook that
  backfills the placeholder and resumes the parent
- AgentRuntimeService: `tryResumeParentFromAsyncTool` (barrier over
  pendingToolsCalling + single-fire CAS + schedule), `refreshMessagesFromDB`,
  and the `resumeAsyncTool` branch in executeStep
- queue/local: forward `payload` to the execution callback so local/in-memory
  resumes (and human-approval) no longer drop their signal

Tests: callSubAgent executor unit tests, tryResumeParentFromAsyncTool
barrier/CAS unit tests, and a server suspend/resume integration test.

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

* 🐛 fix(agent-runtime): keep hooks across waiting_for_async_tool park

The async sub-agent resume reuses the SAME operationId, but dispatchHooks
fired onComplete and unregistered all hooks on every non-continue step —
including the waiting_for_async_tool park. That made completion consumers
(webhooks, bot promises, eval snapshots) fire prematurely on the park and
miss the real terminal state after resume.

For waiting_for_async_tool, persist the parked status (the resume CAS reads
it) but skip onComplete and keep hooks registered, so the eventual resume
under the same op still notifies consumers. waiting_for_human is unchanged
(its resume runs under a new operationId).

Found via the server-subagent agent-eval (real LLM, in-memory runtime):
parent now correctly reaches `done` after the sub-op completes.

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

* 🐛 fix(agent-runtime): unwrap QStash body.payload in runStep handler

QStashQueueServiceImpl nests resume/intervention fields under `body.payload`
(operationId/stepIndex/context stay top-level), but the runStep handler
destructured them from the top level. In production/QStash the resumed step
therefore saw `resumeAsyncTool` (and approvedToolCall/toolMessageId/…) as
undefined and never ran the waiting_for_async_tool DB-refresh/clear-pending
branch — the parent op would stay parked forever. The local queue spreads
payload itself, which masked this in local/eval runs.

Merge `body.payload` over the top-level body so both shapes work. Adds a
handler test asserting the QStash-nested payload reaches executeStep.

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

* 🐛 fix(agent-runtime): unpark parent when callSubAgent fails to start

When a server callSubAgent child op fails to start, no completion bridge
ever fires, so the parent stayed parked in `waiting_for_async_tool`
forever. The runner now drops the placeholder and signals `started:false`
so callSubAgent surfaces an inline tool error instead of parking the
parent — the batch continues (or parks only for genuinely-deferred
siblings, whose barrier already counts this error result).

Also:
- add isParkedStatus/isBlockedStatus to the @lobechat/agent-runtime test
  mock — persistCompletion/getOperationStatus call isParkedStatus, so the
  missing export crashed dispatchHooks (swallowing onComplete) and
  getOperationStatus, failing 3 AgentRuntimeService tests.
- fix completion-bridge totalToolCalls path (finalState.session.toolCalls
  → finalState.usage.tools.totalCalls; the former never existed).
- remove dead AgentOperationModel.queryByParentOperationId (zero callers).

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 22:46:10 +08:00
Arvin Xu ad87e43b2e feat(agent-tracing): tool-result feedback quality analysis (tq command) (#15508)
*  feat(agent-tracing): add tool-result feedback quality analysis (tq command)

Adds a shared, no-LLM analyzer that scores how "clean / LLM-friendly" the
environment feedback (tool return content) is, plus an `agent-tracing tq`
CLI command to preview it over a snapshot corpus.

- src/analysis/toolFeedback.ts: pure analysis lib (reusable core) — per
  tool-result metrics (tokens, self-redundancy, structural-noise ratio,
  error flag/size, format) + op-level and corpus-level rollups.
- src/cli/tool-quality.ts: `tq` (alias `tool-quality`) — token-size
  histogram, dirty leaderboard ranked by token-weighted waste, single-op
  drill-down, and --json.

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

* 🐛 fix(agent-tracing): guard against undefined histogram bucket in buildCorpusReport

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:31:06 +08:00
Arvin Xu 32c293f8c0 feat(claude-code): add per-question custom input to askUserQuestion (#15506)
*  feat(claude-code): add per-question custom input to askUserQuestion

Let users write their own answer as the trailing item in each question's
option list, beside picking a numbered choice. Single-select treats the two
as mutually exclusive; multi-select appends the custom text as an extra
entry. Merged into the question's answer at submit, so the bridge formatter
and completed Render need no changes. Draft round-trips via a __custom__:
prefix on the existing askUserDraft map.

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

* ♻️ refactor(claude-code): split askUserQuestion form & drop draft key prefix

Break the single ~530-line AskUserQuestion.tsx into a folder:
- draft.ts        pure helpers (read/buildSubmitPayload/isQuestionAnswered)
- useAskUserForm.ts  all state + handlers + draft persistence
- OptionCard.tsx / QuestionPanel.tsx  presentational pieces
- index.tsx       thin view

Also drop the `__custom__:<question>` draft-key prefix: persist the draft as
a typed object { picks, custom, escapeText, escapeActive } instead of a flat
string-keyed map. The picks/custom split now lives in named fields, so the
only sentinel left is `__freeform__` — and only in the submit payload, which
is the actual bridge contract. No behaviour change.

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

* 🐛 fix(claude-code): make AskUserDraft assignable to setInterventionDraft

`setInterventionDraft` takes `Record<string, unknown>`; an `interface` isn't
assignable to it (open to declaration merging, so no implicit index
signature). Switch `AskUserDraft` to a `type` alias, which is closed and
satisfies the index signature. Fixes the tsgo TS2345 in CI.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:30:19 +08:00
LiJian 6f5a633c9f feat(connector): Connectors system — API-level tool permissions with plugin fallback (#15463)
*  feat(connector): add ConnectorModel, ConnectorToolModel, tRPC router, and inferCrudType util (LOBE-9984, LOBE-9985)

- packages/database/src/models/connector.ts: ConnectorModel with create/delete/query/queryByIdentifiers/findById/update/updateStatus
- packages/database/src/models/connectorTool.ts: ConnectorToolModel with upsertMany (preserves user permission on sync), updatePermission, queryByConnector, queryByConnectorIds
- src/libs/mcp/utils.ts: inferCrudType() — name-based CRUD type inference (delete > update > read > write)
- src/server/routers/lambda/connector.ts: tRPC router with list/create/update/delete/syncTools/updateToolPermission
- src/server/routers/lambda/index.ts: register connectorRouter

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

*  feat(connector): runtime integration — connector-first tool resolution with plugin fallback (LOBE-9986)

- src/libs/mcp/buildConnectorManifests.ts: converts user_connector_tools rows into LobeToolManifest entries; maps permission → humanIntervention ('needs_approval' → 'required', 'disabled' → excluded)
- src/server/services/aiAgent/index.ts:
  - queryByIdentifiers(agentPlugins) to find matching connectors first
  - filter installedPlugins to exclude connector-covered identifiers
  - inject connectorManifests as additionalManifests into createServerAgentToolsEngine
  - add connector stdio tools to client executor map

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

*  feat(connector): add connector Zustand store slice (LOBE-9987)

- src/store/tool/slices/connector/: new slice with ConnectorState, ConnectorAction, connectorSelectors
  - fetchConnectors, createConnector, deleteConnector, syncConnectorTools, disconnectConnector
  - updateToolPermission with optimistic update + rollback
  - connectorToolsGrouped selector splits tools into read / write groups
- Wired into ToolStore (initialState + store.ts)

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

*  feat(connector): add Connectors UI feature — list, detail, tool permission editor (LOBE-9988)

- src/features/Connectors/: new feature with two-panel layout (list + detail)
  - ConnectorList: groups connectors by Connected / Not connected, Add button
  - ConnectorDetail: sync button, disconnect, tool permission groups (read/write)
  - ToolPermissionGroup: collapsible with batch set (auto/approval/disable all)
  - ToolPermissionRow: three-state toggle auto(✓) / needs_approval() / disabled(🚫)
  - AddConnectorModal: name + MCP URL input via @lobehub/ui/base-ui Modal

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

*  feat(connector): add Connectors tab to Agent customization panel (LOBE-9989)

- src/store/global/initialState.ts: add ChatSettingsTabs.Connector = 'connector'
- src/features/AgentSetting/AgentCategory/useCategory.tsx: add Connectors tab with LinkIcon
- src/features/AgentSetting/AgentConnectors/: new component listing user connectors with toggle
  - toggle calls toggleAgentPlugin(connector.identifier) — reuses agents.plugins[] field
  - shows per-connector tool count
- src/features/AgentSetting/AgentSettingsContent.tsx: render AgentConnectors for Connector tab

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

*  feat(connector): wire Connectors feature to /settings/connector route

- src/store/global/initialState.ts: add SettingsTabs.Connector = 'connector'
- src/routes/(main)/settings/hooks/useCategory.tsx: add Connectors item (LinkIcon) after Skills in AI config group
- src/routes/(main)/settings/features/componentMap.ts: map SettingsTabs.Connector → '../connector'
- src/routes/(main)/settings/features/SettingsContent.tsx: render Connector tab full-width (no SettingContainer), same as Provider
- src/routes/(main)/settings/connector/index.tsx: route page rendering the Connectors feature

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

* 🐛 fix(connector): use cssVar.property syntax in createStaticStyles (not function call)

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

*  feat(connector): refactor /settings/skill to unified master-detail tool manager

## Backend
- connector.ts: add syncBuiltinTool — bootstraps user_connectors from builtin manifest api[]
- connector.ts: add syncPluginTools — bootstraps user_connectors from user_installed_plugins manifest
- connector.ts: upsertConnectorEntry helper + resolveDefaultPermission (maps humanIntervention → permission)
- connectorTool.ts: SyncToolInput.defaultPermission — per-tool default for new rows, existing rows preserved

## Store
- connector/selectors.ts: add connectorByIdentifier, connectorToolsGroupedByIdentifier, isSyncingByIdentifier
- connector/action.ts: add syncBuiltinTool, syncPluginTools (idempotent — safe to call on panel open)

## /settings/skill refactor
- index.tsx: two-panel master-detail layout (left: 300px skill list, right: detail + permissions)
- SkillList: add onSelect + selectedIdentifier props, pass through to builtin/mcp items
- BuiltinSkillItem: add onSelect + isSelected (selection highlight, click triggers right panel)
- McpSkillItem: add onSelect + isSelected
- SkillDetail (new): auto-syncs connector entry on mount, then renders ConnectorDetail permission editor
- SettingsContent: Skill tab now renders full-width (same as Provider/Connector)

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

* 🐛 fix(skill): createStaticStyles returns static object, not a hook

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

* 🐛 fix(skill): wire onSelect to all skill item types — LobehubSkillItem, KlavisSkillItem + error handling in SkillDetail

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

* 🐛 fix(connector): use createStaticStyles correctly — static object, not hook; use string concat instead of cx()

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

*  feat(skill): whole row clickable in list mode, hide action buttons when onSelect provided

All 5 item types (Builtin/Mcp/Lobehub/Klavis/AgentSkill):
- When onSelect is provided (list mode): entire row is clickable, action buttons hidden
- When onSelect is not provided (other usages): original behavior preserved
- Added onSelect/isSelected to AgentSkillItem + wired in SkillList for all agent skill types
- SkillDetail: show friendly message instead of error when skill has no tool permissions

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

* 🐛 fix(connector): route sync action by sourceType; improve no-tools skill UI

ConnectorDetail:
- builtin → Reset (syncBuiltinTool from local manifest, resets permissions to defaults)
- marketplace → Refresh (syncPluginTools from installed plugin manifest)
- custom MCP → Sync (syncTools via remote MCP server, existing behavior)
- Hide Disconnect button for builtin/marketplace (only MCP connectors can disconnect)
- Show 'No tool permissions' message when connector has 0 tools
- Fix hooks-rules violation: move useCallback before early return

SkillDetail:
- Catch sync failure cleanly — shows graceful 'no tool permissions' panel
- Show skill identifier as title even when no tools available

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

*  feat(skill): inline AgentSkillDetail for agent skills; clean ConnectorDetail layout

SkillDetail:
- Add 'agent-skill' ToolDetailType — renders AgentSkillDetail inline (no modal, no connector sync)
- All hooks called before conditional returns (fixes rules-of-hooks)

SkillList:
- Pass type='agent-skill' for market/user agent skills (UUID identifiers, not plugin identifiers)

ConnectorDetail:
- Remove 'Tool permissions / Choose when AI...' subheader — tool groups render directly
- Cleaner layout: name → sync/disconnect buttons → tool groups

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

*  feat(skill): description in ConnectorDetail header + builtin-skill detail panel

Backend (connector.ts):
- syncBuiltinTool: store manifest meta.description + meta.avatar in connector.metadata
- syncPluginTools: same for plugin manifest meta
- upsertConnectorEntry: always update metadata on re-sync (keeps description fresh)

ConnectorDetail:
- Show connector.metadata.description below name in header

SkillDetail:
- Add 'builtin-skill' ToolDetailType for builtinSkills (Artifacts, Task, AgentBrowser)
  → Shows avatar + name + description panel; no connector sync needed (prompt-based)
- Add 'builtin-skill' type: reads from store builtinSkills array by identifier

SkillList:
- builtinAgent items → pass type='builtin-skill' (not 'builtin') to SkillDetail

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

*  feat(skill): fix crudType for camelCase, show skill content, compact items + categorized groups

inferCrudType (utils.ts):
- Fix: use prefix ^ anchoring instead of \b word boundary
- getReactions/listPins/searchMessages now correctly → 'read' (not 'write')
- \b fails on camelCase: 'getreactions' has no boundary after 'get' (both \w chars)

SkillDetail:
- builtin-skill type: render builtinSkill.content via <Markdown variant='chat'>
- Artifacts/Task/LobeHub skills now show their full markdown content in right panel

style.ts:
- Compact skill items: icon 48→36px, padding-block 12→6px

SkillList:
- Remove old flat renderIntegrations() + Divider
- Add categorized sections with headers:
  LobeHub 内置 Tools | 内置 Skill | 社区 Skill | 社区 Tools | 自定义
- Add sectionHeader style

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

*  feat(skill): collapsible sections, compact items matching reference design

style.ts:
- icon: 28→24px, no background (reference style: plain icon, no container bg)
- padding-block: 4→3px, font-size: 13px
- sectionHeader: collapsible with hover state

SkillList:
- Sections are collapsible — click header to toggle
- ChevronDown/ChevronRight icons on section headers
- All renderSection calls now pass a unique key

All item components (Builtin/Mcp/Lobehub/Klavis/AgentSkill):
- gap: 16→8px (tighter horizontal spacing)
- avatar/icon: 32→22px (matches reference ~24px icon)
- In list mode (onSelect): tag moves to RIGHT side of row
- In list mode: remove tag from title area, status text below title

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

*  feat(skill): default select first item; + button opens Add custom connector modal

index.tsx:
- Auto-select first installed builtin tool (or first builtin skill) on page load
- + button → opens AddConnectorModal (add custom MCP connector)
- 技能商店 button → still opens skill store (unchanged)

AddConnectorModal:
- Add Advanced settings section (collapsible chevron)
- OAuth Client ID field → stored in oidcConfig.clientId
- OAuth Client Secret field (UI only, encryption path TBD)
- Clear all fields on cancel/submit

Connectors/index.ts: export AddConnectorModal

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

*  feat(skill): reference-quality UI polish + Connectors/Skills tab switcher

Style polish (matching linear-tool-permissions demo):
- style.ts: icon 20px, padding-block 6px, font-size 14px (no bold)
- All item avatars: 16px
- ToolPermissionRow: py-10px px-12px, font-mono tool names, 15px icons, hover bg
- ToolPermissionGroup: rounded badge for count, outline 'Custom ▾' batch button
- ConnectorDetail: restore 'Tool permissions' h3 + subtitle

Connectors/Skills tab switcher:
- Top of left panel: Connectors tab | Skills tab
- Connectors: builtin tools + OAuth connectors + community/custom MCPs
- Skills: builtin agent skills + community/user agent skills
- Switching tabs resets selection and auto-selects first item in new view
- + button only shown in Connectors view

SkillList: add viewMode='connector'|'skill' prop with filtered section display

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

* 🐛 fix(skill): active permission state + Lobehub OAuth skill tools sync

ToolPermissionRow:
- btnActive: use primary color + primaryBg background (clearly visible selected state)

connector router:
- Add syncToolsFromClient: accepts client-provided tool list for skills that already
  have their tool list fetched (Lobehub OAuth skills, etc.)

Store action:
- Add syncToolsFromClient action

SkillDetail:
- Add 'lobehub-connector' ToolDetailType
- For lobehub-connector: reads server.tools from lobehubSkillStore (already populated
  after OAuth connect) and syncs via syncToolsFromClient — no remote MCP call needed

SkillList:
- Pass type='lobehub-connector' for Lobehub OAuth items (was 'plugin', wrong path)

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

* ♻️ refactor(connector): replace 'Tool permissions' header with connector description

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

* 🐛 fix(connector): show disabled tools in settings UI (only filter at runtime)

connectorToolsGrouped: remove permission !== disabled filter — all tools should
be visible in ConnectorDetail so users can re-enable them. Disabled filtering
already happens at runtime in buildConnectorManifests and queryByConnectorIds.

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

*  feat(skill): section lowercase, 4-group tools, remove tags in list mode

SkillList: remove text-transform: uppercase from sectionHeader
ConnectorDetail: split tools into 4 groups — Read / Create / Update / Delete
  (maps to crudType: read / write / update / delete)
connectorToolsGrouped selector: return { readTools, createTools, updateTools, deleteTools }
All item components: remove SkillSourceTag in list mode (onSelect provided)
  — tags are redundant when section headers already provide categorization

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

*  feat(connector): add Reset permissions button — restore all tools to auto

connector router: resetPermissions endpoint — sets all connector's tools to 'auto'
store: resetConnectorPermissions action
ConnectorDetail:
- Add 'Reset permissions' button — resets ALL tools back to auto (fully open)
- Rename 'Reset'/'Refresh' button to 'Refresh' — clarifies it syncs tool list only
- Two separate concerns: Refresh (tool list) vs Reset permissions (all → auto)

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

* 🐛 fix(connector): use excluded.* in onConflictDoUpdate to ensure crudType updates + add description to tool rows

connectorTool.ts:
- Use sql`excluded.crud_type` etc. instead of table.column refs in onConflictDoUpdate
- table.column in set generates self-reference (no-op) in some Drizzle versions
- Now correctly updates crudType when Refresh is clicked (read/update/delete groups will show correctly)

ToolPermissionRow:
- Add description below tool name: 11px, tertiary color, single-line truncate with ellipsis
- Tooltip shows full description on hover (mouseEnterDelay: 0.5s)

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

* 🐛 fix(connector): createStaticStyles returns static object not hook in ConnectorItem

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

* 🗑️ chore(settings): remove /settings/connector route — Connectors are in /settings/skill

- Remove src/routes/(main)/settings/connector/index.tsx
- Remove SettingsTabs.Connector from enum and componentMap
- Remove Connectors item from settings sidebar useCategory
- Remove Connector from full-width list in SettingsContent
- Remove unused LinkIcon import from useCategory

ChatSettingsTabs.Connector (agent panel) is separate and unchanged.

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

*  feat(connector): disabled tools stay in manifest with blocking description + hard-block at callTool

buildConnectorManifests:
- Disabled tools are now INCLUDED in the manifest (not excluded)
- Description replaced with: '[TOOL DISABLED] The user has disabled this tool and it cannot be executed...'
- humanIntervention: 'required' set for disabled tools so AI is explicitly warned
- AI can inform user the tool is disabled instead of silently not knowing it exists

mcp.callTool:
- Pre-call permission gate: query ConnectorModel + ConnectorToolModel by connector identifier
- If tool.permission === 'disabled': return immediately with "disabled by user" message
- MCP server is never called — the block is enforced server-side regardless of what AI attempts

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

* 🐛 fix(connector): add permission gate to klavis.callTool for disabled tools

Gmail (and other Klavis-sourced connectors) use tools.klavis.callTool,
not tools.mcp.callTool, so the previous MCP permission gate didn't apply.

Fix: Add serverDatabase to klavisProcedure, extract connector identifier from
toolName prefix, query user_connector_tools, hard-block if permission=disabled.

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

* 🗑️ chore(skill): hide + button (custom MCP connector creation — OAuth flow TBD)

Remove AddConnectorModal entry point from /settings/skill header.
Custom HTTP MCP connectors require OAuth (Pre-registration / DCR) which
is not yet fully implemented. Will be re-added in a future PR.

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

* 🐛 fix(connector): only replace plugins with connectors that have a real MCP endpoint

Root cause: Lobehub/Klavis OAuth skills are synced into user_connectors via
syncToolsFromClient with mcpServerUrl=null. buildConnectorManifests generates
mcpParams={url:''} for them. After humanIntervention approval, the runtime calls
tools.mcp.callTool({url:''}) → fails silently → empty result.

Fix: only use connectorsMcp (connectors with mcpServerUrl or stdio config) to
replace installedPlugins and build connector manifests. Connectors without a real
MCP endpoint (Lobehub/Klavis) fall back to their original plugin executor path,
preserving the Klavis callTool execution chain and fixing needs_approval flow.

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

*  feat(connector): centralized tool permission enforcement across all execution paths

connectorPermissionCheck.ts (new shared utility):
- getConnectorToolPermission(): look up permission by identifier + toolName
- buildBlockedToolResponse(): standardized "disabled by user" response
- patchManifestWithPermissions(): patch manifest api[] with DB permissions

ToolExecutionService.executeTool() — centralized disabled gate:
- Queries DB at execution entry for ALL tool types (Lobehub skills, Klavis,
  MCP connectors, builtin plugins, and qstash/execAgent async path)
- Hard-blocks 'disabled' tools before any executor runs
- needs_approval handled by manifest humanIntervention (not blocked here)

aiAgent/index.ts — manifest patching for Lobehub/Klavis:
- After fetching lobehubSkillManifests + klavisManifests, query connector tools
- Patch manifests: needs_approval → humanIntervention:'required' (pauses for approval)
- Patch manifests: disabled → blocking description (AI informed, executor blocks)
- humanIntervention system already handles headless auto-reject for qstash

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

* 🐛 fix(connector): invokeBuiltinTool falls back to store lookup when payload.source is undefined

Root cause: when a tool call is re-invoked after humanIntervention approval,
the payload comes from the DB-stored message which does NOT persist the `source`
field. `internal_transformToolCalls` sets source correctly but it only runs for
LLM-generated tool calls, not for the approval re-invocation path.

Fix: in `invokeBuiltinTool`, if `payload.source` is undefined, do a live lookup
from the tool store (klavisAsLobeTools / lobehubSkillAsLobeTools) to determine
the correct executor. Applies to Klavis (Gmail, etc) and LobeHub Skills alike.

Also: remove all temporary [DEBUG] console.log statements.

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

* 🔨 chore: fix TypeScript errors and test failures after canary rebase

- buildConnectorManifests: LobeToolManifest → ToolManifest (correct export name)
- connectorPermissionCheck: cast permission string to ConnectorToolPermission
- connector.ts model: guard encryptCredentials against null credentials
- ConnectorDetail: String() cast for unknown metadata.description
- AddConnectorModal: move loading to Modal.confirmLoading (correct prop)
- connector/action.ts: break circular ToolStore type reference with Pick<Impl>
- execAgent.disableTools.test.ts: mock ConnectorModel/ConnectorToolModel DB deps

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

* 🐛 fix(connector): P1/P3 fixes + test mock coverage after code review

P1 — real MCP disabled tools now appear in manifest:
- ConnectorToolModel.queryAllByConnectorIds: new method without disabled filter
- aiAgent.ts: uses queryAllByConnectorIds for manifest building so buildConnectorManifests
  receives ALL tools (including disabled) and can emit blocking descriptions
- queryByConnectorIds (non-disabled filter) retained for runtime hot-path

P1 — Klavis gate works for hyphenated identifiers (google-calendar, etc):
- klavis.ts: replace split('_')[0] prefix hack with direct findByToolName DB lookup
- ConnectorToolModel.findByToolName: query user_connector_tools by userId + toolName

P3 — queryByConnector adds userId filter:
- Prevents leaking tool metadata to wrong user if connector UUID is known

Tests — mock ConnectorModel/ConnectorToolModel in all execAgent test files:
- execAgent.builtinRuntime.test.ts
- execAgent.deviceToolPipeline.test.ts
- execAgent.disableTools.test.ts (queryAllByConnectorIds added to mock)

TypeScript — ConnectorDetail metadata.description:
- Use typeof === 'string' type guard to narrow unknown → string for JSX render

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

* 🔨 fix(connector): precise Klavis permission gate + update stale disabled comments

Klavis gate — identifier + toolName (precise, no same-name collision risk):
- CallKlavisToolParams: add identifier? field
- klavisExecutor: pass identifier to callKlavisTool
- callKlavisTool store action: thread identifier through to tRPC mutate
- klavis.callTool router: accept optional identifier in input schema
- Permission gate: when identifier present, do queryByIdentifiers + queryByConnector
  + find by toolName for a precise 2-field lookup; fall back to findByToolName for
  legacy callers without identifier

Comments updated to reflect current disabled behavior:
- buildConnectorManifests.ts: disabled → injected with blocking description
- connector.ts schema: same correction

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 12:03:56 +08:00
AmAzing- 485d664589 💬 style: rebrand platform agent copy to Connect Agent (#15498) 2026-06-06 09:55:34 +08:00
Arvin Xu b1ada9e5fc 🐛 fix(conversation): hide Usage extra for local hetero agents until model arrives (#15501)
Local CLI hetero agents (claude-code, codex) only report `model` after
turn_metadata lands mid-stream. The previous `showUsage` check used the
broad `HETEROGENEOUS_TYPE_LABELS` lookup which matches both local and
remote types, so it returned true with an empty model. Usage then fell
through to the `ModelIcon` path (Usage uses the narrower
`isRemoteHeterogeneousType` for the brand-label branch) and rendered a
lone empty-model placeholder icon under the message.

Align the gate with Usage's internal branching: only bypass `!!model`
for remote hetero (openclaw, hermes) which never expose a real model id.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 01:49:49 +08:00
Arvin Xu 5dc769d135 🐛 fix(agent-signal): attribute self-iteration run trace to reviewed agent & isolate memory runs (#15479)
Background Agent Signal runs (memory / skill / self-reflection) execute under a
builtin agent slug. Two attribution gaps caused their traces to surface in the
wrong place:

- execAgent persisted the run's user + assistant message rows under the builtin
  slug's agent id, while the operation row, isolated thread, and receipts all
  attribute to the reviewed user agent on `marker.agentId`. The trace therefore
  "hung" under the builtin reflection/skill agent. Persist messages under
  `marker.agentId` when present, falling back to the executing agent otherwise.

- The memory run only created its isolated thread when an `assistantMessageId`
  could be extracted from a `clientRuntimeComplete` source id
  (`${assistantMessageId}:completion:${parentMessageId}`). Any other source left
  it undefined, skipping thread creation so the memory-agent messages leaked
  into the active conversation. Fall back to the triggering user `messageId` so
  a child thread is still created.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 01:42:48 +08:00
Arvin Xu 64b7ab2f17 💄 style(topic): one-click collapse/expand all topic groups (#15484)
*  feat(topic): add one-click collapse/expand all groups in topic sidebar

Add a toggle button in the topic sidebar header (next to Filter and the
more-actions menu) that collapses or expands all topic groups at once.
It reuses the existing `expandTopicGroupKeys` global status, so it stays
in sync with manual per-group toggling, and hides itself when there are
fewer than two groups (e.g. flat mode).

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

* 🐛 fix(topic): hide group toggle in flat mode

In flat mode, groupedTopicsForSidebar falls through to time grouping so
the computed group count can exceed one, but List renders FlatMode with
no accordion for the toggle to affect. Hide the control explicitly when
topicGroupMode === 'flat' instead of relying on the group count.

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

* 💄 style(topic): use 2-corner minimize/maximize icons for group toggle

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 01:33:32 +08:00
Arvin Xu 9c4dadda4c feat(task-detail): replace inline comment input with ChatInput that triggers a new run (#14873)
*  feat(task-detail): split task panel comment from topic-thread reply

CommentInput in TaskActivities stays as-is on canary — avatar + EditorCanvas
+ attachment + send button, posting a plain task-level comment.

TopicChatDrawer footer becomes a FeedbackInput that calls the in-scope
ConversationProvider's sendMessage, continuing the existing topic
conversation instead of attaching a comment + restarting the run.

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

*  feat(task-detail): keep FeedbackInput visible while topic is running

Drop the canLeaveFeedback gate so the in-thread reply box renders even
when the topic is pending/running. ConversationStore.sendMessage already
queues messages during an in-flight stream, so this just exposes the
queue affordance to the user — letting them steer the next step
without waiting for the current run to terminate.

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

* 💄 style(task-detail): collapse FeedbackInput behind a follow-up button + add attach action

FeedbackInput now starts collapsed as a full-width "Send follow up message"
button. Click expands a ChatInput shell with EditorCanvas inside and a footer
that carries an AttachmentUploadButton on the left (+ icon) and the send
button on the right. Files are inserted inline into the editor (same
pattern as CommentInput) so they ride along on sendMessage's editorData.

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

* 💄 style(task-detail): tighten CommentInput card & switch follow-up button to filled

- CommentInput card: padding-block 8px → 4px, editor placeholder fontSize 14px
- FeedbackInput collapsed button: default size + variant="filled" for a less
  obtrusive look that sits flush in the chat footer

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

* 💄 style(task-detail): drop top padding above FeedbackInput in topic drawer

Use paddingBlock="0 12px" so the follow-up button hugs the last message
instead of floating with a 12px gap above.

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

* 🐛 fix(task-detail): clear FeedbackInput editor before awaiting sendMessage

Previously the editor cleanup ran after the awaited sendMessage call, so
the box kept the just-sent text on screen until the entire send + stream
lifecycle resolved. Move clearContent / collapse before the await so the
input feels responsive (sendMessage already snapshots markdown and
editorData for its optimistic update).

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

* 🐛 fix(task-detail): keep FeedbackInput expanded after sending

Drop the setExpanded(false) call in handleSubmit so the ChatInput
remains open once the user has opened it. Collapsing it back to the
"Send follow up message" button right after every reply was disruptive
mid-conversation; the button only makes sense as the initial resting
state of the drawer.

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

*  feat(chat): add forceRuntime override to SendMessageParams

Plumb a new optional forceRuntime field through SendMessageParams →
ConversationLifecycle.sendMessage → selectRuntimeType(parentRuntime).
parentRuntime already wins over every other signal in the dispatcher,
so callers can pin a send to 'gateway' / 'client' / 'hetero' regardless
of the agent's local/cloud config.

Also propagate forceRuntime through the message queue (QueuedMessage +
MergedQueuedMessage + mergeQueuedMessages + both drain sites in the
client and hetero executors) so a follow-up queued during an in-flight
run keeps its runtime pin when it eventually fires.

FeedbackInput in TopicChatDrawer passes forceRuntime: 'gateway' so
task-topic follow-ups stay on the server-side path that runTask
originally used, even if the user's global runtime preference is local.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 01:26:25 +08:00
AmAzing- ab7cb07ae5 🐛 fix: type errors in oidc http-adapter test breaking CI lint (#15499) 2026-06-06 01:24:12 +08:00
Rylan Cai 596440901d 🐛 fix: auto-run required tools in headless mode (#15492) 2026-06-06 00:40:24 +08:00
YuTengjing 2b9f08a43b 🐛 fix: timeout Market connection listing (#15487) 2026-06-05 13:27:08 +08:00
YuTengjing 95a0cf1264 🐛 fix: handle runtime request errors (#15478) 2026-06-05 13:13:56 +08:00
Innei 65ba086685 🐛 fix(agent-documents): render system docs in editor (#15462)
* 🐛 fix(agent-documents): render system docs in editor

*  feat(agent-documents): autosave highlight editor with safe unmount flush

Add debounced autosave to the non-markdown highlight editor and a StrictMode-safe
unmount flush via queueMicrotask, plus a beforeunload guard against dirty buffers.

*  test: fix agent document PR type checks
2026-06-05 10:22:31 +08:00
Zhijie He 25635ddb38 feat(task): auto-ensure qstash schedule for task system (#14771)
*  feat(task): auto-ensure qstash schedule

chore: cleanup code

chore: cleanup code

chore: cleanup code

* chore: migrate qstash init workflow to startServer

chore: migrate qstash init workflow to startServer

* fix: set default QSTASH_URL to eu region, same as SDK

fix: set default QSTASH_URL to eu region, same as SDK
2026-06-05 02:07:03 +08:00
Arvin Xu f5d78d3d28 feat(device): switch device cwd handling to structured workingDirs (#15353)
Consume the `working_dirs` column: model `updateDevice`, tRPC `updateDevice`
input + `listDevices` output, and the client cwd pickers now operate on
`WorkingDirEntry[]` instead of the flat `recentCwds: string[]`.

- model / tRPC: `workingDirs` (input capped at 20, validated `{ path, repoType? }`)
- client `deviceCwd`: `nextRecentCwds` → `nextWorkingDirs`
- UI: DeviceWorkingDirectory / WorkingDirectory / DeviceDetailPanel / DeviceItem
  render the detected repo type via the shared `renderDirIcon`

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:27:03 +08:00
Hardy f7c46a30a4 feat(opencode-go) add MiniMax M3, remove deprecated models, rework model fetch logic (#15376)
* 🗑️ chore(opencode-go): remove MiMo V2 Omni and MiMo V2 Pro models

*  feat(opencode-go): fetch model list from API with models.dev enrichment

- Try API /models first for real-time available models
- Enrich with models.dev data (pricing, abilities, SDK routing)
- Fallback to models.dev + model-bank if API fails
- Dynamic Anthropic SDK routing via provider.npm field

* 💰 fix(opencode-go): update MiMo pricing to match models.dev

- mimo-v2.5: input $0.14, output $0.28, cache_read $0.0028
- mimo-v2.5-pro: input $1.74, output $3.48, cache_read $0.0145

*  feat(opencode-go): add MiniMax M3 and remove deprecated Qwen3.5 Plus

- Add minimax-m3: 512K context, vision support (image+video), 131K output,
  pricing 0.6/2.4/0.12 USD per M tokens, released 2026-05-31
- Remove qwen3.5-plus: marked deprecated in models.dev

* 🐛 fix(opencode-go): restore Anthropic routing fallback when models.dev is unreachable

Codex P2 review on #15376:
- `routers` is called with `ClientOptions` (no `client` field), so
  `options.client?.models.list?.()` silently returned `undefined` via
  optional chaining; the `catch` never ran and `modelIds` stayed `[]`.
- In API + models.dev double-failure scenarios, `getAnthropicModels([])`
  returned an empty list, regressing Anthropic SDK routing for MiniMax /
  Qwen models.

Fix:
- Make `getAnthropicModels` self-contained: takes no parameters.
- Fallback chain: models.dev → static model-bank prefix match → `[]`.
- `routers` no longer touches `options.client`.

*  feat(opencode-go): enrich model list with models.dev metadata

The model list pipeline previously forwarded only `{ id }` from the API
and models.dev, so displayName / pricing / context / modalities all came
from the static model-bank. When models.dev disagrees with model-bank
(e.g. a price update or new model), the runtime would show stale data.

Map models.dev fields into the flat shape that `processModelCard`
understands, so each card is enriched with:
  - displayName (dev.name)
  - contextWindowTokens / maxOutput (dev.limit)
  - releasedAt (dev.release_date)
  - functionCall / reasoning / vision / structuredOutput (dev.flags +
    dev.modalities.input)
  - pricing (dev.cost → flat input/output/cachedInput/writeCacheInput;
    processModelCard's formatPricing converts it to units)

Fields models.dev doesn't have (description, organization, settings
.extendParams, etc.) still fall back to the model-bank entry via
processModelCard's knownModel lookup, keeping the static config as the
source of truth for UX-only fields.

*  feat(opencode-go): drive reasoning_content handling from models.dev

The `reasoningInterleavedModels` list was hardcoded and drifted from
models.dev:
  - Missing: kimi-k2.5, kimi-k2.6, mimo-v2-omni, mimo-v2-pro
  - Stale: qwen3.7-max (no longer has `interleaved` in models.dev)

Move the source of truth into the models.dev cache. `fetchModelsDevData`
now also builds an `interleavedIds: Set<string>` from `m.interleaved.field`
alongside `anthropicModels`, so every derived field stays in sync with
a single fetch.

The new `getInterleavedModelIds` sync accessor lets `buildOpenAIPayload`
keep its sync signature; it returns the cached set when populated and
falls back to a hardcoded snapshot of the last-known models.dev state on
the very first chat request before any fetch has run.
2026-06-05 01:11:40 +08:00
Arvin Xu f77f31efc0 🔨 chore(database): re-tighten getBuiltinAgent onConflict after 0109 (#15475)
🔨 chore(database): re-tighten getBuiltinAgent onConflict to the 0109 partial index

Now that migration 0109 has flipped agents_slug_user_id_unique to a partial
index (WHERE workspace_id IS NULL) in all environments, restore the precise
conflict arbiter { target: [slug, userId], where: isNull(workspaceId) } so
unexpected unique violations surface instead of being silently swallowed by the
bare onConflictDoNothing() transition form.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:07:57 +08:00
Rylan Cai cd171d3510 🐛 fix: bypass audits for headless tool calls (#15406)
* 🐛 fix: bypass audits for headless tool calls

* 🐛 fix: block high-risk headless tools at execution

* Revert "🐛 fix: block high-risk headless tools at execution"

This reverts commit 1d4b534e7a36757bfea0ab229b45a7da647898a3.

* 🐛 fix: restore headless audit bypass

* 🐛 fix: resolve headless blocked tools

* 🐛 fix: simplify blocked tool results

* 🧹 chore: remove unrelated prompt diff

* 🐛 fix: narrow blocked tool instruction type

* 🐛 fix: split security blacklist policies

* 🐛 fix: simplify security blacklist policy rules

* 💄 style: tighten security blacklist diff

* 💄 style: reduce agent config doc diff

* 💄 style: tighten headless audit diff

* 💄 style: minimize audit policy diff

* 💄 style: clarify global audit match naming

* 🐛 fix: auto-run required global audits in headless

* 💄 style: clarify headless intervention comments

* 💄 style: clarify headless global audit comment

* 💄 style: use blocked tool instruction type

* 💄 style: clarify headless audit tests

* 💄 style: annotate headless blocked tool tests

* 🐛 fix: type security blacklist policy filter

* 💄 style: clarify local system 403 guidance

* 🐛 fix: use current persist error helper
2026-06-04 23:42:21 +08:00
YuTengjing b7e2663079 ♻️ refactor: expose email harmony options slot (#15477) 2026-06-04 23:06:14 +08:00
René Wang 537c39f771 💄 style(chat-input): rework Plus menu with toggle switches and grouped submenus (#15433) 2026-06-04 21:24:28 +08:00
Arvin Xu ed47d9ece5 🗃️ build(database): migrate unique constraints to workspace scope (#15472)
* 🗃️ db(database): migrate unique constraints to workspace scope (migration 0109)

Replace the legacy user-scoped UNIQUE constraints with workspace-scoped
partial unique indexes across agents, agent evals, agent skills,
documents, sessions, tasks, and rbac roles/user-roles. Adds migration
0109_migrate_unique_constraints and updates the affected schemas.

* 🐛 fix(database): match partial unique index in getBuiltinAgent upsert

Migration 0109 turned `agents_slug_user_id_unique` into a partial index
(WHERE workspace_id IS NULL). A plain `ON CONFLICT (slug, user_id)` no longer
matches it (Postgres 42P10), breaking getBuiltinAgent. Add the same predicate
via onConflictDoNothing's `where` option; builtin agents are always
workspace-less so the predicate always holds.

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

* 🔨 chore(database): use bare onConflictDoNothing in getBuiltinAgent for 0109 transition

Index-shape-agnostic upsert so the builtin-agent path works whether
agents_slug_user_id_unique is the legacy full unique or the 0109 partial,
removing the deploy-ordering coupling. Re-tighten to { target, where } in a
follow-up once 0109 has flipped the index everywhere.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:08:36 +08:00
Arvin Xu 2bb39f470a feat(gateway): add explicit type discriminator to tunneled tool calls (#15473)
*  feat(gateway): add explicit type discriminator to tunneled tool calls

The device-gateway relays builtin local-system calls and tunneled stdio MCP
calls over one `tool-call` channel. The device was meant to tell them apart by
sniffing whether `toolCall.params` exists — fragile: any future builtin tool
that grows a `params` field would be misrouted to the MCP client.

Add an explicit `toolCall.type` discriminator (`'builtin' | 'mcp'`). The HTTP
client stamps it: `executeToolCall` → `'builtin'`, `executeMcpCall` → `'mcp'`.
The device routes on `type`, never on payload shape. Optional + back-compatible:
an older server that omits it is treated as `'builtin'`.

The desktop receiver switches to this discriminator in a follow-up.

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

*  feat(desktop): execute tunneled stdio MCP calls from the gateway (#15470)

Receiving half of the gateway stdio-MCP work. When the cloud server tunnels a
stdio MCP tool call to this device (a `tool_call_request` carrying
`mcpParams`), run it locally instead of falling through to the builtin
local-system tool switch (which keys on apiName and has no MCP context, so it
rejected these as "not available on this device").

- `gatewayConnectionSrv`: add a dedicated `mcpCallHandler` + `setMcpCallHandler`;
  `handleToolCallRequest` routes on the presence of `toolCall.mcpParams`,
  sharing the existing response-envelope path.
- `GatewayConnectionCtr`: wire `setMcpCallHandler` → `executeMcpCall`, which
  maps the wire payload to `McpCtr.runStdioMcpTool`.
- `McpCtr`: extract `runStdioMcpTool` core from the `callTool` IPC method so
  both the renderer and the gateway tunnel share one stdio execution path
  (no SuperJSON round-trip for the in-process caller).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:04:09 +08:00
Johnny 92ec067718 fix: prefer INTERNAL_APP_URL for ComfyUI server calls (#15387)
🐛 fix: prefer internal app url for comfyui calls
2026-06-04 19:37:39 +08:00
Arvin Xu 8f19fde3e7 🗃️ build(database): add workspace_id indexes (#15468)
* 🗃️ db(database): add workspace_id indexes (migration 0108)

Phase 3 of the workspace DB migration (LOBE-9961). Adds a btree index on
workspace_id to 70 tenant tables, plus 7 workspace-scoped partial unique
indexes (WHERE workspace_id IS NOT NULL) that pre-build the "new" side of the
Phase 4 (0109) unique-constraint cutover.

A separate production-safe runbook (0108_concurrent.sql, CREATE INDEX
CONCURRENTLY, ordered smallest->largest) is intentionally NOT committed.

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

* 🗃️ db(database): make 0108 index migration idempotent

Add IF NOT EXISTS to all 70 CREATE INDEX + 7 CREATE UNIQUE INDEX statements,
per the db-migrations standard flow (defensive/idempotent SQL), matching how
0107 used DROP CONSTRAINT IF EXISTS. Safe to re-run and safe if the concurrent
runbook already built the indexes before the auto-migrator reaches 0108.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:03:00 +08:00
Arvin Xu f35f984268 feat(gateway): tunnel stdio MCP tool calls to the device (#15469)
Stdio MCP servers live on the user's machine, but in gateway (cloud) mode
the agent runs server-side and `executeMCPTool` tried to spawn the stdio
binary on the cloud server — which has neither the binary nor access to the
user's machine, so local MCP tools (e.g. tasks calling a local kimi-datasource
MCP) always failed.

Add a dedicated `executeMcpCall` path that forwards the stdio connection
params (command/args/env) to a connected device, which spawns the MCP server
and runs the call locally. It rides the existing `/api/device/tool-call`
relay — the gateway forwards `toolCall` opaquely — so the device-gateway
worker needs no changes; the device routes on the presence of
`toolCall.mcpParams`.

Server-side only: when no device is connected, behavior is unchanged
(standalone Electron still spawns in-process). The desktop-side receiver that
runs the forwarded call lands in a follow-up.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 19:02:11 +08:00
YuTengjing b9fbad7f02 ♻️ refactor(ai-chat): remove simple turn fast path (#15471) 2026-06-04 17:58:57 +08:00
YuTengjing e165b6424b 📝 docs: clarify drizzle raw sql guidance (#15467) 2026-06-04 17:00:42 +08:00
YuTengjing bab3ff4a7a 🐛 fix: reduce agent document context latency (#15436) 2026-06-04 16:23:51 +08:00
Arvin Xu 1e2c1aacd5 🗃️ build(database): add workspace_id FK constraints (#15465)
* 🗃️ db(database): add workspace_id FK constraints (migration 0107)

Phase 2 of workspace_id rollout: add the FK constraint on the 70 tables
that gained a bare `workspace_id` column in Phase 1 (0106), referencing
workspaces(id) ON DELETE CASCADE.

- schema: add `.references(() => workspaces.id, { onDelete: 'cascade' })`
  to all 70 nullable workspace_id columns
- 0107_add_workspace_id_fk.sql: idempotent drizzle migration
  (DROP CONSTRAINT IF EXISTS + ADD), runs in CI / dev / self-host
- 0107_concurrent.sql: production-safe out-of-band runbook
  (NOT VALID + VALIDATE) to avoid write-blocking locks on large tables;
  NOT run by drizzle

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

* 🔥 db(database): remove stray 0107_concurrent migration file

* 🐛 fix(database): break user/workspace schema circular dependency

Move userInstalledPlugins from user.ts into connector.ts to break the
user.ts <-> workspace.ts import cycle flagged by dpdm. connector.ts
already imports both users and workspaces, and consumers import the
table from the schemas barrel, so no call sites change.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:23:14 +08:00
Arvin Xu 475f391d97 ♻️ refactor(message): prefer dedicated usage column over metadata.usage (#15457)
* ♻️ refactor(message): prefer dedicated usage column over metadata.usage

Token usage was promoted out of metadata.usage into a dedicated messages.usage
column, but nothing populated it and all reads still went through metadata.usage.

- Centralize write-side promotion in the DB model (update / updateMetadata /
  create), so all executor callers populate the usage column from a top-level
  usage payload, falling back to metadata.usage. metadata.usage stays dual-written
  for backward-compatible reads.
- Reads prefer the usage column and fall back to metadata.usage: message queries,
  getTokenHeatmaps, recomputeTopicUsage, the usage record service, and context
  token accounting.
- Add top-level usage to UpdateMessageParams + DBMessageItem types.
- Mark metadata.usage and the legacy flat token fields as @deprecated, pointing
  to the top-level usage field.

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

* 🐛 fix(message): dual-write metadata.usage for top-level usage updates

When a caller passed the new top-level `usage` param without also sending
`metadata.usage`, the update wrote only `messages.usage` and left
`metadata.usage` stale/absent — legacy readers and rollback paths still consume
it during the dual-write transition. Fold the resolved usage into the metadata
patch so `metadata.usage` stays in sync regardless of how usage was passed.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:14:11 +08:00
Arvin Xu 133675adda 🗃️ db(database): add workspace_id columns to existing tables (#15446)
* 🗃️ feat(database): add workspace_id columns to existing tables

Add a nullable `workspace_id text` column to user-owned business tables
(agents, sessions, topics, messages, files, tasks, RAG/eval, RBAC, devices,
connectors, etc.) so records can later be scoped to a workspace. Workspace
tables themselves already landed on canary via 0105_add_usage_agent_share_workspace.

Also folds in the additive device schema from #15356: the structured
`working_dirs` jsonb column + `WorkingDirEntry` type (recent_cwds kept,
now @deprecated).

Scope is deliberately column-only — the lowest-risk slice:
- migration 0106 is pure `ADD COLUMN IF NOT EXISTS` (metadata-only, ~ms locks
  per table, online-safe, no app code change since columns are all NULL).
- FKs, btree indexes, and the per-user→workspace-scoped unique-constraint
  conversions are intentionally deferred to follow-up PRs so each can use the
  production-safe execution path Drizzle can't express (NOT VALID + VALIDATE,
  CREATE INDEX CONCURRENTLY, atomic unique swap).

Scoping notes:
- devices / user_connectors / user_connector_tools: scoped (user-owned resources).
- push_tokens: left user/device-level — an Expo token is one per app install and
  receives a person's notifications across all their workspaces.
- agent_shares: no workspace_id — scoped transitively via agent_id → agents.

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

* 🐛 fix(database): satisfy inferred row types after adding workspace_id

Adding workspace_id made it a required key in the Drizzle-inferred row types
($inferSelect), breaking call sites that build those shapes by hand:
- rbac.getUserRoles: include workspace_id in the explicit select projection
- session action: add workspaceId to the constructed chat-group literal
- test mocks (apiKey / generation / generationBatch / generationTopic): add
  workspaceId: null

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

*  test(database): use toMatchObject for topic.create row assertions

The two `expect(createdTopic).toEqual({ ...full literal })` snapshots broke
on every new column (here: workspace_id). Switch them to toMatchObject so the
returned row may carry extra columns without churning the expected literal.
The dbTopic↔createdTopic strict comparisons are left as toEqual.

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-04 13:18:16 +08:00
Arvin Xu e8b914feef ♻️ refactor(agent-signal): S6 — migrate skillManagement to execAgent builtin agent (#15443)
Move the self-iteration skill-management action off the inline policy
implementation onto an execAgent-dispatched builtin agent (slug
`skill-management`), mirroring the S3/S4 memoryWriter + self-iteration
migration. Adds the `agentSignalSkillManagement` serverRuntime, the
builtin-tool-agent-signal skill-management manifest/systemRole, and the
builtin-agents skill-management agent; strips the ~3.5k-line inline
skillManagement policy down to the dispatch shim.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:49:26 +08:00
Hardy 7f3f1278e4 feat(prompts): use XML format for topic title generation to improve DeepSeek compatibility (#15413) 2026-06-04 12:42:11 +08:00
Arvin Xu 951561f685 ️ perf(database): add optional statement_timeout to server DB connections (#15445)
Long-running queries (e.g. an insert stuck for 700s on lock contention)
could block indefinitely because Postgres' statement_timeout defaults to
0 (no limit) and neither the node nor neon pool configured one.

Add an optional DATABASE_STATEMENT_TIMEOUT env (milliseconds, no default)
applied to both NodePool and NeonPool as statement_timeout and
idle_in_transaction_session_timeout, so Postgres aborts a stuck statement
or idle transaction on the server side. Unset keeps the previous behavior.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:34:23 +08:00
lobehubbot 248a4dcab5 🔖 chore(release): release version v2.2.2 [skip ci] 2026-06-04 03:59:37 +00:00
lobehubbot d3eebd3994 Merge remote-tracking branch 'origin/main' into canary 2026-06-04 03:57:53 +00:00
Arvin Xu 6532cd1ee0 🚀 release: 20260604 (#15447)
# 🚀 LobeHub Release (20260604)

**Release Date:** June 4, 2026  
**Since v2.2.1:** 88 merged PRs · 11 contributors

> This week brings Execution Devices out of the lab — run agents and
Claude Code on any configured local or remote machine — alongside Claude
Opus 4.8, token-usage analytics, and Page sharing.

---

##  Highlights

- **Execution Devices** — Pick where an agent runs. Desktop and CLI
devices auto-register with a stable machine ID, route through the
gateway by channel, and surface a device switcher in the chat input. Run
remote Claude Code on a configured device, with a recent-directory
picker you can drag to reorder. (#15300, #15315, #15322, #15343, #15351,
#15371)
- **Claude Opus 4.8** — Day-one support for Anthropic's latest model.
(#15314)
- **Token-usage analytics** — A new token-usage mode on the activity
heatmap, backed by a denormalized topic usage/cost rollup so totals stay
accurate without recomputing from messages. (#15365, #15417, #15425)
- **Page sharing** — Share a Page through a dedicated document share
flow, plus new Workspace and Agent share tables. (#15309, #15439)
- **Self-iteration agents** — Agent Signal's execAgent migration lands a
server-runtime bridge, async memory writer, and a registered
self-iteration tool package, with a CLI trigger command for testing.
(#15360, #15364, #15392)
- **Knowledge search** — BM25 search now extends to file-backed
documents, and the portal ships an editable CodeMirror viewer for local
files with document highlighting. (#15247, #15298)

---

## 🏗️ Core Agent & Architecture

### Agent Signal & Runtime

- **execAgent migration** — Server-runtime bridge, completion
projection, async memory writer, and removal of the legacy
`executeSelfIteration` path. (#15392)
- Registered the self-iteration builtin tool package and restored the
three mode-specific self-iteration agent slugs. (#15202, #15364)
- Added a CLI trigger command with a golden-snapshot fixture for Agent
Signal. (#15360)
- **Skill priority** — Agent Builder now emits a skill-priority
instruction with matching server runtime. (#15409)
- Retry empty LLM completions instead of silently finishing the turn.
(#15355)
- Classify topic/agent/session foreign-key violations as
`ConversationParentMissing` for clearer recovery. (#15408)
- Persist canonical nested usage/performance on assistant messages, and
re-link orphan tool messages at the raw bucket write boundary. (#15359,
#15438)
- Guard `createAgent` against LLM double-encoded array fields. (#15381)

---

## 🖥️ Execution Devices & Gateway

- Auto-register desktop and CLI devices with a stable machine ID, and
add the `@lobechat/device-identity` package. (#15300, #15321)
- New Devices settings page behind the Execution Device Switcher lab,
with a device switcher shown for all agents in the chat input. (#15315,
#15371)
- `connectionId` + channel routing across the gateway client and device
list; preset the local device on the first LLM request for the 本机
target. (#15322, #15435)
- Run remote Claude Code on a configured device, with drag-to-reorder
recent-directory management and client renders for device tool results.
(#15343, #15351, #15437)
- Preserve content and state across gateway tool calls, and prevent
duplicate streaming from stale reconnects. (#15114, #15354)

---

## 🖥️ CLI & Desktop

- Preserve content/state for connect local file and shell tools; render
the `runCommand` tool result card. (#15441, #15442)
- New `lh topic view` command; CLI now auto-registers its device on
login, matching desktop. (#15340, #15377)
- Resolve CLI tools from the shell `PATH`, and clarify local command
session handling. (#15368, #15389)
- Relocate visual-ref helpers to `@lobechat/const` to fix a renderer
crash; upload `.blockmap` files to S3 for differential updates. (#15326,
#15369)
- Fix a market OAuth expiry that triggered the wrong re-login modal, and
kill dev child processes on parent shutdown. (#15246, #15290)

---

## 🗂️ Pages, Library & Knowledge

- Document share flow with business slot stubs, plus Workspace and Agent
share tables. (#15309, #15439)
- Export Agent profiles as Markdown, preserving an empty agent prompt on
export. (#15312, #15316)
- Editable CodeMirror viewer for local files with document highlighting;
BM25 search extended to file-backed documents. (#15247, #15298)
- Default new Agent-doc files to `.md` and preserve IME composition;
refresh folder data on slug switch and dedupe breadcrumb fetches.
(#15335, #15427)

---

## 💬 Chat & User Experience

- Group-by-status mode for the Topic sidebar; dropped the legacy
session→agentId compatibility path from Topic queries. (#15366, #15378)
- Restore editor focus after the file picker closes, and close the skill
dropdown before navigating to settings. (#15391, #15394)
- Strip markdown tokens from fallback Topic titles; keep an open
ActionBar popup when hovering another message. (#15303, #15372)
- Stabilize home starter loading and stop transliterating model names in
the home starter; show artifact source while streaming. (#15310, #15324,
#15386)
- Group the sidebar spacer with recents and agents. (#15373)

---

## 📊 Analytics, Tasks & Notifications

- Token-usage mode on the activity heatmap, backed by a denormalized
topic usage/cost rollup. (#15365, #15417, #15425)
- Push: new `PushChannel`, receipt cron, and `pushToken` tRPC API.
(#15233)
- Tasks now support file and image attachments. (#15141)

---

## 🧩 Models & Providers

- Support Claude Opus 4.8 and configurable model routing with starters.
(#15314, #15384)
- MiniMax M3: new model entry and an Anthropic video runtime. (#15380,
#15403)
- Add `intern-s2-preview` with `thinking_mode`, and `step-3.7-flash`
support. (#15308, #15317)
- Block disabling the official provider; fix default provider setup in
business mode. (#15379, #15382)

---

## 🎨 UI & Modals

- Migrate modals to `@lobehub/ui/base-ui` (LOBE-9711 + eval batch),
including the create-custom-model and feedback/changelog modals.
(#15401, #15416)
- Restructure confirmModal title and content across deletion flows;
polish the service-model form and migrate its Switch to base-ui.
(#15426, #15440)
- Wrap the BlueBubbles bridge config into a connection card; update
`@lobehub/ui` to v5.15.5. (#15325, #15342)

---

## 🔒 Reliability

- Replace hardcoded `session_context` values with template variables in
credentials. (#15352)
- Point `CHANGELOG_URL` to `/changelog`. (#15428)

---

## 👥 Contributors

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

@hezhijie0327 · @qybaihe · @sxjeru · @arvinxx · @Innei · @tjx666 ·
@LiJian · @sudongyuer · @cy948 · @rivertwilight · @AmAzing129

Plus @lobehubbot and renovate[bot] for maintenance.

---

**Full Changelog**: v2.2.1...release/weekly-20260604
2026-06-04 11:56:58 +08:00
AmAzing- 54e1b59ce6 feat(agent-management): paginate searchAgent with real totals + wire 8 packages into CI (#15448)
*  feat(agent-management): paginate searchAgent with real totals and cap notice

The searchAgent tool silently clamped limit to 20 with no pagination and
reported totalCount as the returned page size, so models (and users) could
never discover agents beyond the 20 most recently updated ones.

- AgentModel: extract shared where builder, add countAgents (same
  conditions as queryAgents)
- lambda router + client agent service: expose countAgents
- server tool runtime & AgentManagerRuntime: pass offset through, report
  real totals (workspace + marketplace), emit explicit notes when the
  requested limit is capped and when more pages exist, explain
  out-of-range offsets instead of claiming no matches
- manifest: add offset param, document pagination
- agent-manager-runtime: add vitest config + test scripts (suite was
  previously unrunnable), repair stale store mocks

* 👷 build(ci): wire 8 tested packages into the package test workflow

An audit found 8 packages carrying test:coverage scripts that were never
added to the CI PACKAGES allowlist, so their suites never ran:

- agent-gateway-client, device-gateway-client, device-identity,
  eval-dataset-parser: already green, added as-is
- eval-rubric, fetch-sse: had no package-level vitest config, so vitest
  fell back to the root config whose setup/aliases break outside src/ —
  added minimal configs
- heterogeneous-agents: one assertion drifted (labels registry gained
  amp/hermes/openclaw/opencode) with nobody noticing — updated
- agent-manager-runtime: wired in the previous commit

All 8 verified locally with the exact CI command
(bun run --filter <pkg> test:coverage).

*  test(agent-management): cover searchAgent error path and market totalCount fallback

Codecov flagged 3 uncovered lines in the patch: the searchAgents catch
block (2 misses) and the totalCount ?? items.length fallback (1 partial).
Add the missing failure-path and fallback tests on both execution paths
(client AgentManagerRuntime + server tool runtime).
2026-06-04 10:52:25 +08:00
Arvin Xu 72ea0f94f7 🐛 fix(cli): preserve content/state for connect local file/shell tools (#15442)
* 🐛 fix(cli): preserve content/state for connect local file/shell tools

Route file/shell tool calls in connect mode through LocalSystemExecutionRuntime
so the result carries formatted prompt `content` plus structured `state`, and
forward `state` over the gateway tool-call response — aligning the CLI with the
desktop gateway path (PR #15114).

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

* 🐛 fix(cli): preserve getCommandOutput timeout when polling running commands

Routing getCommandOutput through the runtime dropped the per-call/gateway
timeout: the CLI mapping didn't forward it and LocalSystemExecutionRuntime's
denormalizeParams stripped it before ShellProcessManager.getOutput, so polling
fell back to the 30s default and could block past the gateway budget. Carry
timeout through the runtime param type, denormalize, and the CLI mapping.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 01:28:11 +08:00
Arvin Xu a3a08c2395 🐛 fix(chat): re-link orphan tool messages at the raw bucket write boundary (#15438)
A fast hetero-agent (Claude Code) tool can have its parent assistant's
`tools[]` momentarily dropped (stale/out-of-order `replaceMessages` snapshot,
or an optimistic `updateMessage{tools}` on the wrong assistant during a step
boundary) while the `role:'tool'` row + parentId survive. Since conversation-
flow binds a tool into its assistant solely via `assistant.tools[].id`, the
tool then renders as a top-level orphan bubble (`inspector.orphanedToolCall`).

Fix at the RAW `dbMessagesMap` write boundary — shared by `replaceMessages`
and `internal_dispatchMessage` (the optimistic-update path) — so the Source of
Truth stays consistent for optimistic updates, not just the parsed display.
`reconcileAssistantToolLinks` re-attaches the missing `tools[]` entry for any
present tool row whose parentId resolves to an assistant in the same bucket;
it only acts on present rows (never resurrects deletions) and never removes or
reorders entries.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:59:12 +08:00
Arvin Xu 643ad16a5d 🐛 fix(github): render runCommand tool result card (#15441)
The github render/inspector were registered under the snake_case
`run_command` key, but the tool call emits the camelCase `runCommand`
apiName, so the lookup missed and fell back to the generic collapsed
pill. Register both casings so the custom card renders.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:33:28 +08:00
Arvin Xu 5761d20637 feat(db): add workspace and agent share table (#15439)
*  feat(db): add usage column to messages table

Promote token usage/cost out of `metadata.usage` into a dedicated
`messages.usage` jsonb column, with btree expression indexes on
`usage.cost` and `usage.totalTokens`. Additive only — no data backfill;
`metadata.usage` stays the source of truth during the transition.

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

*  feat(db): add agent share schema (picked from #15430)

Bring the agent-share schema layer over from #15430: new `agent_shares`
table + `topics.sender_id` column/index, schema relations and barrel
export. Migration renumbered to 0106 to sit after the usage column.

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

*  feat(db): add workspace schema (picked from #15414)

Bring over only the standalone `workspace.ts` schema from #15414 — the
workspaces / workspace_members / workspace_invitations / workspace_audit_logs
tables (self-contained, FK to users only). None of #15414's workspaceId
column additions across other tables are included. Migration is 0108-safe,
renumbered to 0107.

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

* 🗃️ chore(db): squash usage/agent-share/workspace into one migration

Collapse the three stacked migrations (0105 usage, 0106 agent_share,
0107 workspace) into a single idempotent 0105_add_usage_agent_share_workspace.
Schema source is unchanged; only the migration files/snapshot/journal are
consolidated.

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

*  test(db): add senderId to expected topic shape in create test

The picked agent-share schema added topics.senderId, so the created row
now returns it; update the two toEqual assertions accordingly.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:24:09 +08:00
Innei fd3c6cf8fc 🐛 fix(ui): restructure confirmModal title and content across deletion flows (#15440)
🚸 fix(ui): restructure confirmModal title and content across deletion flows

Move long warning sentences from `title` into `content` and use short verb titles
("Delete", "Uninstall", "Wipe Data", etc.). Add `okText`/`cancelText` i18n for all
fixed sites so confirm buttons match the action language.

Covers topic/thread/agent/group/library/file/model/skill/storage delete flows.
2026-06-04 00:23:58 +08:00
Arvin Xu d81e5e703e feat(remote-device): add client renders for device tool results (#15437)
*  feat(remote-device): add client renders for listOnlineDevices and activateDevice

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

* 🐛 fix(utils): make SVG event-handler stripping engine-independent

DOMPurify's FORBID_ATTR / SVG-profile allowlist path relies on the underlying DOM's
attribute + namespace handling, which differs across engines (jsdom vs happy-dom) and
DOMPurify versions — in some CI environments on* handlers on SVG-namespaced nodes slipped
through. Add a scoped uponSanitizeAttribute hook to drop every on* attribute deterministically,
and assert by security property instead of exact serialization to drop whitespace brittleness.

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

* 🐛 fix(remote-device): render activation failure content when no device state

activateDevice returns success:false with explanatory content but no error and no state when
the target is offline/unknown. The tool detail view only skips custom rendering when result.error
is set, so the custom renderer's `return null` rendered a blank result. Fall back to the failure
content so the user/model still sees the message.

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

* 🐛 fix(utils): deterministically scrub SVG on* handlers via post-pass

The DOMPurify uponSanitizeAttribute hook still failed in CI: <script> is removed (tag filtering)
but on* handlers survive, because the attribute-sanitization phase doesn't run for SVG-namespaced
nodes in CI's DOM engine — so the hook never fires. Replace it with an explicit regex scrub on the
serialized output, which strips every on* event-handler attribute independent of the DOM engine.

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

* 🔒 fix(utils): loop SVG on* scrub until stable to close recombination bypass

A single-pass regex replace can leave a fresh handler behind when removing one splices the
surrounding text back together (` on onclick="x"click="y"` → ` onclick="y"`) — the CodeQL
js/incomplete-multi-character-sanitization case. Repeat the scrub until the string stops changing
so no on*= token can survive. Adds a regression test for the recombination input.

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-03 23:59:35 +08:00
Innei 2a4b6e4974 🐛 fix(agent-doc): default new files to .md and preserve IME composition (#15427)
* 🐛 fix(agent-doc): default new files to .md and preserve IME composition

- Append `.md` to newly-created agent documents; pre-select only the stem
  in the inline rename input so the extension stays intact.
- Wire `useIMECompositionEvent` on the explorer container so Enter pressed
  during IME composition (e.g. Chinese pinyin) no longer commits the
  half-formed name through pierre/trees' shadow-DOM input.

* 🐛 fix(agent-doc): use native capture listener for IME guard

React `onKeyDownCapture` can lose to pierre/trees' bubble handler in some
event ordering edge cases, and the original guard missed IMEs that report
`keyCode === 229` or fire Enter just after compositionend in the same task.

- Bind a native `keydown` capture listener on the container so we can
  inspect `composedPath()` and confirm the keydown originated inside the
  shadow-DOM rename input.
- Extend the IME guard with an `imeSessionRef` that stays true through one
  extra microtask after compositionend.
- Drop the React `onKeyDownCapture` prop in favour of the native listener.

*  revert(agent-doc): drop IME guard pending pierre/trees upstream fix

The inline rename input lives in pierre/trees' shadow DOM and we can't
reliably suppress its IME-composing Enter commit from the outside. Roll
back the local hack and track the issue upstream instead. The default
`.md` extension and stem-only selection on rename stay in place.

*  feat(agent-doc): preselect stem on inline rename too

Existing files entering inline rename (right-click → Rename, or F2) now
narrow the selection to the stem after pierre/trees' `input.select()`,
matching the new-file flow so the user never has to retype `.md`.

* 🐛 fix(agent-doc): preserve extension on filename collisions
2026-06-03 23:54:39 +08:00
Arvin Xu 2fb0970cf9 💄 style(stats): add token-usage mode to activity heatmap (#15425)
* 💄 feat(stats): ladder shorten number up to B and T tiers

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

* 💄 feat(stats): move token summary below overview and surface cumulative tokens

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

* 💄 style(stats): add 12px gap between overview cards and token summary

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

* 💄 style(stats): move heatmap summary under the activity title

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:51:57 +08:00
Arvin Xu 7a93df9e44 ️ perf(device): preset local device on first LLM request for 本机 target (#15435)
* ️ perf(device): preset local device on first LLM request for 本机 target

When the desktop runs an agent against the local machine (executionTarget
'local'), resolve this desktop's own gateway deviceId client-side and pass it
as the run's `deviceId`. The server then presets `activeDeviceId` and injects
`lobe-local-system` into the very first LLM payload, skipping the extra
`activateDevice` round-trip the model was forced to make whenever more than one
device was online.

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

*  test(device): cover local deviceId resolution in executeGatewayAgent

Asserts the client forwards this desktop's deviceId only for the local (本机)
target — including the unset-on-desktop fallback — and never for sandbox,
explicit remote device, or off-desktop runs.

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

* 🐛 fix(device): gate local-device binding on effective runtime mode

`resolveLocalDeviceId` defaulted an unset `agencyConfig.executionTarget` to
'local' and sent this desktop's deviceId. But the legacy ModeSelector writes
only `runtimeMode`, leaving executionTarget unset — so an explicit cloud/none
run would still get a deviceId, which the server turns into activeDeviceId and
injects lobe-local-system, wrongly routing a cloud run to the local machine.

Gate on `isLocalSystemEnabledById` (effective runtimeMode === 'local'), the
source of truth both selectors agree on.

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

* 🚨 fix(test): use import-type alias instead of inline import() type

Satisfies @typescript-eslint/consistent-type-imports (CI lint).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:31:46 +08:00
Arvin Xu d9673c3c41 ♻️ refactor(agent-signal): execAgent migration — serverRuntime bridge + completion projection + async memoryWriter + executeSelfIteration removal (#15392)
* 🚧 wip(agent-signal): S1 — self-iteration tools as static primitives, no side-channel

Rewrite all three self-iteration execAgent tool surfaces (review / reflection /
feedback-intent) as static, named primitives instead of reusing the dynamic
createServerToolSet / createToolSet factory (which carries the legacy
reserveOperation / receipt / completeOperation side channel the migration removes).

Package (builtin-tool-agent-signal):
- AgentSignalToolService.invoke (generic bag) → AgentSignalRuntimeService, a
  narrow named DB-primitive seam (skillManagement precedent). Artifact recorders
  echo their input; reads/mutations route to one primitive each. The runtime
  carries no dedupe / receipt / operation-state side channel — idempotency and
  receipt projection live on the completion path, not the tool call.

Server primitives (pure live-DB reads + writes, keyed to api names):
- review/server.ts createReviewRuntimePrimitives — proposal lifecycle + resource
  tools, parameterized by window scalars from the operation marker, reusing the
  existing snapshot/preflight/projection/brief helpers.
- tools/runtimePrimitives.ts createResourceRuntimePrimitives — the skill-read /
  skill-write / writeMemory surface shared by reflection and feedback-intent.
- No context blob and no getEvidenceDigest: evidence is embedded in the agent
  prompt, so tools only touch live state.

serverRuntimes: agentSignalReview / agentSignalReflection / agentSignalFeedbackIntent
thin factories wiring ToolExecutionContext → primitives → package runtime, all
registered. createServerToolSet / createToolSet left untouched (legacy
executeSelfIteration path, removed in S4).

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

* 🚧 wip(agent-signal): S2 — completion-path receipt projection from finalState

Replace the in-runtime receipt accumulator with finalState-driven projection on
the completion path. finalState is only in hand inside the completion lifecycle
(S3 final snapshots are write-only — get() is a null stub; the operation row has
no messages; prod webhook hooks strip finalState), so receipts must be projected
from the one point state exists.

- CompletionLifecycle.emitSignalEvents: extract the compact, kind-tagged tool
  outcomes from the terminal state (extractSelfIterationCompletionPayload) and
  carry them on the agent.execution.completed payload — only for marked
  self-iteration runs, never the full message history.
- completionPolicy: forward the payload to onSelfIterationCompleted.
- completion/buildSelfIterationReceipts: project mutations + artifacts into
  user-visible receipts, mirroring the legacy createReceipts kind/status/target
  mapping. Deterministic receipt ids (sourceId + tool call id) → idempotent
  re-projection; the store dedupes by id.
- completion/selfIterationCompletionHandler: build + persist receipts.
- orchestrator: wire the handler into createDefaultAgentSignalPolicies.
- agent-signal source type: add an opaque selfIteration field to the
  agent.execution.completed payload.

Inert until the dispatch side stamps the operation marker (S3 / S4): without a
marker the extractor returns undefined and the handler no-ops.

Tests: buildSelfIterationReceipts (5) + extractCompletionPayload (4); completion
policy + CompletionLifecycle + orchestrator suites green.

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

* 🚧 wip(agent-signal): S3 part 1 — completion-side memory receipt support (inert)

Foundation for migrating the memory writer to the async execAgent path: teach
the completion path to project a memory receipt from a finished memory-writer
run. Inert until the dispatch side stamps a kind:'memory' marker (part 2).

- completion routing is now keyed on the operation MARKER (the selfIteration
  payload), not the agent slug — a memory writer runs as the user's own agent,
  so a slug check would miss it. completionPolicy gates on payload presence;
  agentId loosened to string.
- extractCompletionPayload: for a kind:'memory' run, synthesize a writeMemory
  mutation from the run's finalState (the memory builtin tool results are not
  kind-tagged, so extractMutations finds nothing) via resolveMemoryActionResultFromState.
- buildSelfIterationReceipts: a memory run surfaces as just its action receipt,
  no aggregate review summary.
- extract the pure memory finalState parsers into a dependency-light
  ./memoryActionResult module so the completion lifecycle can reuse them without
  dragging the heavy memory-runner module (ModelRuntime/AgentService/…) into its
  graph. userMemory re-exports them for backward compat.
- bump a too-tight (5s) timeout on the real-orchestration integration test.

Tests: completion (12) + completionPolicy (8) + userMemory (12) green; agentSignal
policies + orchestrator suites (138) green; type-check clean.

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

* ♻️ refactor(agent-signal): S3 — migrate memoryWriter to async execAgent + completion receipt

Flip the memory-writer action from a blocking executeSync run to an async
queued operation (autoStart) stamped with an agent-signal `memory` marker.
The user-visible "memory saved" receipt is no longer projected synchronously
from the action result — it is projected on the completion path from the run's
finalState (extractMemoryMutations → buildSelfIterationReceipts), so the receipt
appears a few seconds later once the run completes.

- userMemory.ts: add `dispatch` path enqueuing via createOperation(autoStart),
  stamping appContext.agentSignal so completion can project the receipt.
- receiptService.ts: drop the synchronous memory receipt projection (would
  duplicate the async one, with a premature empty target).
- types.ts: add `agentSignal` marker to OperationCreationParams.appContext.
- tests: cover the memory-kind completion loop end-to-end (single memory
  receipt, correct target + anchor, no aggregate summary).

Note: the memory run uses createOperation (not execAgent), so it never
synthesises a user message and cannot recurse into analyzeIntent — no
suppressSignal needed on this path.

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

* 🚧 wip(agent-signal): S4 step 0 — forward agentSignal marker through execAgent

Foundation for migrating self-iteration onto execAgent: let a background run
carry its agent-signal marker so the S2 completion path can project receipts.

- Move AgentSignalOperationMarker / AgentSignalOperationKind into @lobechat/types
  (ExecAgentAppContext can now reference it); operationMarker.ts re-exports the
  type and keeps the runtime parse/validate helpers.
- ExecAgentAppContext: add `agentSignal?` field.
- execAgent: forward `appContext.agentSignal` into createOperation's appContext
  (it was dropped by the curated passthrough), so it lands in
  state.metadata.agentSignal — the key the completion extractor reads.

No behaviour change yet: nothing sets appContext.agentSignal on the execAgent
path until the self-iteration dispatch helper lands.

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

* 🚧 wip(agent-signal): S4 step 0b — self-iteration execAgent dispatch helper

Shared primitive for migrating the 3 self-iteration modes off the hand-rolled
runtime onto async execAgent (used by reflection/feedback/nightly-review next).

- enqueueSelfIterationRun(): create an isolated thread (when anchored), then
  execAgent the builtin slug with suppressSignal + the agent-signal marker on
  appContext, autoStart, headless. Returns immediately (fire-and-forget).
- marker: add `agentId` (the reviewed user agent). A slug run resolves the
  operation agentId to the builtin agent, so receipts must attribute to the
  reviewed agent carried on the marker.
- buildSelfIterationReceipts: attribute to `marker.agentId ?? agentId` (memory
  runs leave it unset and fall back to the run agentId — unchanged).

Not wired into the mode handlers yet.

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

* ♻️ refactor(agent-signal): S4 — migrate executeSelfIteration to async execAgent

Replace the hand-rolled `executeSelfIteration` runtime (new AgentRuntime +
custom call_llm executor + 6 closure side-channels) with the standard async
`execAgent` queue path. nightly-review / self-reflection / self-feedback-intent
now enqueue via `enqueueSelfIterationRun → execAgent` and project their
receipts/briefs on the `agent.execution.completed` completion path.

- Delete `execute.ts` (1500 lines) + `execute.test.ts`; gut the three server
  adapters (review/reflection/feedback) to drop the synchronous run path and
  the legacy receipt/runtime wiring.
- `aiAgent`: background runs execute under a builtin slug but attribute their
  resource tools + receipts to the *reviewed* user agent via the run marker.
- Drop the orchestrator's `writeDailyBrief` default — nightly review writes its
  brief in-run via the builtin review serverRuntime primitive.
- Add `ReviewRunStatus.Dispatched` for enqueued background runs.
- Completion-path debug logging across CompletionLifecycle / completionPolicy /
  completion handler.

Part of LOBE-9434 (S4 · LOBE-9876).

* 🐛 fix(agent-signal): make execAgent resolve builtin slugs + give self-iteration agents a mini model

Live-testing the S4 self-iteration → execAgent path surfaced two gaps that kept
background runs (nightly-review / self-reflection / self-feedback-intent) from
ever dispatching:

- execAgent threw `Agent not found: <slug>` when addressed purely by a builtin
  slug (the self-iteration dispatch path) because getAgentConfig only resolves
  persisted rows. Lazily materialize the virtual builtin row via
  AgentModel.getBuiltinAgent — mirrors the inbox/task precedent — then re-resolve.
- The three self-iteration builtin agents had no `persist` model, so runs fell
  back to the user's default chat model. Give them `persist: { DEFAULT_MINI_MODEL,
  DEFAULT_MINI_PROVIDER }` (gpt-5.4-mini), matching the legacy executeSelfIteration
  behavior.

Verified live: self-reflection now dispatches, the async operation reaches `done`,
and a `review` completion receipt is projected on the completion path. Adds two
execAgent.builtinRuntime tests (builtin-slug materialization + unknown-id still
throws).

Part of LOBE-9434 (S4).

* 🚨 fix(agent-signal): use type-only import for createServerSelfReviewBriefWriter

After the S4 gutting, review/server.ts only uses createServerSelfReviewBriefWriter
in a `ReturnType<typeof ...>` position — split it into a type-only import to
satisfy @typescript-eslint/consistent-type-imports (the lone lint:ts error).

* 🐛 fix(agent-signal): carry tool apiName in result content so action receipts project

The agent runtime persists tool messages with only content/role/tool_call_id (no
message-level apiName), so the completion extractor's `message.apiName` read was
always undefined in live runs — buildSelfIterationReceipts then dropped every
mutation via `if (!apiName) return []`, so durable skill/proposal writes produced
no action receipt (only the summary survived; memory was exempt via a hard-coded
apiName).

Fix the extraction channel, not the shared runtime:
- ExecutionRuntime stamps `apiName` into the result content alongside `kind`.
- extractFromFinalState reads apiName from the content (message.apiName fallback).

Tests reworked to the real persisted shape (apiName in content, no message-level
apiName) — the prior mocks hid the bug.

Part of LOBE-9434 (S4).

* 🐛 fix(agent-signal): persist run marker to operation metadata for server tools

Self-iteration server tools (nightly-review etc.) read the run marker from
`agent_operations.metadata` via readAgentSignalMarker, but recordStart only
persisted a trimmed appContext and never wrote metadata — so in live runs the
marker was always undefined and review/proposal writes fell back to a 1970
window/localDate + operationId source (non-idempotent).

recordStart now persists `metadata: { agentSignal }` from appContext.agentSignal,
so the tool path matches the completion path (which reads it from finalState).

Part of LOBE-9434 (S4).

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:12:59 +08:00
sxjeru dd2e32cf6f 💄 style: Add new MiniMax-M3 model (#15403)
*  feat(minimax): add MiniMax M3 model with pricing and update tests

* Update minimax.ts

* fix test
2026-06-03 19:29:02 +08:00
YuTengjing a5ab99f055 📝 docs: add agent code style guidance (#15434)
* 📝 docs: add code style guidance for hook extraction and file splitting

* 📝 docs: tighten file-splitting guidance

* 📝 docs: clarify agent guidance wording
2026-06-03 18:45:40 +08:00
Arvin Xu 41bccc4aa8 chore: remove LOBE-XXX markers from code comments (#15422)
chore: remove LOBE-XXX markers from code comments

- match.test.ts: replace (LOBE-9913) marker with inline comment context
- nightly-review.golden.json: replace (LOBE-9434) marker with execAgent migration context

Co-authored-by: Arvin Xu <arvin@lobehub.com>
2026-06-03 17:02:24 +08:00
AmAzing- 1ce4e026a7 🐛 fix(const): point CHANGELOG_URL to /changelog (#15428) 2026-06-03 15:25:48 +08:00
Innei 89c55bf658 💄 style(service-model): polish form layout & migrate Switch to base-ui (#15426)
- align memory model InputNumber height (32px) with base-ui Select trigger via local ConfigProvider
- stack optional features as switch above model select, switch right-aligned
- migrate optional features Switch from antd to @lobehub/ui/base-ui
2026-06-03 14:18:36 +08:00
Arvin Xu 2eb9e34fda feat(stats): add daily token-usage mode to activity heatmap (#15417)
*  feat(stats): add daily token-usage mode to activity heatmap

Add a Messages/Tokens toggle to the stats activity heatmap. The token
mode sums assistant messages' `metadata.usage.totalTokens` (the source of
truth for usage) bucketed by the day each message was created, so tokens
land on the day they were actually consumed rather than on a topic's
creation date. Aggregation runs in SQL (SUM over the jsonb path, GROUP BY
date) and levels are scaled relative to the busiest day.

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

* 💄 feat(stats): format heatmap token counts and add token stat row

- Format tooltip token counts compactly (e.g. 44.2K, 12.5M) via the chart's
  customTooltip; message counts get thousand separators.
- Add a token-dimension summary row (cumulative / peak daily / current streak
  / longest streak) shown in token mode, derived client-side from the heatmap
  data over the past year.

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

*  feat(stats): add longest-task duration to token heatmap stats

Add the "longest task" figure to the token-mode stats row, computed from
the longest wall-clock agent operation (completedAt - startedAt) over the
past year — MAX in SQL on the agent_operations table, scoped by user and
using the (user_id, created_at) index. Rendered as a compact 1h 15m / 45s
duration.

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

* 💄 feat(stats): default heatmap to token mode and move toggle beside title

- Token is now the first/default segmented option (Messages second); the
  share card keeps Messages as its default.
- Move the Messages/Tokens toggle next to the section title (left) via a new
  StatsFormGroup `afterTitle` slot; day tags stay on the right.

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-03 01:54:07 +08:00
Innei 13ce3c52ec ♻️ refactor: migrate modals to @lobehub/ui/base-ui (LOBE-9711 + eval batch) (#15416)
* ♻️ refactor: migrate modals to @lobehub/ui/base-ui (LOBE-9711 + eval)

Move 5 root createModal sites (LibraryModal/AddFilesToKnowledgeBase,
LibraryModal/CreateNew, Electron/AuthRequiredModal, SkillStore,
SkillStore/SkillDetail) to base-ui imperative createModal. Drop
allowFullscreen/destroyOnHidden/getContainer (base-ui handles them),
rename children→content, afterClose→onOpenChangeComplete, styles.body
→styles.content.

For AuthRequiredModal, base-ui imperative ModalInstance.update only
accepts Partial<BaseModalProps>, so the previous closable/keyboard
dynamic lock is reduced to maskClosable only — Esc/X close cannot be
blocked during sign-in.

Convert 11 declarative <Modal open … /> sites under eval/bench to
imperative createXxxModal factories, splitting each into Content.tsx
(body) + index.tsx (factory). Update callers in eval/index.tsx,
bench/[id]/{datasets/[id],features/{BenchmarkHeader,DatasetsTab,
RunsTab,TestCasesTab},runs/[id]/{index,features/RunHeader}} to call
factories on click instead of toggling local open state.

Delete unused TestCasePreviewModal.tsx (dead code); extract the
inline preview Modal from TestCasesTab into a new
TestCasePreviewModal feature folder.

* ♻️ refactor: move modal actions to base-ui footer slot, drop content padding overrides

Per @innei feedback on PR #15416:

- base-ui's ModalContent already has 12px/16px default padding; remove
  manual paddingBlock/paddingInline wrappers in Content components and
  drop styles.content.padding=0 overrides in factories.
- Move OK/Cancel (and other actions) into the createModal({footer}) slot
  using base-ui's ModalFooter atom for proper flex/justify-end styling.
- Form submit wired via antd Form's name + Button form=name htmlType=submit
  so the footer button outside Form can submit it. Shared loading state
  flows from Content to Footer via a per-modal closure that calls
  instance.update({footer: ...}).

New helper src/utils/createFormModal.tsx encapsulates the common pattern
for plain form modals (Cancel + Submit). Custom factories (RunCreate
split-button, BatchResume with selection counter, DatasetImport step-aware
footer, AuthRequired sign-in flow) use inline closure plumbing.

Touched files: 11 modal pairs (Content + Footer + index) + 1 helper.

* 🔥 chore: remove dead AddFilesToKnowledgeBase modal

`useAddFilesToKnowledgeBaseModal` exported from
`src/features/LibraryModal/AddFilesToKnowledgeBase/` had no callers in
the main codebase — only its own test referenced it. Remove the entire
folder (index, SelectForm, index.test) and drop the re-export from
`LibraryModal/index.ts`.

* 💄 style: bleed SkillStore scroll viewport past modal content padding

base-ui ModalContent has 12px/16px default padding, which insets the
SkillStore scroll viewport and makes the scrollbar look blocked. Pull
the body wrapper out with negative margins (marginInline: -16,
marginBlockEnd: -12) so the inner scroll container sits flush with the
modal edge. Grid items inside the scroll keep their own 16px padding.

* 🐛 fix: cast Modal.update to ImperativeModalProps for footer typing

base-ui's ModalInstance.update is typed as Partial<BaseModalProps>,
which excludes the `footer` and `content` fields that only
ImperativeModalProps carries. At runtime the imperative updateModal
spreads any shape, so the cast is sound — narrow it at each call site.

Also delete src/routes/(main)/eval/bench/[benchmarkId]/features/
DatasetRunCreateModal/, an orphaned re-export of RunCreateModal's
removed default export.
2026-06-03 00:17:48 +09:00
YuTengjing f9eb48feea feat: add limited offer & original price locale keys for top-up (#15415) 2026-06-02 21:00:12 +08:00
YuTengjing 8dee729f9f feat: add storage pay-as-you-go stubs and locale keys (#13501) 2026-06-02 20:45:15 +08:00
LiJian 359b348989 feat(agent-builder): add skill priority instruction and server runtime (#15409)
*  feat(agent-builder): add skill priority instruction and server runtime

- Add <skill_coexistence> section to agent-builder system prompt so the
  model always prefers Agent Builder tools over LobeHub skills for
  agent configuration tasks when both are active simultaneously
- Add agentBuilder server runtime to support background (QStash)
  execution: implements updateConfig, updatePrompt, searchMarketTools,
  getAvailableModels (DB-backed, LobeHub provider first, max 20 chat
  models), and installPlugin (market source only; official/OAuth tools
  return a clear unsupported error)
- Register agentBuilderRuntime in the server runtime registry

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

*  feat(agent-builder): fix identity confusion when user provides agent name/purpose

Add <identity_boundary> section and example to prevent the AgentBuilder
from roleplaying as the agent being configured. Short phrases like
"健康助手,咨询健康问题" must be interpreted as configuration requests,
not service requests.

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

* 🐛 fix(agent-builder): address three server runtime issues

- getAvailableModels: use AiInfraRepos instead of raw AiProviderModel +
  AiModelModel so builtin providers (DEFAULT_MODEL_PROVIDER_LIST) are
  included even when the user has no DB-customized providers
- installPlugin (official): allow builtin tools (lobe-web-browsing etc.)
  to be enabled directly; only block OAuth-requiring tools (Klavis,
  LobehubSkill) that cannot be installed in background context
- installPlugin (market): fetch and persist the marketplace MCP manifest
  on install so server tool discovery can find and execute the plugin

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 17:10:30 +08:00
Arvin Xu 0c3450de7c test(model-runtime): classify ollamacloud "context window exceeds limit" as ExceededContextWindow (#15411)
* 🐛 fix(model-runtime): classify ollamacloud "context window exceeds limit" as ExceededContextWindow

ollamacloud surfaces context-window overflow as a generic 400 the upstream
labels ProviderBizError. Document the ollamacloud provenance on the existing
`context window exceeds` ECW pattern and add a regression test asserting the
message wins over the 400 / ProviderBizError catch-alls.

Fixes LOBE-9913

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

* 🔥 chore(model-runtime): drop redundant ollamacloud note on ECW pattern

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:07:43 +08:00
René Wang cbc259094d 📝 docs: add Codex & Claude Code agent guides; merge image+video generation (#15407)
- New docs/usage/agent/{codex,claude-code}.{mdx,zh-CN.mdx} cover how to
  delegate the Codex and Claude Code CLIs from the LobeHub desktop app
  (install, sign-in, working-directory pinning, in-chat tool renderers,
  resume behavior, execution targets, limitations).
- Rename docs/usage/getting-started/image-generation.{mdx,zh-CN.mdx} to
  generation.{mdx,zh-CN.mdx} and expand to cover the Video workspace
  alongside Image.
- Update <Card> links in sibling resource/vision docs to point at the
  new /generation slug.
2026-06-02 13:55:32 +08:00
Arvin Xu ccf33e8b98 🐛 fix(agent-runtime): classify topic/agent/session FK violations as ConversationParentMissing (#15408)
When a user deletes a topic (or agent/session/thread) while an agent operation
is still running, the assistant/tool-message INSERT fails with a Postgres 23503
foreign_key_violation on the corresponding `messages` FK. The persist-error
guard only recognised the `messages_parent_id_messages_id_fk` self-FK, so every
other reference deletion slipped through as a raw `Failed query: insert into
"messages"` 500 — surfacing to the user as a driver/SQL error and polluting the
error dashboard as DatabasePersistError noise (one of the longest-standing
top error categories).

Generalise `isParentMessageMissingError` → `isMidOperationReferenceMissingError`
to match a 23503 violation on any of the mid-operation-deletable `messages`
references (parent / quota message, topic, agent, session, thread). These all
mean "the referenced context was deleted mid-flight" — a lost race against the
user, not a runtime failure — so they are normalised to the typed, user-side
`ConversationParentMissing` error like the parent case already was.

Out-of-scope FKs (e.g. `messages_user_id_users_id_fk`, other tables) stay real
failures.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:23:19 +08:00
YuTengjing d1a6ffaf30 🔨 chore: tighten skill descriptions for triggering (#15397) 2026-06-02 13:00:52 +08:00
qybaihe 66c9339e98 fix(desktop): resolve CLI tools from shell PATH (#15368)
* fix(desktop): resolve CLI tools from shell PATH

* fix(desktop): validate resolved CLI with fallback PATH
2026-06-02 11:29:57 +08:00
LiJian 857aaf4766 feat(chat-input): show execution-device switcher for all agents (#15371)
 feat(chat-input): show execution-device switcher for all agents and add desktop download link

- Remove `!isHeterogeneous` guard so the device switcher surfaces for every agent type (not just non-heterogeneous), controlled by the existing Lab toggle
- Make the sandbox/runtime-env mode selector mutually exclusive with the device switcher: hide it when `enableExecutionDeviceSwitcher` is on
- Add a "下载桌面端 / Get Desktop App" quick link in the execution-device popover header (right side) linking to https://lobehub.com/downloads

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 11:20:52 +08:00
Innei 4e91a3181d ♻️ refactor(modal): convert create custom model modal to base-ui imperative API (#15401)
* ♻️ refactor(modal): convert create custom model modal to base-ui imperative API

* ♻️ refactor(modal): convert edit model modal to base-ui imperative API

* 🐛 fix: make extend params preview read-only
2026-06-02 12:18:27 +09:00
Arvin Xu c9ca46e1e0 chore: remove LOBE-XXX annotations from code comments (#15398)
Replaced all LOBE-XXX references in comments with descriptive context
instead of internal Linear issue markers. As an open-source project, these
internal references should not be exposed.

Changes:
- LOBE-9834 (empty completion): replaced with inline descriptions of the
  "empty completion" failure mode
- LOBE-6587 (task scheduler): replaced with "task scheduler infra" ref
- LOBE-6634 (getTaskDetail model/provider): updated TODO description
- LOBE-9434 #5/#7 (execAgent migration): removed issue markers

12 files changed

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>
2026-06-02 10:46:44 +08:00
Innei 37db828c17 ♻️ refactor(modal): convert feedback & changelog modals to base-ui imperative API
Migrate `FeedbackModal` and `ChangelogModal` from declarative `@lobehub/ui`
modals + a `useFeedbackModal` zustand store to the `@lobehub/ui/base-ui`
imperative `createModal()` API. Call sites now invoke `openFeedbackModal()`
/ `openChangelogModal()` directly — no more open/close state plumbing
through `(main)/_layout` or `(mobile)/me/(home)`. The `useFeedbackModal`
hook is removed.

Also:
- Wrap the email address in `feedback.emailContact` with a `<email>` tag
  (Trans component → mailto link); all 18 locale files updated.
- Restore the external link on the changelog modal header pointing to
  `CHANGELOG_URL`; the previous Button used `onClick={onClose}` despite
  the ArrowUpRight icon suggesting an external navigation.
- Footer test mocks updated to match the new module exports.
2026-06-02 03:00:48 +09:00
Innei 0208c0adfe 🐛 fix(chat-input): restore editor focus after file picker closes (#15394)
Picking files via the antd `Upload` dropdown (paperclip / plus menus) and via
the AgentTasks attachment helpers leaves focus on `document.body` once the OS
file picker dismisses, so the editor cursor disappears and users do not know
they can keep typing.

Refocus the editor right after the file picker yields:

- `ChatInput/ActionBar/Upload`: subscribe to `editor` from the chat input
  store and call `editor?.focus()` in the three `beforeUpload` handlers
  (image / file / folder).
- `ChatInput/ActionBar/Plus`: same fix for the unified file-or-image upload
  entry; add `editor` to the items `useMemo` deps.
- `EditorCanvas/editorAttachments`: refocus inside `insertFilesIntoEditor`
  so every AgentTasks composer (CommentInput, CommentCard, FeedbackInput,
  CreateTask*, TaskInstruction via `pickAndInsertAttachments`) recovers
  the cursor too.

Fixes LOBE-9862
2026-06-02 01:19:36 +08:00
Rylan Cai 09a57d4618 🐛 fix: clarify local command session handling (#15389) 2026-06-02 00:55:17 +08:00
Arvin Xu 73dd0ef136 🔖 chore(cli): bump @lobehub/cli to 0.0.24 (#15393)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:45:24 +08:00
Innei d2e4833f1e 🐛 fix(chat-input): close skill dropdown before navigating to settings (#15391)
The skill market dropdown's settings button navigates to /settings/skill
but does not close the controlled dropdown first, so the popup persists
after the trigger has unmounted (LOBE-9852).

Also restore the negative margins on the skill market footer (regressed
in #15214 when bumping @lobehub/ui to 5.15.1) so the stats row aligns
with the dropdown's outer padding.

Apply the same defensive close-before-navigate to ModelSwitchPanel:
- ListItemRenderer no-provider / empty-model rows previously navigated
  without calling onClose at all.
- Footer and GenerationListItemRenderer now close before navigate
  instead of after, for a consistent ordering.

Closes LOBE-9852
2026-06-02 00:32:12 +08:00
Innei 5119c0802d 🐛 fix(topic): strip markdown tokens from fallback titles (#15372)
Sliced raw user input was leaking syntax (#, **, ``` etc.) into topic / thread / agent / group / document titles whenever LLM summarization had not yet produced a clean title. Run the source string through `markdownToTxt` (remove-markdown) before slicing so the visible fallback is plain text.
2026-06-01 23:22:46 +08:00
Innei 3e51b87b1e 💄 style(sidebar): group spacer with recents and agents (#15373) 2026-06-01 23:22:07 +08:00
Arvin Xu 1e8b5959da ♻️ refactor(topic): drop legacy session→agentId compatibility from topic queries (#15378)
* ♻️ refactor(topic): drop legacy session→agentId compatibility in topic queries

Topic ownership is fully migrated to `topics.agentId`, so the
`agentsToSessions` lookup that mapped a legacy `sessionId` back to an agent
is no longer reachable in practice. Remove it from the agent query, count,
and batch-delete paths — they now match `topics.agentId` directly.

- `query()`: drop the `agentsToSessions` pre-query and the `sessionId` OR
  branch; keep the inbox fully-orphan fallback (all owner columns null),
  which is unrelated to session linkage.
- `count()` / `batchDeleteByAgentId()`: match `topics.agentId` only.
- Remove the now-unused `agentsToSessions` import.

Tests updated to assert session-only legacy topics are no longer matched.

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

* ♻️ refactor(topic): make topic rank/recent agent-centric, drop returned sessionId

Topic ownership is `topics.agentId`, so the topic ranking and recent-topic
queries no longer need to expose or resolve a legacy `sessionId`.

- `TopicModel.rank()` now selects `topics.agentId` instead of `sessionId`;
  `TopicRankItem.sessionId` → `agentId`.
- `TopicModel.queryRecent()` stops selecting `sessionId`.
- `recentTopics` TRPC procedure: drop the `agentsToSessions` batch resolve
  and the `after()` runtime agentId backfill — both keyed off the legacy
  session mapping. Agent topics now map straight through `topic.agentId`.
- Topic ranking UI navigates to `SESSION_CHAT_TOPIC_URL(agentId, topicId)`
  (`/agent/:aid/:topicId`), falling back to the inbox agent id when a topic
  has no agentId, replacing the old `/agent?session=...` query-param link.

Rank test asserts `agentId`; the broader `getTopics` session-resolution
path is intentionally left untouched.

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

*  revert(topic): restore session→agentId resolution in query/count/delete

The integration tests (topic.integration.test.ts) showed this compatibility
is still load-bearing: the topic write path (createTopic / batchCreateTopics
/ updateTopic) persists `sessionId` with `agentId = null`, so dropping the
read-side session→agentId resolution made freshly-created topics
unqueryable/undeletable by agentId.

Revert the read-side removal from `query()` / `count()` /
`batchDeleteByAgentId()` (and their tests) until the write path is migrated to
store `agentId` directly. The agent-centric `rank()` / `queryRecent()` /
`recentTopics` surface changes are kept.

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

* ♻️ refactor(topic): drop session→agentId compatibility from topic read paths

Topic ownership is fully migrated to `topics.agentId` (old data backfilled,
new app no longer depends on sessionId), so the legacy session resolution in
the topic read paths is dead and can go.

- `query()` / `count()` / `batchDeleteByAgentId()`: match `topics.agentId`
  directly; drop the `agentsToSessions` lookup + `topics.sessionId` OR branch.
  The inbox fully-orphan fallback (all owner columns null) is kept.
- `getTopics` TRPC procedure: drop the `after()` runtime agentId backfill and
  the now-unused `AgentMigrationRepo` wiring / `after` import. The sessionId→
  agentId reverse-resolution of the query *filter* is kept for clients that
  still pass a sessionId.
- Update topic integration + model tests to agent-native fixtures; remove the
  legacy-session and runtime-migration cases that exercised the removed path.

The write path (createTopic/batchCreate/updateTopic) is intentionally left
unchanged per scope; no data migration is performed.

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

* ♻️ refactor(topic): keep getTopics runtime agentId backfill during transition

Restore the `after()` runtime migration in `getTopics` (and the
`AgentMigrationRepo` wiring / `after` import). The read paths no longer
resolve sessionId, but the backfill is still needed to migrate straggler
legacy (sessionId-only) topics over the transition window; a legacy topic is
backfilled on first query and becomes agentId-queryable thereafter.

Restore the migration integration tests, adjusted: they assert the agentId
backfill happens after the query rather than expecting legacy rows in the
first (now agentId-only) response.

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

* ♻️ refactor(topic): keep recentTopics runtime agentId backfill

Restore the recentTopics session→agentId backfill removed earlier: re-select
`sessionId` in `queryRecent` (internal only — not exposed in the RecentTopic
response) and re-add the `batchResolveAgentIdFromSessions` resolution + the
`after()` migrateAgentId backfill. Like the getTopics backfill, this keeps
migrating straggler legacy (sessionId-only) topics during the transition.

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

* 💄 chore(topic): drop unnecessary comment churn in topic router/model

Revert the migration/backfill comments to their original wording so the
restored getTopics/recentTopics blocks are byte-identical to canary, and drop
the extra queryRecent select comment. No logic change.

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

* ♻️ refactor(agent): replace session-based assistant ranking with agent-native rank

The assistant usage ranking was session-centric (SessionModel.rank joining
agentsToSessions, returning a sessionId; UI linked /agent?session=...). Rework
it as agent-native:

- Add `AgentRankItem` type (id = agentId); remove `SessionRankItem`.
- Add `AgentModel.rank`: count topics grouped by `topics.agentId`, joined to
  agents for avatar/title, ordered by count. Mirrors the recents filter
  (real agents + inbox, excluding other virtual agents). No sessions involved.
- Add `agent.rankAgents` TRPC procedure + `agentService.rankAgents`; remove
  `session.rankSessions`, `sessionService.rankSessions`, `SessionModel.rank/_rank`.
- AssistantsRank UI: navigate to `SESSION_CHAT_URL(agentId)` → `/agent/:aid`,
  resolving the inbox title via the store's inboxAgentId.

Move the rank tests from session.test.ts to agent.test.ts.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:51:28 +08:00
Rylan Cai 5b25b8d8bb ️ perf: skip user count on api key checks (#15385) 2026-06-01 22:25:38 +08:00
YuTengjing fd82f6fd0e 🐛 fix: add restore subscription copy (#15388) 2026-06-01 21:31:22 +08:00
YuTengjing 80c11a09e2 🐛 fix: stabilize home starter loading (#15386) 2026-06-01 20:48:48 +08:00
Arvin Xu c8096590c4 feat(topic): add group-by-status mode to topic sidebar (#15366)
*  feat(topic): add group-by-status mode to topic sidebar

Add a new "By status" grouping option to the agent topic sidebar. Topics
bucket into fixed-priority groups — waitingForHuman first, then running,
then active, with the remaining states below. Topics without a status are
treated as active. Only non-empty groups render.

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

*  feat(topic): resolve group-by-status ordering on the server

The sidebar only loads the first page of topics, so grouping that partial
list client-side could hide high-priority topics (awaiting-human / running)
that live on a later page. Push the ordering to the query instead.

- Add `sortBy: 'updatedAt' | 'status'` to the topics query (TRPC + model).
  `status` orders by a priority CASE (waitingForHuman → running → active →
  paused → failed → completed → archived) before the updatedAt tiebreaker,
  so the most important topics always land on the first page.
- Plumb `sortBy` through the service, store fetch action (incl. SWR key),
  and the shared topic hooks; `useFetchChatTopics` requests `status` ordering
  only when the resolved agent group mode is `byStatus` (group sessions keep
  the default). The client still buckets for display, now over a correctly
  ordered page.

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

* 🐛 fix(topic): bucket streaming topics under "running" in group-by-status

A topic generating a response shows the loading ring via the client-only
`topicLoadingIds` state, not a persisted `status`, so it was landing in the
"active" group. Mirror the sidebar TopicItem icon precedence when bucketing:
waitingForHuman wins, then a topic that is streaming on this client (or
persisted as running) goes to "running", then the persisted status.

The loading overlay stays client-side (the server can't know a given client
is mid-stream); the selector passes `topicLoadingIds` into the byStatus
grouping only.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:44:38 +08:00
YuTengjing dda527926d feat: support configurable model routing and starters (#15384) 2026-06-01 18:46:00 +08:00
YuTengjing 5f0fa7bf50 🐛 fix: block disabling official provider (#15382) 2026-06-01 17:39:54 +08:00
YuTengjing c50d790feb 🔨 chore: pin Vitest 3.2.4 (#15383) 2026-06-01 17:30:31 +08:00
LiJian 4d030e9db1 🐛 fix(agent-manager): guard createAgent against LLM double-encoded array fields (#15381)
* 🐛 fix(agent-manager): guard createAgent against LLM double-encoded array fields

When an LLM calls createAgent, it can send plugins/tags/openingQuestions
as a JSON string (e.g. '["lobe-cloud-sandbox"]') instead of a proper
array. This caused jsonb to store a double-serialized string rather than
an array, breaking downstream ETL queries with "cannot get array length
of a scalar".

updateAgentConfig already had this guard (line 130+); apply the same
parseArrayParam helper to all three array fields in createAgent.

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

* 🐛 fix(agent-manager): guard server-side createAgent against double-encoded array fields

Same LLM double-encoding guard applied to the server-side execution path
(src/server/services/toolExecution/serverRuntimes/agentManagement.ts),
which directly calls agentModel.create() and was equally vulnerable.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:00:42 +08:00
YuTengjing 56ce192c61 🐛 fix: default provider setup in business mode (#15379) 2026-06-01 16:03:14 +08:00
YuTengjing 21a73b22b2 feat: support MiniMax M3 Anthropic video runtime (#15380) 2026-06-01 16:00:06 +08:00
LiJian 818e67d1f0 🐛 fix(gateway): prevent duplicate streaming from stale reconnects (#15354)
* 🐛 fix(gateway): prevent duplicate streaming from stale reconnects

When a new agent execution starts for a topic that has a stale
`runningOperation` in its metadata, `useGatewayReconnect` would still
attempt to reconnect to the old operation concurrently with the new one,
producing duplicate streaming events.

Fix by:
1. Optimistically updating the topic's `runningOperation` to the new op
   immediately after `executeGatewayAgent` creates it, and disconnecting
   any live reconnect connection for the stale op.
2. Skipping `connectToGateway` in the reconnect path when the topic
   already has a newer running operation ID.

* 🐛 fix(gateway): add post-refresh stale-op guard and fix test mocks

Two issues fixed:

1. Race condition: the `reconnectToGatewayOperation` guard only ran before
   `refreshGatewayToken`. A stale reconnect that passed the initial check
   could still proceed after the token refresh if `executeGatewayAgent`
   started a new operation during the await. Re-check `runningOperation`
   after the token refresh to bail out in that case.

2. Test failures: the `executeGatewayAgent` mock state was missing
   `topicDataMap`, causing `topicSelectors.getTopicById` to crash with
   "Cannot read properties of undefined". Added `topicDataMap: {}` and
   `internal_dispatchTopic` to both mock setups.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:18:06 +08:00
Arvin Xu e14f2e96f6 🐛 fix(cli): auto-register device on login, matching desktop (#15377)
* 🐛 fix(cli): auto-register device on login, matching desktop

Device registration previously only ran in `lh connect`, so `lh login`
left no device row until the user separately connected the gateway. The
desktop app registers on login; this aligns the CLI.

Extract the shared identity-resolution + register logic into
`device/register.ts` (`resolveDeviceIdentity` + `registerDevice`) and call
it from `login` right after auth (best-effort, non-fatal). `connect` keeps
its own call as an idempotent fallback for `--token` sessions that never
went through login.

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

* 🐛 fix(cli): skip login-time device registration for fallback identities

When node-machine-id can't read a machine id, deriveDeviceId returns a
fresh random id with identitySource 'fallback'. Since `lh login` has no
--device-id and persists no fallback id, registering it on every login
spawns orphan device rows that never match the id a later `lh connect`
resolves. Defer registration to connect in that case.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 13:24:22 +08:00
Arvin Xu cf5ec7b96a test(hetero-agent): use canonical usage fields in persistence fixtures (#15375)
The HeterogeneousPersistenceHandler fixtures wrote `metadata.usage` as
`{ inputTokens, outputTokens }` — a shape the het adapters never emit. Both
claudeCode/codex build canonical `UsageData`
(`totalInputTokens`/`totalOutputTokens`/`totalTokens`) via `toUsageData()` and
`handleTurnMetadata` persists it unchanged, so production het messages already
carry canonical fields that the topic usage rollup sums correctly.

The unrealistic alias fixtures made it look like rollups would store
`total_* = 0` for Claude Code/Codex topics. Align the fixtures with real
adapter output.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:51:31 +08:00
Arvin Xu c3f91f10ac feat(database): maintain denormalized topic usage/cost rollup from messages (#15365)
*  feat(database): maintain denormalized topic usage/cost rollup from messages

Topics carry usage/cost aggregate columns (total_input_tokens /
total_output_tokens / total_tokens / total_cost / usage / cost / model /
provider) mirroring agent_operations, but nothing populated them. Add a
canonical derived-projection rollup maintained live from the topic's messages.

- `recomputeTopicUsage(trx, userId, topicId)` (new `models/topicUsage.ts`):
  sums the topic's `role='assistant'` messages (thread messages included — they
  carry topic_id too) over the canonical `metadata.usage`, grouped by
  (provider, model). Writes the same shape as agent_operations: scalar totals,
  a flat `usage` jsonb ({ llm:{ apiCalls, processingTimeMs, tokens }, tools,
  humanInteraction }), and a `cost` jsonb ({ total, currency, llm:{ byModel[] },
  tools }) — NULL when no model reported cost. `model`/`provider` = the
  dominant model by total tokens. Pure derived & idempotent: resets to NULL
  when no measurable usage remains, so deletes/regenerations are reflected.

- Hook it into MessageModel at the shared chokepoints, inside the existing
  transactions: `update()` (only when the incoming payload carries
  `metadata.usage`, i.e. assistant finalize / hetero step — streaming
  content-only updates don't trigger it) and `deleteMessage()` /
  `deleteMessages()` (recompute affected topics). This covers all LLM-call
  write paths since they funnel through MessageModel.update.

- `TopicModel.recomputeUsage(id)` wraps the canonical fn in a transaction for
  external callers (e.g. the historical backfill).

Tool/human-interaction sub-totals are left as a zero skeleton (not
reconstructable from assistant messages); the historical backfill will reuse
the same aggregation via raw SQL without bumping updated_at.

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

*  test(database): cover topic usage/cost rollup recompute

Add tests for the denormalized topic usage rollup: core
`recomputeTopicUsage` aggregation (per-model grouping, dominant model,
cost-null path, userId scoping, NULL reset), the `TopicModel.recomputeUsage`
wrapper, and the `MessageModel` update/delete hooks.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:21:47 +08:00
Arvin Xu 650a178709 feat(agent-signal): register self-iteration builtin tool package (#15364)
Part of LOBE-9434 (#3). Gives the three (currently dormant) self-iteration
builtin agents a declarative tool surface so they no longer run with no tools.

One package `@lobechat/builtin-tool-agent-signal` with internal subdirs:
- `shared/`: the 3 stable identifiers, JSON-schema specs for the full tool
  surface (resource / review / reflection), a result-kind map (read | artifact
  | mutation — the LOBE-9434 #5 discriminator), `createAgentSignalManifest`,
  and one shared `AgentSignalToolExecutionRuntime` that dispatches per api name
  and stamps every result with its kind so `extractFromFinalState` can
  partition outcomes from a persisted snapshot.
- `review/` `reflection/` `feedback-intent/`: per-mode manifests assembled from
  the shared specs + a mode-specific system prompt, exported under their three
  stable identifiers. Review = resource + proposal/idea tools; reflection &
  feedback-intent share the resource + reflection-recorder surface.

Registered all three manifests in `@lobechat/builtin-tools`. `executors` is
omitted on purpose — BuiltinToolManifest defaults to server-only execution.

The server-side execution bridge (wiring the ExecutionRuntime to the existing
createToolSet(adapters) from ToolExecutionContext) lands with the
executeSelfIteration -> execAgent migration (#7); the ExecutionRuntime is
exported (./executionRuntime) and ready for it. No production self-iteration
path is touched — executeSelfIteration still serves all current runs.

Tested: shared ExecutionRuntime (dispatch + kind stamping + error handling) and
review manifest structure. bun run type-check clean for everything touched.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 12:09:06 +08:00
Arvin Xu e1d6b30127 🐛 fix(desktop): relocate visual-ref helpers to @lobechat/const to fix renderer crash (#15369)
🐛 fix(desktop): relocate visual-ref helpers to @lobechat/const

PR #15114 added @lobechat/prompts + @lobechat/tool-runtime to the desktop
nested pnpm workspace. pnpm then linked their @lobechat/types dependency to
the desktop type-only stub (apps/desktop/stubs/types) inside the SHARED
packages/*/node_modules, which the renderer/web build also consumes. The
runtime value import `createVisualFileRef` (in prompts/files/{image,video}.ts)
resolved to the stub — which only surfaces types — so the renderer crashed on
boot with `SyntaxError: ... does not provide an export named createVisualFileRef`.

The stub is load-bearing: pointing the desktop workspace at the real
@lobechat/types fails install (model-bank@workspace:* dependency cascade), so
the stub must stay. Fix the contract instead: visual-ref helpers are runtime
logic, not types, so they don't belong in @lobechat/types. Move the
zero-dependency helpers to @lobechat/const/visualRef (already a real desktop
member, no cascade) and import them via the narrow subpath. prompts/tool-runtime
now only `import type` from @lobechat/types, so the stub link is harmless.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:46:03 +08:00
Arvin Xu 7d1086b096 🐛 fix(remote-device): preserve content/state across gateway tool calls (#15114)
The cloud→gateway→desktop path was JSON.stringify-ing the entire IPC result
into `content`, dropping `state` and leaking `{success: true, ...}` into the
LLM-facing prompt. Routes remote tool calls through `LocalSystemExecutionRuntime`
(same runtime the renderer uses) so `content` is the formatted prompt and
`state` flows through `DeviceProxy` → `RuntimeExecutors` into `pluginState`.

Also moves `LocalSystemExecutionRuntime` from `@lobechat/builtin-tool-local-system`
(renderer-coupled, React/antd peers) into `@lobechat/tool-runtime` so the
desktop main process can reuse it without pulling UI deps.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:01:25 +08:00
LiJian 58c671b7ac 🐛 fix(agent-builder): explicitly sync editing agent ID to chatStore (#15357)
The Agent Builder reads the wrong agent's context because
`getChatStoreState().activeAgentId` — which the chat service uses to
build `agentBuilderContext` — can drift from the agent currently open in
the profile editor under certain timing conditions (SWR cache hits,
navigation order, React effect scheduling).

Fix: `AgentBuilderProvider` now accepts an `editingAgentId` prop and
writes it to `chatStore.activeAgentId` in a `useEffect`. This makes
the data flow explicit instead of relying on `AgentIdSync` alone.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:19:38 +08:00
Arvin Xu e0ead38c20 ♻️ refactor(agent-signal): restore 3 mode-specific self-iteration agent slugs (#15202)
The Phase 1 consolidation into a single `self-iteration` slug (PR #15187,
inheriting commit 627f899895 from the closed #15116) conflated three
distinct background flows that have:

- Independent receipt tables and idempotency Redis namespaces
- Different preflight / brief projection paths
- Different audit pipelines

`one identifier = one behavior` is a load-bearing contract once these
agents are routed through the standard execAgent plugin lookup. Restore
the 3 mode-specific slugs so each agent declares its own tool surface:

| slug                    | future plugin identifier        |
| ----------------------- | ------------------------------- |
| `nightly-review`        | `agent-signal-review`           |
| `self-reflection`       | `agent-signal-reflection`       |
| `self-feedback-intent`  | `agent-signal-feedback-intent`  |

`SELF_ITERATION_AGENT_SLUGS` now contains all three; `completionPolicy`
dispatches on slug membership rather than equality; callback receives
the resolved `agentId` so mode-specific bookkeeping can route from it.

Plugin arrays reference the future identifiers but the tool packages
are not yet registered — invoking any of these agents today runs the
LLM with no tools (dormant by design). Tool-package registration
follows in a separate PR.

No behavior change for existing callers (none invoke these slugs yet).
2026-06-01 10:03:42 +08:00
René Wang f71be63bea 📝 docs: add May 26 weekly changelog (#15183) 2026-06-01 09:36:32 +08:00
Tsuki 4d840e9071 feat(push): add PushChannel, receipt cron, and pushToken tRPC API (#15233)
Send-side machinery for mobile push notifications (LOBE-8771), stacked
on top of the schema PR (#15186).

### tRPC
- `pushToken.register` / `pushToken.unregister` exposed on both
  `MobileRouter` and `LambdaRouter`.

### `PushChannel`
- Structurally compatible with cloud's `NotificationChannel` so cloud
  can register it without casts.
- Fans a single notification out to all of a user's tokens, chunks via
  `expo-server-sdk`, respects the 600 msg/sec project limit with 100ms
  throttle between chunks.
- Embeds `(ticketId, expoToken)` pairs in `providerMessageId` for
  receipt reconciliation.
- Returns `no_tokens` / `invalid_tokens` / `rate_limited` /
  `all_send_failed` so callers can distinguish.

### `processPushReceipts`
- Pure helper to be called by cloud's Vercel cron (companion PR).
- Polls Expo receipts in parallel (`Promise.all` across chunks),
  updates `notification_deliveries` in bulk, prunes `push_tokens` rows
  flagged `DeviceNotRegistered`.
- Configurable lookback window + min-age guard (default: 24h / 15min).

### Dev tooling
- `/api/dev/test-push` (404s in production) lets you fire a real push
  directly to a user's registered tokens, bypassing `NotificationService`.
  Useful for end-to-end verification before cloud submodule sync.

### Types
- `NotificationSettings` gains an optional `push` channel.

Tests: 21 added (router 7, PushChannel 7, processPushReceipts 7).

Linear: https://linear.app/lobehub/issue/LOBE-8771

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 01:49:04 +08:00
Arvin Xu d382df1b2c ♻️ refactor(agent-runtime): persist canonical nested usage/performance on assistant messages (#15359)
 feat(agent-runtime): persist canonical nested usage/performance on assistant messages

The standard agent chat path (RuntimeExecutors) only flattened token usage
onto message metadata and never persisted performance metrics, while the
heterogeneous and client store paths already wrote the canonical nested
`metadata.usage` / `metadata.performance`. Converge the server path so all
writers produce the same shape:

- capture `data.speed` (ModelPerformance) from the model-runtime onCompletion
  callback and write `metadata.performance`
- write nested `metadata.usage` alongside the existing flat fields (kept for
  backward-compatible readers) on both the normal and interrupted finalize
- read usage/performance from the nested shape first (flat fallback) in the
  usage service

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 01:28:53 +08:00
Zhijie He d75e193ea0 💄 style: add intern-s2-preview support, support thinking_mode (#15308)
* style: add intern-s2-preview support, support thinking_mode

* chore: remove stream limited with tools

* fix: fix search missing for intern-s1-pro

* chore: migrate to processModelList for model fetch

fix: fix ci error
2026-06-01 01:20:00 +08:00
Arvin Xu 7989952d2e feat(agent-signal): add CLI trigger command + golden snapshot fixture (#15360)
Part of LOBE-9434 (#1 / LOBE-9435). Adds the local verification tooling the
execAgent migration depends on.

- `lh agent-signal trigger`: enqueue any producer-side Agent Signal source
  event for the authenticated user (nightly_review / self_reflection /
  self_feedback_intent / user.message / tool.outcome.*)
- server: `buildTriggerSourceEvent` default-payload builder +
  `AGENT_SIGNAL_TRIGGER_SOURCE_TYPES` allowlist, surfaced through a new
  authed `agentSignal.triggerSourceEvent` tRPC procedure that re-derives
  userId from context (owner-scoped, override can't repoint it)
- golden snapshot fixture + dependency-free `assertGoldenFinalState`
  structural assertion (ideas/intents/writeOutcomes >= 1, brief non-empty)
  for use by the migration regression tests
- builder unit tests + offline/live e2e, regenerated man page

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 01:06:09 +08:00
Tsuki 480f6a8e7b feat(task): support file & image attachments (#15141)
*  feat(task): support file & image attachments (LOBE-8967)

Adds attachment / image upload to all four Task input surfaces (Create
Modal, Inline Entry, Task Instruction, Comment Input, Feedback Input)
plus comment edit. Attachments persist in `tasks.editor_data` /
`task_comments.editor_data` as part of the Lexical JSON state and flow
into agent runs via `execAgent.fileIds` — images as multimodal vision
content, documents through `documentService.parseFile` for text
extraction.

Server-side fileId resolution rides on the editor's
`extractMediaFromEditorState` (`@lobehub/editor/headless` 4.15.1), so
no junction tables are needed — editor_data is the single source of
truth. The /f/{fileId} proxy URL contract from the file router stays
the bridge between editor URLs and backend file lookup.

Five UI surfaces share `EditorCanvas` + `editorAttachments` for inline
attachment insertion. Comment display renders the Lexical state via
`@lobehub/editor/renderer`'s `LexicalRenderer` so image sizes round-
trip without the EditorCanvas hydration flash.

DB schema (`tasks.editor_data jsonb` column) landed separately via
#15280.

Fixes LOBE-8967

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

* 🐛 fix(task): correct fileId prefix + accept nodes without status

Real-world editor_data exposed two bugs in the regex-based extract:

1. `fileId` prefix was wrong — the regex looked for `fle_…` but
   `idGenerator('files')` actually produces `file_…`, so every proxy
   URL `/f/file_…` silently failed to match.
2. `@lobehub/editor`'s `extractMediaFromEditorState` requires
   `status === 'uploaded'` strictly. Editor data from the cloud upload
   path and from historical inserts omits the `status` field entirely,
   so the upstream helper silently dropped everything. Walk the tree
   ourselves and treat a missing `status` as uploaded.

Verified against real `tasks.editor_data` rows: T-6 (proxy URL form)
now extracts `file_…` correctly. T-8 (cloud R2 signed URL form) still
returns `[]` — that requires either aligning cloud's `createFile` to
return the proxy URL or adding a DB-fallback resolver, tracked as a
follow-up.

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

* 🐛 fix(task): resolve fileIds from pre-signed editor URLs via files.url lookup

Root cause: `fileService.getFileAccessUrl()` returns different URL forms
depending on the environment:

- prod / non-dev → `getFileProxyUrl(fileId)` = `${APP_URL}/f/{fileId}`
- dev → `getFullFileUrl(file.url)` = a pre-signed R2/S3 URL

The dev branch is intentional so remote model providers can fetch the
file directly (proxy URLs point to localhost and aren't reachable). But
the pre-signed URL doesn't contain the fileId anywhere, so our regex
extract silently returned [] for every local upload — agent never saw
any attached image.

Same shape happens for historical cloud data where the editor stored
pre-signed URLs.

Fix: make `extractFileIdsFromEditorData` async and take a `{ db, userId }`
context. Fast path stays the proxy-URL regex; URLs that don't match fall
back to a single batched `SELECT id FROM files WHERE user_id = ? AND url
IN (…)` keyed on the storage path extracted from each URL's pathname.

Verified against real local data:

  T-6 (proxy URL form)         → file_2vFD2sdzW9VO   (regex fast path)
  T-8 (pre-signed R2 URL)      → file_cAQ4naT8G8r5   (DB fallback)
  T-9 (pre-signed R2 URL × 2)  → file_…, file_…      (DB fallback)

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

* 🐛 fix(task): dedupe fileIds by storage key in DB fallback

Same bytes re-uploaded by the same user produce multiple `files` rows
with identical `url` + `file_hash`. The DB fallback in
`extractFileIdsFromEditorData` was returning every matching row, so a
task with one inline image but three historical upload attempts fed
the agent three copies of the same image — wasteful multimodal tokens
and noisy provider input.

Group results by `files.url` and keep the first row per key. Verified
against real local data:

  T-6  (1 img, 1 upload)              → 1 fileId
  T-8  (1 img, 1 upload)              → 1 fileId
  T-9  (1 img, 2 dup uploads)         → 1 fileId (was 2)
  T-10 (1 img, 3 dup uploads)         → 1 fileId (was 3)

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

* 💄 style(editor): render inline file nodes as block-level cards

The default @lobehub/editor `ReactFile` decorator paints file attachments
as a tiny inline pill (icon + filename in monospace, inline-block with
0.4em padding), so a single PDF on its own line looked cramped and
hugged the surrounding text.

Override the upstream styling via the `className` prop the plugin
already exposes: full-width flex row, 10px gap, 14px padding,
`borderRadiusLG` corner, subtle hover, primary tint on `.selected`.
Aligns the editor's file attachment row with the Linear attachment
card look — and with the LexicalRenderer card the comment thread
already uses, so the same file looks consistent across surfaces.

The upstream component still only renders icon + name (no size), but
the layout change is the main UX win.

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

* 💄 style(editor): Linear-style file card with hover download

Replace the upstream inline pill FileNode UI with a full-width card
(icon + name + size + hover-revealed download button) wired in both the
live editor and the read-only LexicalRenderer for saved comments.

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

* 🐛 fix(editor): use existing editor:file.* keys for file card states

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 00:34:18 +08:00
Arvin Xu 45a6f2b440 🐛 fix(agent-runtime): retry empty LLM completions instead of silent done (#15355)
When a stalled tool loop made the model return an empty completion (no
content, no tool calls, ~0 output tokens), the harness finalized the
operation to `done` and persisted a blank assistant message — an empty
bubble with `status=done, error=null`, completely silent.

The call_llm executor now detects this "gave up" turn and throws
`ModelEmptyError`, which its existing LLM retry loop catches and re-issues
(a retry usually yields real content). Empty completions use a dedicated
retry budget (EMPTY_COMPLETION_MAX_RETRIES) so the branded provider — which
has 0 general retries because its own fallback chain re-routes failed
requests — still re-issues an HTTP-200-but-empty turn (the LOBE-9834 repro
path). If every retry is also empty, it propagates to a readable,
dashboard-visible terminal error (`ModelEmptyCompletion`, E8014, provider
attribution, countAsFailure) instead of a silent done.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:37:36 +08:00
LiJian 4bc77fc103 🐛 fix(creds): replace hardcoded session_context values with template variables (#15352)
* 🐛 fix(creds): replace hardcoded session_context values with template variables

- Replace hardcoded `Current user`, `Session date`, `Sandbox mode` in
  systemRole.ts with {{username}}, {{session_date}}, {{sandbox_enabled}}
- Inject {{session_date}} via Intl.DateTimeFormat in RuntimeExecutors
- Remove isCredsEnabled gate so {{CREDS_LIST}} / {{KLAVIS_SERVICES_LIST}}
  are always substituted when userId is available, regardless of execution path

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

* 🧪 test(creds): mock klavisEnv to prevent t3-oss jsdom throws in tests

klavisEnv uses @t3-oss/env-nextjs which throws in jsdom (vitest treats
it as a client context). Previously the isCredsEnabled gate short-circuited
before the access; now that the gate is removed, the mock is needed.

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

* 🐛 fix(creds): add client-side generators and restore isCredsEnabled gate

- Add session_date and sandbox_enabled variable generators to
  contextEngineering.ts so client-side renders substitute them correctly
- Restore isCredsEnabled gate in RuntimeExecutors to avoid fetching creds
  on every call_llm step; now checks both enabledToolIds (client-activated
  path) and manifestMap (execAgent path) to cover all execution paths

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

* 🔨 chore(creds): revert isCredsEnabled gate in RuntimeExecutors

Remove the isCredsEnabled OR-condition that caused execAgent test failures.
Keep session_date, sandbox_enabled, and always-inject CREDS_LIST/KLAVIS_SERVICES_LIST.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 20:03:51 +08:00
Arvin Xu e4d5017e76 feat(device): add recent directory management with drag-to-reorder (#15351)
Add a full-width "Add directory" button to pick a folder via the native
picker, make the recent directories list reorderable via SortableList, and
drop the Save button so all device edits (name, default cwd, recent dirs)
persist immediately.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:22:34 +08:00
Zhijie He 27121a6f1a 💄 style: add step-3.7-flash support (#15317)
* style: add step-3.7-flash support

* chore: support step-3.5 reasoning effort
2026-05-31 17:02:32 +08:00
Arvin Xu 373b5e90b2 style(device): run remote CC on a configured device (#15343)
*  feat(device): run remote CC on a configured device with cwd + device context

Make `claude-code`/`codex` dispatched to an `lh connect` device (executionTarget
='device') run in the user's configured directory with a device-appropriate
system context, instead of inheriting the cloud-sandbox setup.

3a — server cwd passthrough:
- resolve the run cwd in the useDevice branch: topic-level workingDirectory
  override > the bound device's `defaultCwd` (read from DB via DeviceModel; the
  gateway only knows live connections, not the user-owned cwd), and pass it to
  dispatchAgentRun.

3b — device-specific systemContext, end to end:
- new `buildRemoteDeviceHeteroContext` — strips the cloud-sandbox boilerplate
  (ephemeral /workspace, pre-cloned repos, commit-or-lose warnings) that would
  mislead an agent on the user's own persistent machine; keeps agent static
  context + resumed conversation history + a minimal cwd note.
- thread `systemContext` through the contract: AgentRunRequestMessage,
  GatewayHttpClient.dispatchAgentRun, deviceProxy.dispatchAgentRun.
- desktop: spawnLhHeteroExec now injects systemContext as the first text block
  of a content-block array on stdin (mirrors spawnHeteroSandbox); previously it
  wrote only the bare prompt, so any context was silently dropped.

The gateway relays unknown fields transparently (`...runParams`), so no gateway
change is needed.

Tests: buildRemoteDeviceHeteroContext unit (6) + GatewayConnectionCtr forwards
cwd/systemContext. type-check clean; existing device/desktop/pkg suites green.

Part of LOBE-9579 (Step 3a/3b). Old ephemeral boundDeviceId migration (3d) and
the web cwd picker (3c) are out of scope here.

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

*  feat(device): optimistic device cwd persistence (defaultCwd + recentCwds)

Foundation for the device-scoped cwd picker (executionTarget=device): persist a
working-directory pick to the bound device's registry record so the server's
hetero dispatch (which reads device.defaultCwd) stays in sync and the picker can
offer recent dirs.

- nextRecentCwds: pure most-recent-first / dedupe / cap-20 list builder (the
  server stores recentCwds verbatim, so the client owns this) — unit tested.
- useUpdateDeviceCwd: optimistic `device.updateDevice` — patches the listDevices
  cache in onMutate for instant UI, invalidates onSettled to re-sync truth (self-
  corrects a failed write without manual rollback).

Not yet wired into a picker — the target=device recentCwds-list + manual-input
picker mode that consumes this is the next step.

Part of LOBE-9579 (Step 3c, data layer).

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

*  feat(device): gate send on bound-device online for device-targeted hetero

Extend the pre-send device guard from remote-only agents (openclaw / hermes) to
any hetero agent whose run dispatches to a device — i.e. claude-code / codex with
executionTarget='device'. If the bound device is offline (or none is bound), the
send button is disabled and a guard alert is shown, instead of letting the run
fail at dispatch time.

- new selector currentAgentExecutionTarget
- isDeviceExecution = remote-typed OR executionTarget==='device'; drives the
  guard's enabled flag, the blocked state, and the alert.
- device execution no longer requires cloud credentials (it doesn't use the
  cloud sandbox), so the cloud-not-configured gate now exempts it.

The guard hook already handled non-remote types (online check only, no platform
capability probe), so no hook change is needed.

Part of LOBE-9579 (Step 3, device online guard).

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

* 💄 style(tool-render): flatten nested-background tool renders into single-layer surfaces

Remove the card-in-card look across builtin tool renders by dropping the outer
colorFillQuaternary container fill (the framework tool card already provides the
surface) and keeping at most one delineated inner box.

- claude-code AskUserQuestion: rebuilt as a flat Question / divider / Selected
  layout; add i18n keys (question/selected/reply/noAnswer)
- claude-code Skill, local-system WriteFile: flat container + single previewBox
- agent-management CreateAgent/GetAgentDetail: flat container, keep outlined
  systemRole block
- web-onboarding SaveUserQuestion: drop the redundant inner value box

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

* 📝 docs(builtin-tool): document single-layer surface rule for tool renders

Add §0.8 "stay single-layer — don't nest filled cards": the framework tool
card is already the surface, so the Render's outer wrapper carries no fill and
at most one filled box delineates real content. Cross-link from §2 Render rules
and the diagnostic table, and note the deliberate outlined-panel exception
(TodoWrite / Task).

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

* 📝 docs(builtin-tool): consolidate fragmented UI shared-style rules

The §0 shared rules had drifted into 8 one-line subsections (0.1–0.8). Fold the
five mechanical "every file looks like this" rules ('use client', memo +
displayName, BuiltinXProps generics, t('plugin'), store reads) into a single
annotated component skeleton (0.1), merge the two styling rules into 0.2, and
keep the single-layer surface rule as 0.3. Update the §0.8 cross-references in
§2 and the diagnostic table to §0.3.

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

* 📝 docs(builtin-tool): split UI reference into a per-topic ui/ folder

The single 770-line ui.md had grown unwieldy. Break it into references/ui/
with a README index and one file per topic: principles, shared-rules, the six
surfaces (inspector/render/placeholder/streaming/intervention/portal),
composition, and diagnostics. Convert in-doc §-number cross-refs to cross-file
links and repoint SKILL.md + tool-design.md at the new folder.

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

*  feat(device): device-scoped cwd picker for executionTarget=device

When a hetero run is bound to a remote device, the device's filesystem isn't
browsable from here, so the local folder picker doesn't apply. Add
DeviceWorkingDirectory — a self-contained bar item (chip + popover) sourced from
the bound device's recentCwds plus a manual path input.

- Picking/typing a cwd pins it to the active topic (override) and persists it to
  the device via useUpdateDeviceCwd (optimistic defaultCwd + recentCwds), which
  is exactly what the server's device-dispatch branch reads back.
- Same per-cwd CC-session-reset confirm as the local picker.
- WorkingDirectoryBar routes to it when executionTarget==='device' (both web —
  replacing CloudRepoSwitcher — and desktop, replacing the local picker +
  GitStatus); local/sandbox paths are unchanged.
- Reuses existing i18n keys (recent / noRecent / placeholder).

Completes LOBE-9579 Step 3c. type-check clean.

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

* 💄 style(tool-render): flatten ToolResultCard + de-duplicate Read header

ToolResultCard was the card-in-card shared component (colorFillQuaternary
wrapper around a colorBgContainer box) behind CC Read/Grep/Glob/Write/WebSearch/
WebFetch. Flatten it to single-layer (flat wrapper, one colorFillTertiary
content box) so all consumers stop stacking fills inside the framework tool card.

CC Read header showed the filename strong-label and then dumped the full
absolute path whose tail repeated the same basename, end-truncated so the
meaningful suffix was hidden. Show the directory only (filename stays the
strong label), and drop the conflicting word-break so the dir ellipsizes on one
line.

Note ToolResultCard in the skill as the canonical single-layer header+content
card to reuse.

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

* 💄 fix(device): mark current device, native cwd browse, fix edit Save button

Settings → Devices page polish:
- Badge the row for the machine you're on ("This device"), resolved from the
  desktop gateway's own deviceId (web has no current device → no badge).
- For the current device, the edit modal's Default working directory gains a
  native folder picker (electronSystemService.selectFolder) next to the manual
  input — you can't browse a remote device's filesystem, only your own.
- Edit modal footer now uses real Button components (Cancel + primary Save)
  instead of the base-ui Modal's default okText, which rendered with the wrong
  (non-primary) color.

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

* 💄 fix(device): neutral current-device tag + per-channel tags

- "This device" badge uses the default neutral tag instead of success green.
- Show each live connection's channel as a small tag (desktop / cli) so a
  multi-channel device's connections are individually legible.

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

*  feat(devtools): add API jump-list column to the render gallery

The render gallery stacked all of a toolset's API previews in one scroll column
(67 for Claude Code), making any specific render slow to find. Add a middle
column listing the toolset's apiNames: clicking scrolls the matching preview
card into view (landing below the sticky lifecycle bar via scroll-margin), and
an rAF-throttled scrollspy highlights the API the reader is on and keeps that
item visible in the list. A leading dot marks APIs that ship a Render. The
content area now owns its own scroll so the list stays pinned.

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

* 💄 fix(devtools): make the API jump-list readable + deep-linkable

The jump-list was a wall of identical `mcp__claude_ai_Linear__…` truncations and
the active item barely differed from hover. Show just the trailing action for
mcp__ tools (full id in a title tooltip + the preview card header), render names
in monospace, and give the active item a primary left-accent so it reads as
selected. Clicking now pins a `#api-<name>` hash (deep-linkable / shareable) and
loading a hashed URL jumps straight to that card below the sticky bar.

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

*  feat(devtools): add an Aggregate message-flow preview tab

The gallery only previewed each API in isolation. Add a View tab (By API /
Aggregate): Aggregate stitches every render-bearing API into one compact
content + tool message flow, so renders can be judged in conversational context
across any lifecycle mode. Inspector-only MCP tools are dropped to keep the
thread about the renders, and the API jump-list column hides in this view.

Extract the Inspector/Body surface rendering out of ToolPreview into shared
ToolInspectorSlot / ToolBodySlot (toolSurfaces.tsx) so both tabs derive props
identically and never drift. View choice persists to localStorage.

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

* 💄 fix(devtools): densify API list + keep mcp prefix visible

The earlier "shorten mcp names" change solved the wrong problem and hid the
`mcp__` prefix, so MCP tools no longer read as MCP. The actual complaint was row
height. Restore the full identifier and instead middle-elide it
(`mcp__claude_ai_Li…get_diff`) so both the muted `mcp` namespace and the
distinguishing trailing action stay visible; full id remains in the title
tooltip. Drop row height to a fixed dense 22px (flex-shrink:0 so it scrolls
instead of squishing) to fit far more APIs per screen.

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

* ♻️ refactor(devtools): render Aggregate tab through the real Conversation renderer

The hand-rolled MessageList only approximated the chat. Replace it with the
actual shipping renderer: seed a `ConversationProvider` (skipFetch) with fixture
`assistantGroup` messages and map each render-bearing API to a real tool
payload, then render the real `MessageItem` for each. Tool state is driven
purely by the message shape — `result` → success, `result.error` → error,
`intervention.pending` → intervention, unterminated `arguments` JSON →
streaming — so the preview is byte-for-byte what users see in chat. Skips the
virtualized `ChatList` (and its data fetches) by mapping `MessageItem` directly.

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

*  feat(device): device detail drawer (channels + recent dirs + config)

Clicking a device row now opens a right-side detail drawer instead of a small
edit modal:
- Connections: render every live connection from the `channels` array, each
  with its channel tag (desktop / cli) + connected-since.
- Name + default working directory (native folder browse on the current
  device); saving a default cwd also seeds the recent list.
- Recent directories: list `recentCwds`, click to reuse, × to remove — this is
  where you can see and manage the recent list (previously not surfaced).

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

*  feat(device): record recentCwds on the local device picker

Local-mode runs execute on this machine, but the local working-directory picker
only persisted to a desktop-local recents store — the dir never reached the
device registry, so the settings detail view (and a future device-mode picker)
couldn't see it.

- WorkingDirectory.selectDir now also records the chosen dir into the current
  device's recentCwds (resolved from the gateway's own deviceId).
- useUpdateDeviceCwd gains a { setDefault } option so local mode records
  recentCwds without repointing the device's defaultCwd.

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

* 🩹 fix(devtools): thread Aggregate preview messages via parentId

Each fixture turn was an orphaned message with no parentId, so the renderer saw
a pile of disconnected messages rather than one conversation. Chain every turn
onto the previous one (`parentId` = prior message id) so they read as a single
linear thread.

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

* ♻️ refactor(devtools): seed flat messages so conversation-flow groups the Aggregate

The previous version hand-built `role: 'assistantGroup'` messages, bypassing the
real grouping. Seed the flat DB-shaped messages instead — an `assistant` message
carrying the tool_use plus a linked `role: 'tool'` result message per API — and
let conversation-flow's `parse()` synthesize the assistantGroup exactly as it
does in chat. The consecutive tool turns now collapse into one real workflow
group (one avatar, N content+tool blocks) instead of N hand-rolled groups.
Lifecycle state rides the tool message the same way production carries it
(content/pluginState = success, pluginError = error, pluginIntervention = pending).

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

* 💄 refactor(device): inline master-detail device settings; drop uppercase labels

Per feedback:
- Replace the floating edit Drawer with an inline right-hand detail panel —
  the devices page is now a master-detail layout (device list on the left,
  selected device's detail on the right), like the rest of settings.
- Drop the ALL-CAPS section labels (no more text-transform: uppercase /
  letter-spacing) — labels use natural case + a muted color.

DeviceItem becomes a selectable list row (no own modal); DeviceDetailPanel
renders the detail inline (connections per channel, name, default cwd + browse,
recent dirs). Keyed on deviceId so the form resets on selection change.

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

* 💄 refactor(device): detail panel opens on click, not by default

Per feedback — mirror the memory-preferences master-detail pattern:
- No device is selected by default; the right detail panel only renders once a
  row is clicked (clicking the selected row again closes it). Panel has its own
  close (×).
- List flexes to fill when nothing is selected; the detail appears as a right
  column on selection.

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

* 🐛 fix(devtools): bind render gallery to viewport height so columns scroll

The page root used height:100%, which only resolves when an ancestor route
provides a bounded height — under mounts that don't, the whole page grew to
content height and the API list never scrolled internally. Bind the root to
100dvh directly and add min-height:0 to the flex chain (main + the API list)
so the scroll container engages regardless of how the route is mounted.

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

*  feat(devtools): add WebFetch / WebSearch fixtures so they render

Both APIs had no fixture, so the gallery fell back to schema-sampled args with no
content and the renders drew empty (just the icon). Add fixtures with realistic
args + content: WebFetch (url + prompt + markdown answer), WebSearch (query +
allowed_domains + results), plus their apiList descriptions.

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

* 💄 fix(device): render connections straight from device.channels[]

Drop the device.online-based synthetic single-channel fallback — the connection
rows now come purely from the device.channels[] array (one row per live
connection), with offline = empty array.

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

* 🐛 fix(hetero): distinguish CC server throttle from user quota limit

A 429 "Server is temporarily limiting requests (not your usage limit)"
was classified as a user rate_limit, rendering the misleading "Claude
Code usage limit reached" reset-time guide. Key the rate_limit vs
overloaded decision on the structured rate_limit_event reset window
(resetsAt / rateLimitType) instead of the HTTP status, so 429/529 with
no quota signal fall through to the overloaded (retry) UX.

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

* 💄 fix(devtools): loosen the API list density

22px rows at 12px overcorrected into a cramped sidebar. Relax to 30px rows,
13px label, a small inter-row gap, and a touch more vertical padding so the
jump-list reads comfortably.

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

* 💄 fix(device): align connection rows in the list item (drop 30px indent)

The connection rows had a 30px inline-start padding that pushed them right of
the cwd line; align them with the rest of the device info.

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

* 💄 fix(device): move connection status dot to the first line

The online/offline status now sits as a dot next to the device name + badges
(with the connected / last-active time as a tooltip), instead of a separate
third line. Per-channel connection detail still lives in the detail panel.

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

* 💄 feat(devtools): show the Aggregate preview as "Lobe AI"

The seeded preview conversation resolved its avatar/name through an agentId that
wasn't in the agent store, so every turn fell back to the unresolved-agent
"Unnamed Assistant" / UN avatar. Seed agentMap with a Lobe AI meta
(DEFAULT_INBOX_AVATAR + title) for the devtools agentId, shared via
DEVTOOLS_AGENT_ID / DEVTOOLS_AGENT_META so MessageList's context and the store
seed stay in sync. Restored on unmount.

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

* 🐛 fix(devtools): carry tool result state in BuiltinInspectorProps

The Aggregate preview passes `result.state` to inspectors, matching the
real runtime, but the canonical `result` type omitted `state` — failing
type-check. Add `state?: any` so devtools and runtime agree.

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

* 🐛 fix(device): pin topic cwd and add hetero-tracing toggle

- Prefer the topic's own `metadata.workingDirectory` over the device
  default when dispatching, so an existing topic keeps its pinned cwd
- Add `heteroTracingEnabled` store flag to trace CLI raw streams in
  packaged builds (Help menu checkbox)
- Reorder the connection status dot ahead of badges in DeviceItem

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

*  feat(device): add Help-menu toggle to record hetero-agent CLI traces in production

Packaged builds previously never wrote hetero-agent (CC / Codex) CLI traces,
so production issues couldn't be captured. Add a persisted `heteroTracingEnabled`
toggle in the Help menu (all 3 platforms) plus an "Open HeteroAgent Directory"
entry. Dev still always traces to `cwd/.heerogeneous-tracing`; packaged builds,
when enabled, centralize traces under `<appStoragePath>/heteroAgent/tracing`
(sibling to the existing files cache) via shared dir constants.

Closes LOBE-9828

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

* 📝 docs(skills): fold stacked-prs guidance into the pr skill

Merge the standalone `stacked-prs` skill into `pr` as a supplementary section
(ordering rule, file placement, git split recipe, dependency verification,
Linear bookkeeping, gotchas) and absorb its triggers into the pr description,
rather than keeping a separate skill.

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

* 🐛 fix(devtools): chain RenderGallery previews into one assistantGroup

Unfinished tool states (streaming / loading) now emit a paired tool result
message with `LOADING_FLAT` content instead of none, and every assistant turn
chains onto the previous message's id. The tool_use → tool_result link is what
lets conversation-flow merge the turns into one assistantGroup; without it the
unfinished modes rendered as one orphaned group per tool.

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

* ♻️ refactor(device): key hetero trace location off the toggle, not isPackaged

`resolveTraceRootDir` now centralizes traces under
`<appStoragePath>/heteroAgent/tracing` whenever `heteroTracingEnabled` is on,
instead of gating on `isPackaged`. Packaged behavior is unchanged (it only
traces when the toggle is on), and a dev who opts in now also gets the
centralized dir reachable from the Help-menu entry. Plain dev runs keep
writing to `cwd/.heerogeneous-tracing`.

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

* 🐛 fix(device): move hetero dir consts to a side-effect-free module

Importing the new `HETERO_AGENT_*` constants from `@/const/dir` dragged that
module's load-time `app.getPath()` / `app.getAppPath()` calls into the menu and
controller import graphs, breaking menu/controller suites whose electron mocks
or partial `@/const/dir` mocks didn't anticipate it. Relocate the pure path
segments to `@/const/heteroAgent` (no electron import) and point the controller
+ all three menu impls there. Also add the now-required `storeManager.get/set`
to the menu test app mocks (the Help-menu tracing checkbox reads it at build).

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

* 💄 style(devtools): refine RenderGallery surfaces and fix local-system fixtures

- flatten the active ApiList item (drop accent bar) and the ToolPreview card shadow
- give the Aggregate thread a white container surface
- hide deprecated lobe-notebook toolset and legacy *Local* aliases from the gallery
- re-key local-system fixtures to current API names + add missing call args
- backfill agent-management call args so inspectors render their argument rows

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

*  test(desktop): default global electron mock so import-time app access is safe

`@/const/dir` reads `app.getAppPath()` / `app.getPath()` at module load — fine
in production (app is ready), but it forced every test that transitively imports
it to stub those basics, which is the real root of the recent breakages.

Register a default `electron` mock in the global vitest setup, giving every
suite a ready `app` (paths + readiness) plus light stubs for the common
namespaces. Suites that need specific behavior still declare their own
`vi.mock('electron', …)`, which overrides this per-file. This keeps production
free to use plain value-style path constants instead of lazy getter functions.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:11:55 +08:00
Arvin Xu 3caa3efb18 feat(device): auto-register desktop & CLI devices with stable machine ID (#15300)
 feat(device): auto-register desktop & CLI devices; send connectionId + channel

App layer — wires desktop and `lh connect` to the device registry and the
connection-routing scheme. Depends on @lobechat/device-identity and the
gateway-client connectionId/channel options (earlier PRs in this stack), plus
the device.register / listDevices endpoints (already on canary).

- desktop derives the stable deviceId on gateway connect (old per-install random
  UUID demoted to the routing `connectionId`), registers via device.register,
  and tags channel `desktop` / `desktop-dev`
- `lh connect` derives + registers before opening the WS (explicit --device-id
  still pins a VM); channel `cli` (env-overridable); connectionId persisted in
  `~/.lobehub/connection-id`
- CLI api client preserves explicit --token connects during registration

Part of LOBE-9572. Closes LOBE-9576 / LOBE-9577.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:35:09 +08:00
Arvin Xu c27b62e10c 💄 style(imessage): wrap BlueBubbles bridge config into a connection card (#15342)
* 💄 style(imessage): wrap BlueBubbles bridge config into a connection card

Regroup the iMessage BlueBubbles bridge settings into a single bordered
card with a clearer top status / middle form / bottom action layout:

- Header shows the connection title + overall test status badge
  (Pending Test / Connected / Failed), with breathing room before the
  form fields.
- Server URL field gains an inline hint box (127.0.0.1 vs LAN IP).
- A full-width bridge service bar at the bottom: running/stopped status
  with the listening address on the left, the primary Enable Bridge
  toggle on the right, and the less-frequent Refresh / Test actions on a
  second row.

Test status is tracked locally and reset on any field edit so the badge
never shows a stale pass.

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

* 💄 style(claude-code): fix WebFetch inspector URL truncation and align chip with Bash

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

* 💄 style(imessage): use BlueBubbles logo for the bridge status icon

Swap the generic plug glyph for the BlueBubbles app logo so the bridge
service card reads more recognizably. The icon sits in a white rounded
tile; the running state is already conveyed by the Running tag.

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

* 💄 style(imessage): move BlueBubbles logo to the connection header

Promote the BlueBubbles logo next to the section title so it identifies
the integration up front, and drop the icon tile from the bridge service
row — the running/stopped state reads fine as text + status tag there.

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

* 💄 style(imessage): enlarge bridge logo, fix disabled status, clarify relay copy

- Logo now spans both header lines (44px) for a stronger section anchor.
- Bridge status reflects this config's Enable toggle (running && enabled),
  so flipping it off no longer keeps showing "Running" until the next save.
- Service descriptions now explain the bridge relays iMessage messages to
  LobeHub, so the local server's purpose is clear.

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

* ♻️ refactor(imessage): make Electron main the SoT for the bridge status

Read the bridge status via SWR (revalidates on focus + after each mutation)
instead of caching a divergent copy, and drop the manual Refresh button.

- `enabled` / `running` / `serverUrl` / `passwordSet` now derive from the
  main-process status, not local form state.
- Enable is a write-through toggle: it auto-persists the current Server URL +
  password and starts/stops the bridge immediately (option B), surfacing real
  connection errors on enable.
- Test is ungated from enable — it pings BlueBubbles directly and only needs a
  Server URL + password.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:31:40 +08:00
Arvin Xu a9d74bb143 chore: remove LOBE-XXX auto-link pattern from WelcomeText (#15336)
Remove the LOBE-\d+ regex from AUTO_LINK_PATTERNS since LOBE issue references
should not appear in an open-source codebase. Only GitHub issue references (#\d+)
remain auto-linked.

Co-authored-by: arvinxx <arvinxx@lobehub.com>
2026-05-30 19:26:33 +08:00
Arvin Xu e1fe37933d feat(cli): add lh topic view command (#15340)
* feat(cli): add `lh topic view` command to display topic details and messages

* test(cli): add unit tests for `lh topic view` command

* fix(cli): improve topic view - fix --no-messages bug, add tool calls, threads, pagination

* test(cli): update view tests - fix mock, add tool/thread/pagination cases

* feat(topic): add getTopicDetail trpc procedure for structured topic metadata

* refactor(cli): use getTopicDetail for view command metadata, show full fields

* test(cli): update view tests to use getTopicDetail mock
2026-05-30 19:13:34 +08:00
Innei 1c3e973bab 🐛 fix(library): refresh folder data on slug switch and dedupe breadcrumb fetch (#15335)
Resource Explorer kept showing the previous folder's items when sidebar
hierarchy clicks switched the URL slug. SWR `onSuccess` only fires after
revalidate completes, so cache-hit navigations could not update the
zustand mirror that the Explorer reads from.

- Move SWR data → store sync into a `useEffect` so cache hits also push
  fresh items into `useFileStore` immediately, while keeping the 30s
  deduping window to avoid wasted background revalidations.
- Reuse the Breadcrumb SWR cache in `LibraryHierarchy`: replace
  `tree.navigateTo(slug)` (which fetched the breadcrumb directly) with
  `tree.expandAncestors(ids)`, and let `useFetchFolderBreadcrumb` feed
  the ids so a folder switch no longer issues two parallel
  `document.getFolderBreadcrumb` requests.

Fixes LOBE-4293
2026-05-30 17:27:07 +08:00
Innei 22c264bb77 feat(page-share): add document share flow with business slot stubs (#15309)
*  feat(page-share): add document share flow

*  improve page share probe fallback

* ♻️ refactor(page-share): extract to business slot stubs

* ♻️ refactor(page-share): move shared-page viewer to /share/page/:id

- Drop anonymous handling on /page/🆔 revert middleware allowlist, main
  layout PageShareLayout wrap, and outlet-context probe branch
- Add /share/page/:id route under share tree (parallel to /share/t/:id),
  registered in desktop/desktop-vite/mobile router configs
- New PublishedShell business slot stub (pass-through); cloud provides the
  marketing banner + chrome
- Align SharePopover i18n schema with the topic-share pattern

* 🐛 fix(page-share): provide pageShare router stub procedures for OSS type-check

The /share/page/:id route calls lambdaClient.pageShare.getSharedDocument;
the empty router({}) stub left the OSS standalone type-check unable to
resolve it. Stub now declares all three procedures (getShareSettings,
updateShareSettings, getSharedDocument) with cloud-matching inputs and
throws NOT_FOUND when invoked without the cloud override.
2026-05-29 22:36:51 +08:00
Innei 1736faf3af 📝 docs(spa-routes): document .desktop.{ts,tsx} variant pattern (#15327)
Extend the spa-routes skill so agents catch all `.desktop` colocated
variants under `src/routes/`, not just the desktopRouter pair. Adds a
new "3b. Other .desktop variants" section listing the current known
cases (settings componentMap, agent index, group index), spells out
the drift risk for each, and lists the rules for editing/adding/
removing variant pairs. Also updates the skill description so the
trigger glob covers `componentMap.desktop`, `index.desktop.tsx`, and
`.desktop.tsx variant`.
2026-05-29 17:50:41 +08:00
Innei 6c58af9c84 🐛 fix(desktop): upload .blockmap files to S3 for differential updates (#15326)
The S3 publish action was missing *.blockmap from its upload glob,
causing electron-updater to always fall back to full downloads.
2026-05-29 17:23:19 +08:00
Innei 0139c054a2 ⬆️ chore: update @lobehub/ui to v5.15.5 (#15325)
Bump @lobehub/ui from the pkg.pr.new preview to the released v5.15.5,
and switch the community user list search inputs from antd Input.Search
to @lobehub/ui SearchBar to align height with the status Select.
2026-05-29 17:10:54 +08:00
Arvin Xu 063fa61c49 feat(device): connectionId + channel routing in gateway client & device list (#15322)
*  feat(device): connectionId + channel routing in gateway client & device list

Shared client + server + settings-UI half of decoupling the gateway connection
routing key from the stable deviceId (the gateway DO change lives in the
device-gateway repo).

- GatewayClient gains `connectionId` (per-install routing UUID) + `channel`
  (freeform label) options, both sent on the WS URL; `currentConnectionId` getter
- consume the gateway's device-centric `/api/device/devices` shape: deviceProxy
  maps it to runtime devices + nested channels (tolerant of a legacy flat shape
  via `?? []`); device.listDevices flattens channels; DeviceItem shows the label

Part of LOBE-9572. Closes LOBE-9781.

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

* 🔥 chore(device): remove unused in-repo apps/device-gateway

`apps/device-gateway` was a stale, non-deployed mirror of the device-gateway
Cloudflare worker (the real one lives in its own repo and already diverged —
it has AdminDO / geo / message-api / the tool-call-timeout refactor this copy
never got, and no CI here deploys this directory). Keeping it around just makes
the in-repo gateway look like it ignores the connectionId/channel this client
now sends. Drop it; the gateway contract is owned by the service repo.

- delete apps/device-gateway/**
- drop its tsconfig `exclude` entry
- retarget the protocol-mirror comment in device-gateway-client to the service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 16:03:40 +08:00
YuTengjing dc3186a990 🐛 fix: preserve empty agent prompt export (#15316) 2026-05-29 14:06:47 +08:00
YuTengjing 50d7b126c8 🐛 fix: stop transliterating model names in home starter (#15324) 2026-05-29 14:00:01 +08:00
Innei 42487663b9 🐛 fix(scripts): kill dev child processes on parent shutdown (#15246)
Detach next/vite children into their own process group so process.kill(-pid)
reaps the whole tree (npm -> vite, etc.). Forward SIGHUP, escalate to SIGKILL
after a timeout, and add uncaughtException / 'exit' fallbacks to avoid
leaving orphan processes when the dev startup script is killed.
2026-05-29 13:55:14 +08:00
Arvin Xu 94c7fa4d76 chore(device): add @lobechat/device-identity (#15321)
 feat(device): add @lobechat/device-identity (stable machine-derived deviceId)

New shared package: `deriveDeviceId` hashes the OS machine id with the userId
(+ salt) so one machine + one user → one stable, user-scoped deviceId that
survives LobeHub reinstalls. Falls back to a caller-supplied random UUID (flagged
via `identitySource: 'fallback'`) when the machine id is unavailable.

Foundational layer — no consumers yet; desktop/CLI wire it up in a later PR.

Part of LOBE-9572. Closes LOBE-9574.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 13:28:10 +08:00
LiJian 2461709de4 🐛 fix(desktop): market OAuth expiry triggers wrong re-login modal (#15290)
🐛 fix(desktop): market OAuth expiry no longer triggers LobeHub re-login modal

When sandbox tools (Document Writing, Agent Browser) encountered a
Market OAuth token expiry on desktop, the server threw UNAUTHORIZED
which caused responseMeta to set X-Auth-Required: true, triggering the
LobeHub cloud re-login modal instead of the Market OAuth dialog.

- Add MARKET_AUTH_REQUIRED_MESSAGE sentinel to desktop-bridge
- market.ts uses this message for Market auth TRPCErrors
- responseMeta skips X-Auth-Required for Market auth errors
- MarketAuthProvider on desktop now calls handleUnauthorized() when
  silent token refresh fails, correctly opening the Market OAuth flow

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:54:37 +08:00
lobehubbot 5609b6313b Merge remote-tracking branch 'origin/main' into canary 2026-05-29 01:52:45 +00:00
Arvin Xu 21aceb6fee feat(settings): add Devices settings page behind Execution Device Switcher lab (#15315)
Add a "Devices" tab under the General settings group (above Hotkeys) that
lists the user's registered devices. Each device is keyed by deviceId; the
gateway's live WS connections are nested as channel rows under their device
rather than shown as separate devices. The tab is gated behind the
`enableExecutionDeviceSwitcher` lab flag.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 03:02:48 +08:00
YuTengjing 2657b667be feat: export agent profiles as Markdown (#15312) 2026-05-29 02:45:25 +08:00
YuTengjing f042dd352e feat: support Claude Opus 4.8 (#15314) 2026-05-29 02:19:05 +08:00
Innei 15cb3be9cc 🐛 fix(conversation): keep open ActionBar popup when hovering another message (#15303)
* 🐛 fix(conversation): keep open ActionBar popup when hovering another message

When a dropdown inside the singleton message ActionBar is open, hovering
another message used to move the singleton host's DOM and swap the rendered
actionType, which unanchored or unmounted the open popup. Freeze both the
host placement target and the rendered actionType while any descendant has
`data-popup-open`, and re-commit the latest live values once the popup
closes (observed via MutationObserver).

* ♻️ refactor(conversation): freeze message ActionBar subtree while popup is open

Replace the manual committed-state freeze with `@lobehub/ui` `Freeze`:
split the host migration effect + portal render into `ActionBarBody`, and
wrap it with `<Freeze frozen={isPopupOpen}>` in `SingletonMessageActionsBar`.

While any descendant of the host has `data-popup-open`, the inner body is
suspended — its migration effect doesn't run and its render is paused, so
hovering another message no longer DOM-moves the trigger or unmounts the
dropdown's React subtree. Once the popup closes, the body resumes with the
latest live `actionType` / `portalElement` and migrates the host normally.

* Revert "♻️ refactor(conversation): freeze message ActionBar subtree while popup is open"

This reverts commit a8d47bedbb.
2026-05-29 01:31:41 +08:00
Innei 65113ca2a7 feat(kb): extend BM25 search to file-backed documents (#15247)
`searchKnowledgeBaseDocuments` only matched inline `custom/document`
pages, so parsed PDFs and other file-backed documents never surfaced
via the BM25 path — vector search was the sole way to retrieve them.

Run two scoped ParadeDB queries in parallel (inline via
`documents.knowledge_base_id`, file-backed via a `knowledge_base_files`
join) and merge by score in JS. A single OR-ed predicate trips
ParadeDB's `Unsupported query shape` because `paradedb.score()`
requires a conjunctive tantivy scan.

Folder rows are excluded; hits now carry an optional `fileId` so the
agent can read with either `docs_*` or `file_*` ids. The XML formatter
exposes the new attribute downstream.
2026-05-29 01:01:47 +08:00
YuTengjing 2194b23390 🐛 fix: show artifact source while streaming (#15310) 2026-05-29 00:52:13 +08:00
YuTengjing 234c87dd9d 🐛 fix: restore file access URL policy (#15305) 2026-05-28 22:51:32 +08:00
Innei 9945cecf87 feat(portal): editable CodeMirror viewer for LocalFile + Document highlight (#15298)
*  feat(portal): editable CodeMirror viewer for LocalFile + Document highlight

Replace the read-only Highlighter in the LocalFile portal preview and the
Document portal highlight mode with a shared `CodeEditorPane` powered by
`@lobehub/editor/codemirror`. Pane supports inline editing, Cmd/Ctrl+S to
save, lobeTheme tokens, and language-aware syntax highlighting.

LocalFile flow
- Track per-path edit buffers + save action in the chat portal store
  (`dirtyLocalFileContents`, `setLocalFileBuffer`, `saveLocalFile`).
- Show a filled dot on the tab close button when the file is dirty;
  hovering still reveals the X. Closing a dirty tab (via X or the context
  menu's "Close") prompts a confirmation modal via `confirmModal` from
  `@lobehub/ui/base-ui`.
- After save, mutate the SWR cache to the just-saved content before
  clearing the buffer so CodeMirror does not see a stale `value` prop and
  reset the cursor.

Document flow
- For non-markdown documents (`getDocumentRenderMode` → `highlight`),
  render `CodeEditorPane` with a local edit buffer keyed by `documentId`.
- Save calls `documentService.updateDocument({ saveSource: 'manual' })`,
  mutates the document-meta SWR cache, then clears the buffer.

Bump `@lobehub/editor` to ^4.15.0 to pick up the new
`@lobehub/editor/codemirror` subpath export.

* 🐛 fix(portal): force read-only on truncated local file previews

When a file exceeds MAX_PREVIEW_CHARS the preview only holds the first
500k character prefix. Editing and saving against that prefix would
silently overwrite the rest of the file with the truncated content.

Pass `readOnly={truncated}` to the editor, ignore any stale buffer when
truncated, and short-circuit handleSave so Cmd/Ctrl+S is a no-op in this
mode.

* ♻️ refactor(portal): drop MAX_PREVIEW_CHARS truncation for local files

Always pass the full file content to the editor instead of slicing at
500k characters. The truncation existed only to avoid losing data when
saving the previously-Highlighter-rendered prefix, but with full content
available the editor can both display and persist the file safely.

Removes the `truncated` / `truncatedLabel` plumbing, the truncated
banner, and the associated read-only short-circuit in handleSave.

*  test(portal): update document body highlight editor test
2026-05-28 22:42:25 +08:00
4348 changed files with 304469 additions and 52688 deletions
+2 -2
View File
@@ -51,7 +51,7 @@ export interface GlobalServerConfig {
### 3. Assemble Server Config (if new domain)
In `src/server/globalConfig/index.ts`:
In `apps/server/src/globalConfig/index.ts`:
```typescript
import { <domain>Env } from '@/envs/<domain>';
@@ -97,7 +97,7 @@ AI_IMAGE_DEFAULT_IMAGE_NUM: z.coerce.number().min(1).max(20).optional(),
// packages/types/src/serverConfig.ts
image?: PartialDeep<UserImageConfig>;
// src/server/globalConfig/index.ts
// apps/server/src/globalConfig/index.ts
image: cleanObject({ defaultImageNum: imageEnv.AI_IMAGE_DEFAULT_IMAGE_NUM }),
// src/store/user/slices/common/action.ts
+9 -9
View File
@@ -1,6 +1,6 @@
---
name: agent-runtime-hooks
description: "Agent runtime lifecycle hooks for observing and intercepting agent execution. Use when adding hooks to agent operations, mocking tool calls, logging step events, handling human intervention, sub-agent calls, context compression, or building eval/tracing integrations. Triggers on 'hooks', 'beforeToolCall', 'afterToolCall', 'beforeStep', 'afterStep', 'onComplete', 'onError', 'tool mock', 'agent lifecycle', 'human intervention', 'callAgent', 'compact'."
description: 'Agent runtime lifecycle hooks. Use for before/after tool or step hooks, tool mocks, human intervention, sub-agent calls, context compression, evals, tracing, callAgent, or lifecycle events.'
user-invocable: false
---
@@ -50,14 +50,14 @@ execAgent({ hooks })
## Key Files
| File | Role |
| ---------------------------------------------------------- | ------------------------------------------------------ |
| `packages/agent-runtime/src/types/hooks.ts` | Type definitions (AgentHookType, all event interfaces) |
| `src/server/services/agentRuntime/hooks/types.ts` | Server-side types (AgentHook, re-exports) |
| `src/server/services/agentRuntime/hooks/HookDispatcher.ts` | Registration, dispatch, dispatchBeforeToolCall |
| `src/server/modules/AgentRuntime/RuntimeExecutors.ts` | Tool/Compact/HumanIntervention hook dispatch |
| `src/server/services/agentRuntime/AgentRuntimeService.ts` | Step hooks + HumanIntervention resume/reject |
| `src/server/services/aiAgent/index.ts` | CallAgent hook dispatch |
| File | Role |
| --------------------------------------------------------------- | ------------------------------------------------------ |
| `packages/agent-runtime/src/types/hooks.ts` | Type definitions (AgentHookType, all event interfaces) |
| `apps/server/src/services/agentRuntime/hooks/types.ts` | Server-side types (AgentHook, re-exports) |
| `apps/server/src/services/agentRuntime/hooks/HookDispatcher.ts` | Registration, dispatch, dispatchBeforeToolCall |
| `apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts` | Tool/Compact/HumanIntervention hook dispatch |
| `apps/server/src/services/agentRuntime/AgentRuntimeService.ts` | Step hooks + HumanIntervention resume/reject |
| `apps/server/src/services/aiAgent/index.ts` | CallAgent hook dispatch |
## Registration Flow
+19 -19
View File
@@ -1,6 +1,6 @@
---
name: agent-signal
description: Build or extend LobeHub Agent Signal pipelines for background or quiet agent work driven by event sources, semantic signals, and action handlers. Use when adding a new Agent Signal source, signal or action type, policy, middleware handler, workflow handoff, dedupe or scope behavior, or observability around `src/server/services/agentSignal/**`, `packages/agent-signal`, or `packages/observability-otel/src/modules/agent-signal`.
description: 'Build or extend LobeHub Agent Signal pipelines. Use for signal sources, signal/action types, policies, middleware, workflow handoff, dedupe, scope behavior, or observability.'
---
# Agent Signal
@@ -26,9 +26,9 @@ Agent Signal has one consistent shape:
Read:
- `src/server/services/agentSignal/index.ts`
- `src/server/workflows/agentSignal/index.ts`
- `src/server/workflows/agentSignal/run.ts`
- `apps/server/src/services/agentSignal/index.ts`
- `apps/server/src/workflows/agentSignal/index.ts`
- `apps/server/src/workflows/agentSignal/run.ts`
## Core Model
@@ -48,11 +48,11 @@ Keep the boundaries strict:
## Implementation Workflow
1. Decide whether the use case is synchronous or quiet background work.
2. Define or reuse a source type in `src/server/services/agentSignal/sourceTypes.ts`.
3. Define or reuse signal and action types in `src/server/services/agentSignal/policies/types.ts`.
2. Define or reuse a source type in `apps/server/src/services/agentSignal/sourceTypes.ts`.
3. Define or reuse signal and action types in `apps/server/src/services/agentSignal/policies/types.ts`.
4. Implement handlers with `defineSourceHandler`, `defineSignalHandler`, or `defineActionHandler`.
5. Bundle handlers with `defineAgentSignalHandlers(...)`.
6. Register the policy in `src/server/services/agentSignal/policies/index.ts` and pass it into the runtime factory if needed.
6. Register the policy in `apps/server/src/services/agentSignal/policies/index.ts` and pass it into the runtime factory if needed.
7. Add or update ingress code that emits or enqueues the source event.
8. Add observability and tests before considering the flow complete.
@@ -63,19 +63,19 @@ Keep the boundaries strict:
`packages/agent-signal/src/base/builders.ts`
`packages/agent-signal/src/base/types.ts`
- Server-owned runtime and middleware:
`src/server/services/agentSignal/runtime/AgentSignalRuntime.ts`
`src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
`src/server/services/agentSignal/runtime/middleware.ts`
`src/server/services/agentSignal/runtime/context.ts`
`apps/server/src/services/agentSignal/runtime/AgentSignalRuntime.ts`
`apps/server/src/services/agentSignal/runtime/AgentSignalScheduler.ts`
`apps/server/src/services/agentSignal/runtime/middleware.ts`
`apps/server/src/services/agentSignal/runtime/context.ts`
- Existing policy example:
`src/server/services/agentSignal/policies/analyzeIntent/index.ts`
`src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
`src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
`src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
`src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
`apps/server/src/services/agentSignal/policies/analyzeIntent/index.ts`
`apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
`apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
`apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
`apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
- Observability:
`src/server/services/agentSignal/observability/projector.ts`
`src/server/services/agentSignal/observability/traceEvents.ts`
`apps/server/src/services/agentSignal/observability/projector.ts`
`apps/server/src/services/agentSignal/observability/traceEvents.ts`
`packages/observability-otel/src/modules/agent-signal/index.ts`
## Implementation Rules
@@ -86,7 +86,7 @@ Keep the boundaries strict:
- Use stable ids and idempotency keys when the same source can arrive more than once.
- Preserve scope discipline. The runtime uses `scopeKey` to serialize related background work.
- Prefer the dedicated shared package types and builders from `@lobechat/agent-signal` for normalized nodes and result contracts.
- Add focused tests near the touched runtime, policy, or store module. Existing tests under `src/server/services/agentSignal/**/__tests__` are the reference pattern.
- Add focused tests near the touched runtime, policy, or store module. Existing tests under `apps/server/src/services/agentSignal/**/__tests__` are the reference pattern.
## References
@@ -32,9 +32,9 @@ source node
Read:
- `src/server/services/agentSignal/index.ts`
- `src/server/services/agentSignal/sources/index.ts`
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
- `apps/server/src/services/agentSignal/index.ts`
- `apps/server/src/services/agentSignal/sources/index.ts`
- `apps/server/src/services/agentSignal/runtime/AgentSignalScheduler.ts`
## Package Boundaries
@@ -56,7 +56,7 @@ Read:
- `packages/agent-signal/src/types/events.ts`
- `packages/agent-signal/src/types/builtin.ts`
### `src/server/services/agentSignal`
### `apps/server/src/services/agentSignal`
Treat this as the server-owned implementation layer.
@@ -89,11 +89,11 @@ Examples:
Define source payloads in:
- `src/server/services/agentSignal/sourceTypes.ts`
- `apps/server/src/services/agentSignal/sourceTypes.ts`
Build normalized sources in:
- `src/server/services/agentSignal/sources/buildSource.ts`
- `apps/server/src/services/agentSignal/sources/buildSource.ts`
- `packages/agent-signal/src/base/builders.ts`
### Signal
@@ -109,7 +109,7 @@ Examples from `analyzeIntent`:
Define server-owned signal types in:
- `src/server/services/agentSignal/policies/types.ts`
- `apps/server/src/services/agentSignal/policies/types.ts`
### Action
@@ -157,9 +157,9 @@ When a user asks for "the procedure", document the flow above and point to the e
Read:
- `src/server/services/agentSignal/sources/index.ts`
- `src/server/services/agentSignal/runtime/context.ts`
- `src/server/services/agentSignal/constants.ts`
- `apps/server/src/services/agentSignal/sources/index.ts`
- `apps/server/src/services/agentSignal/runtime/context.ts`
- `apps/server/src/services/agentSignal/constants.ts`
Use `enqueueAgentSignalSourceEvent(...)` when the work should stay quiet and out-of-band. That path:
@@ -172,8 +172,8 @@ This is the preferred path when the UI request should finish immediately and the
Read:
- `src/server/workflows/agentSignal/index.ts`
- `src/server/workflows/agentSignal/run.ts`
- `apps/server/src/workflows/agentSignal/index.ts`
- `apps/server/src/workflows/agentSignal/run.ts`
## Existing Example: `analyzeIntent`
@@ -192,8 +192,8 @@ agent.user.message
Read:
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/index.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
@@ -2,7 +2,7 @@
## Fluent Registration API
Use the middleware helpers in `src/server/services/agentSignal/runtime/middleware.ts`.
Use the middleware helpers in `apps/server/src/services/agentSignal/runtime/middleware.ts`.
They provide:
@@ -32,7 +32,7 @@ The context gives you:
Read:
- `src/server/services/agentSignal/runtime/context.ts`
- `apps/server/src/services/agentSignal/runtime/context.ts`
## Return Contracts
@@ -48,7 +48,7 @@ Return one of these shapes:
Read:
- `packages/agent-signal/src/base/types.ts`
- `src/server/services/agentSignal/runtime/AgentSignalScheduler.ts`
- `apps/server/src/services/agentSignal/runtime/AgentSignalScheduler.ts`
## Policy Composition Pattern
@@ -72,8 +72,8 @@ That bundle is later passed into the runtime via:
Read:
- `src/server/services/agentSignal/policies/index.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/index.ts`
- `apps/server/src/services/agentSignal/policies/index.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/index.ts`
## Source Handler Pattern
@@ -81,7 +81,7 @@ Use a source handler when you are interpreting a producer event into semantic si
Reference:
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackSatisfaction.ts`
Pattern:
@@ -114,8 +114,8 @@ Use a signal handler when one semantic state should branch into more semantic st
References:
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackDomain.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/feedbackAction.ts`
Pattern:
@@ -148,7 +148,7 @@ Use an action handler when the runtime should do actual work.
Reference:
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
Pattern:
@@ -186,9 +186,9 @@ Keep these rules:
Use this split:
- external event payloads:
`src/server/services/agentSignal/sourceTypes.ts`
`apps/server/src/services/agentSignal/sourceTypes.ts`
- policy-owned signal and action payloads:
`src/server/services/agentSignal/policies/types.ts`
`apps/server/src/services/agentSignal/policies/types.ts`
- normalized shared node contracts:
`packages/agent-signal/src/base/types.ts`
@@ -216,10 +216,10 @@ Prefer focused tests near the touched code.
Useful references:
- `src/server/services/agentSignal/runtime/__tests__/AgentSignalRuntime.test.ts`
- `src/server/services/agentSignal/__tests__/index.integration.test.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/__tests__/*`
- `src/server/services/agentSignal/policies/analyzeIntent/actions/__tests__/*`
- `apps/server/src/services/agentSignal/runtime/__tests__/AgentSignalRuntime.test.ts`
- `apps/server/src/services/agentSignal/__tests__/index.integration.test.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/__tests__/*`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/__tests__/*`
Test at the smallest level that proves the behavior:
@@ -24,9 +24,9 @@ After runtime execution, the service projects one compact observability model fr
Read:
- `src/server/services/agentSignal/observability/projector.ts`
- `src/server/services/agentSignal/observability/traceEvents.ts`
- `src/server/services/agentSignal/observability/store.ts`
- `apps/server/src/services/agentSignal/observability/projector.ts`
- `apps/server/src/services/agentSignal/observability/traceEvents.ts`
- `apps/server/src/services/agentSignal/observability/store.ts`
Projection outputs:
@@ -58,7 +58,7 @@ Workflow-triggered runs do not naturally pass through the normal foreground runt
Read:
- `src/server/workflows/agentSignal/run.ts`
- `apps/server/src/workflows/agentSignal/run.ts`
Use that path when:
@@ -77,8 +77,8 @@ Check:
Read:
- `src/server/services/agentSignal/index.ts`
- `src/server/services/agentSignal/sources/index.ts`
- `apps/server/src/services/agentSignal/index.ts`
- `apps/server/src/services/agentSignal/sources/index.ts`
### The signal exists but no action runs
@@ -98,8 +98,8 @@ Check:
Reference:
- `src/server/services/agentSignal/policies/actionIdempotency.ts`
- `src/server/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
- `apps/server/src/services/agentSignal/policies/actionIdempotency.ts`
- `apps/server/src/services/agentSignal/policies/analyzeIntent/actions/userMemory.ts`
### Background runs are hard to discover
+376
View File
@@ -0,0 +1,376 @@
---
name: agent-testing
description: >
Agentic end-to-end testing for LobeHub: backend verification via the CLI,
frontend verification via agent-browser (Electron), full-stack verification in
the browser, and bot-channel verification via osascript. Local-first today,
designed to extend to cloud automation. Triggers on 'cli test', 'test with cli',
'verify with cli', 'backend test with cli', 'local test', 'test in electron',
'test desktop', 'test bot', 'bot test', 'test in discord', 'test in telegram',
'test in slack', 'test in wechat', 'test in weixin', 'test in lark', 'test in feishu',
'test in qq', 'manual test', 'osascript', 'test report', or any local
end-to-end verification task.
---
# Agent Testing (Agentic End-to-End Verification)
One skill for all agentic end-to-end testing — local-first today, designed to
also run as full cloud automation. Every test session follows the same
four-step contract:
```
Step -1: Plan approval → Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
```
## Step -1 — Plan approval for non-trivial tests
Skip directly to Step 0 if: the test is a single re-run after a fix, the plan
was already agreed on, or the user gave exact commands.
Otherwise, propose a test plan (surface, cases, expected evidence, assumptions)
and use the runtime structured question tool (`request_user_input` /
ask-user-question equivalent) with two fixed choices:
1. `开始执行 (Recommended)` — 测试方案没问题,开始执行
2. `先讨论下` — 方案有问题,先讨论下
Wait for the user's choice before proceeding.
## Step 0 — Environment setup + auth check (mandatory)
Step 0 is about getting the environment ready: **dependencies are healthy**
and **auth is green**. A test run that dies halfway on a missing dependency or
a login wall wastes the whole session — clear both gates BEFORE writing a
single test step.
### 0.0 Resolve the current test environment
Before starting a dev server, checking auth, opening agent-browser, or writing
test steps, print and confirm the current local test environment:
```bash
./.agents/skills/agent-testing/scripts/test-env.sh
```
This command is the source of truth for local test ports. It reads the current
shell plus `.env` files using the same precedence as `scripts/runWithEnv.mts`,
then prints:
- `APP_URL`
- `PORT`
- `SERVER_URL`
- `AUTH_TRUSTED_ORIGINS`
- `SPA_PORT`
- `MOBILE_SPA_PORT`
- `DESKTOP_PORT`
For commands that need these values, export them from the same resolver:
```bash
eval "$(./.agents/skills/agent-testing/scripts/test-env.sh --exports)"
```
Do not rely on hard-coded port tables. If the printed values do not match the
running dev server, fix/export the env first, then continue.
### 0.1 Dependencies are installed — root AND standalone apps
The root pnpm workspace does **NOT** cover every app: `pnpm-workspace.yaml`
lists `packages/**`, `e2e`, `apps/server`, and only `apps/desktop/src/main`
**`apps/desktop` and `apps/cli` are standalone**, each keeping its own
`node_modules` with its own links into `packages/`. A root install does not
refresh them, so install in every app the test will touch:
```bash
pnpm install # root workspace
cd apps/desktop && pnpm install # Electron surface
cd apps/cli && pnpm install # CLI surface
```
Symptom of a stale standalone install: the build/launch fails to resolve a
recently added workspace package — `Rolldown failed to resolve import
"@lobechat/<pkg>"` (Electron) or `Cannot find module '@lobechat/<pkg>'` (CLI).
### 0.2 Run scripts from the repo root
All paths in this skill (`./.agents/skills/agent-testing/...`) are
repo-root-relative, and background commands inherit the current working
directory — a script launched while `cwd` is `apps/desktop` fails with
`No such file or directory`. Verify `pwd` is the repo root before launching
long-running scripts.
### 0.3 Init local dev env without `.env`
For Web smoke against local code, start a **normal local dev environment**.
First check the repo root for `.env`:
- If `.env` exists, use the existing local configuration and start the dev
server normally.
- If `.env` does not exist, use the agent-testing env bootstrap.
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
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`
exists, do not call any `init-dev-env.sh` subcommand.
Decision flow:
```bash
if [[ -f .env ]]; then
bun run dev
else
./.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
fi
```
Bootstrap flow when no `.env` exists:
```bash
# From repo root. Managed DB flow requires Docker Desktop.
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
If using an existing Postgres instead of the managed Docker DB, set
`DATABASE_URL` and 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
```
For backend-only checks, `dev-next` is available, but Web smoke needs the
full-stack `dev` command so Next can proxy the SPA HTML from Vite:
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
```
Useful subcommands:
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh env # print exports
./.agents/skills/agent-testing/scripts/init-dev-env.sh write # write .records/env/agent-testing-dev.env
./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate # migrations only
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user # seed user + CLI API key
./.agents/skills/agent-testing/scripts/init-dev-env.sh qstash # local QStash for workflow paths
./.agents/skills/agent-testing/scripts/init-dev-env.sh clean-db # remove managed DB container
```
Default script env:
- `APP_URL=http://localhost:3010`
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres`
- `DATABASE_DRIVER=node`
- `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
triggers QStash/Workflow.
- `KEY_VAULTS_SECRET`, `AUTH_SECRET`, auth verification off
- S3 mock vars
- Managed DB container: `lobehub-agent-testing-postgres`
`seed-user` creates `agent-testing@lobehub.com` / `TestPassword123!` with
onboarding already completed, plus a local API key in
`.records/env/agent-testing-cli.env` for CLI automation. When running Cucumber
against this dev server, pass the same script env into the test process too;
Cucumber has its own `BeforeAll` seed path and it must see `DATABASE_URL`
instead of silently skipping setup:
```bash
cd e2e
# Only in the no-.env branch.
eval "$(../.agents/skills/agent-testing/scripts/init-dev-env.sh env)"
BASE_URL=http://localhost:3010 HEADLESS=true bun run test:smoke
```
### 0.4 Auth is green for the selected surface
**Auth is the gate for automated testing, but the gate is surface-scoped.**
Pick the intended surface first when it is already clear from the task, then
check only that surface. Do not block a Web test on CLI device-code auth or an
Electron login state unless the test spans those surfaces.
```bash
./.agents/skills/agent-testing/scripts/setup-auth.sh status --surface web
```
Use `status` with no `--surface` only for cross-surface test plans.
| Surface | Mechanism | One-key path | Standard check |
| -------- | --------------------------------------------- | ------------------------ | ----------------------------------------- |
| CLI | Seeded API key, device-code fallback | `setup-auth.sh cli-seed` | `setup-auth.sh status --surface cli` |
| Web | Seeded better-auth login into `agent-browser` | `setup-auth.sh web-seed` | `setup-auth.sh status --surface web` |
| Electron | App's own persistent login state | Log in once in the app | `setup-auth.sh status --surface electron` |
| Bot | Native apps already logged in | — | per-platform screenshot |
Login-state checks are standardized — do NOT hand-roll `window.__LOBE_STORES`
eval snippets; use `scripts/app-probe.sh auth` (returns `{ isSignedIn, userId }`,
works for Electron CDP and web sessions via `AB_TARGET`).
For Web tests, the test surface is always `agent-browser --session lobehub-dev`.
Use `setup-auth.sh web-seed` first in the seeded local env. The user's normal
Chrome is only a source for copying the Cookie header when seed auth is not
available or `status --surface web` still fails. If Chrome is already logged in,
do not open a login page; verify agent-browser first, then request the Network
`Cookie:` header only if that verification fails. Full background and failure modes:
[references/auth.md](./references/auth.md).
## Step 1 — Pick the surface by change scope
| Change scope | Default surface | Why | Guide |
| ------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------- | ---------------------------------- |
| **Backend** (TRPC router / service / model / migration) | **CLI** | Fastest loop, text-assertable output, zero UI flakiness | [cli/index.md](./cli/index.md) |
| **Pure frontend** (components, store, styles, UX) | **Electron** (agent-browser + CDP) | Primary product shape; `__LOBE_STORES` state introspection | [ui/electron.md](./ui/electron.md) |
| **Full-stack** (new API + UI consuming it) | **Web** (browser + local dev server) | One surface where network requests and UI are observable together | [ui/web.md](./ui/web.md) |
| **Bot channels** (Discord / WeChat / Lark / …) | Native app via osascript / bridge | Only way to exercise the real channel end-to-end | `bot/<platform>/index.md` |
Escalate, don't duplicate: verify a backend change with the CLI first; only add
a UI pass when the change actually affects the UI.
### Environment support (local macOS vs cloud Linux)
The decisive constraint per surface is **how evidence (screenshots) is
captured**: CDP-based capture (`agent-browser screenshot`) renders from the
browser engine and needs no real display; OS-level capture (`screencapture`,
osascript) is macOS-only.
| Surface | macOS (local) | Linux / cloud (headless) | Screenshot mechanism |
| -------- | ------------- | --------------------------------------------------------- | ------------------------------------------------------ |
| CLI | ✅ | ✅ | n/a — text output |
| Web | ✅ | ✅ headless Chromium works natively | CDP — no display needed |
| Electron | ✅ | ⚠️ runs, but needs a display server: wrap with `xvfb-run` | CDP works under Xvfb; `capture-app-window.sh` does NOT |
| Bot | ✅ | ❌ osascript + native apps are macOS-only | macOS `screencapture` only |
When a test must stay cloud-portable, prefer CDP-based evidence over
OS-level capture wherever both exist.
### Bot platforms
| Platform | Guide | Quick switcher |
| ------------- | ------------------------------------------------ | --------------------- |
| Discord | [bot/discord/index.md](./bot/discord/index.md) | `Cmd+K` |
| Slack | [bot/slack/index.md](./bot/slack/index.md) | `Cmd+K` |
| Telegram | [bot/telegram/index.md](./bot/telegram/index.md) | `Cmd+F` |
| WeChat / 微信 | [bot/wechat/index.md](./bot/wechat/index.md) | `Cmd+F` |
| Lark / 飞书 | [bot/lark/index.md](./bot/lark/index.md) | `Cmd+K` |
| QQ | [bot/qq/index.md](./bot/qq/index.md) | `Cmd+F` |
| iMessage | [bot/imessage/index.md](./bot/imessage/index.md) | bridge (no osascript) |
Each platform folder contains an `index.md` (activation, navigation,
send-message, verification snippets) and a `test-<platform>-bot.sh` script
sharing the interface:
```bash
./.agents/skills/agent-testing/bot/<platform>/test-<platform>-bot.sh <channel_or_contact> <message> [wait_seconds] [screenshot_path]
```
New to osascript automation? Read
[references/osascript.md](./references/osascript.md) first — it is a general
macOS-automation asset (activate, type, paste, screenshot, accessibility reads,
gotchas), not bot-specific.
## Step 2 — Run
Surface guides above carry the detailed workflows. Shared infrastructure:
| Need | Where |
| ------------------------------------ | -------------------------------------------------------------------- |
| Start / restart the local dev server | [references/dev-server.md](./references/dev-server.md) |
| `agent-browser` command reference | [references/agent-browser.md](./references/agent-browser.md) |
| osascript patterns (general macOS) | [references/osascript.md](./references/osascript.md) |
| Agent gateway probing | [references/agent-gateway.md](./references/agent-gateway.md) |
| Screen recording | [references/record-app-screen.md](./references/record-app-screen.md) |
### Scripts
All under `.agents/skills/agent-testing/scripts/`:
| Script | Usage |
| ------------------------- | ---------------------------------------------------------------------------- |
| `test-env.sh` | Print/export the resolved local test env and ports |
| `setup-auth.sh` | One-stop auth setup & status check (`status` / `cli` / `web`) |
| `init-dev-env.sh` | Self-contained local dev env (`setup-db` / `seed-user` / `dev-next` / `dev`) |
| `app-probe.sh` | LobeHub app probes: `auth` / `route` / `ops` / `goto <path>` / `errors` |
| `record-gif.sh` | Frame-sequence → GIF for time-based behavior (streaming, timers, animations) |
| `report-init.sh` | Scaffold a structured test report (Step 3) |
| `electron-dev.sh` | Manage Electron dev env (start/stop/status/restart, CDP 9222) |
| `capture-app-window.sh` | Screenshot a specific app window (general; used by bot tests) |
| `record-app-screen.sh` | Record app screen (video + periodic screenshots) |
| `record-electron-demo.sh` | Record Electron app demo with ffmpeg |
| `agent-gateway/` | Gateway probe / dump / analyze tools |
`app-probe.sh` is the LobeHub-specific fast path into app state — auth check,
current route, running operations, and `goto <path>` quick navigation
(`/agent/<agentId>/<topicId>`, `/task/<taskId>`, `/settings`, …) so a test can
jump straight to the state under test instead of clicking through the UI. See
[ui/electron.md](./ui/electron.md#lobehub-probes--quick-navigation) for usage.
## Step 3 — Structured report (mandatory deliverable)
Every automated test session ends with a structured, evidence-backed report —
not a chat-only summary. Scaffold it up front and fill it as you test:
```bash
DIR=$(./.agents/skills/agent-testing/scripts/report-init.sh my-feature "Verify my feature")
# ... test, saving screenshots / CLI transcripts into $DIR/assets/ ...
# fill $DIR/report.md (scope, case table with inline evidence, verdict, score) and $DIR/result.json
```
Reports live in `.records/reports/<timestamp>-<slug>/` (gitignored): `report.md`
(human-readable, with screenshots/GIFs embedded directly in the case table),
`result.json` (machine-readable pass/fail + score), `assets/` (evidence).
Format spec and evidence rules:
[references/report.md](./references/report.md).
Two hard rules worth front-loading:
- **Report language = the user's conversation language.** Write the ENTIRE
`report.md` (headings included) in the language the user is conversing in —
no mixed English. `result.json` keys/status values stay English.
- **The case table is the main reading surface.** Prefer the compact
`# | case | result | key observation | evidence` shape and embed the
screenshot/GIF in the evidence cell. Use separate evidence sections only for
long CLI transcripts, HAR summaries, or supplemental detail.
- **Visual evidence must render inline.** Screenshots and GIFs in `report.md`
must use Markdown image syntax like `![case 1](assets/case1.png)`. Do not
use bare file paths, Markdown links, or local file links as the primary
visual evidence; those make the report unreadable without opening each asset.
- **Final replies must include visual evidence links.** When a run includes UI
screenshots or GIFs, include the report directory and the most important
visual artifacts in the final chat response. Each item must include a stable
label, an evidence caption describing the observed UI outcome, and a
repo-relative path, for example:
`[Image #1 - error toast shows provider auth failure](<report-dir>/assets/foo.png)`.
Use repo-relative paths, not absolute paths.
- **Time-based behavior needs a GIF, not a screenshot.** If a case asserts
change over time (streaming output, a ticking timer, loading states,
animations), record it with `scripts/record-gif.sh` and embed the GIF —
a static screenshot cannot prove the behavior.
## Directory map
```
agent-testing/
├── SKILL.md # this router
├── cli/index.md # backend verification via the LobeHub CLI
├── ui/electron.md # pure-frontend verification in the desktop app
├── ui/web.md # full-stack verification in the browser
├── bot/<platform>/ # bot-channel verification (osascript / bridge)
├── references/ # shared knowledge: auth, dev-server, agent-browser, osascript, report
└── scripts/ # setup-auth, report-init, electron-dev, capture, recording, gateway
```
## Gotchas
- agent-browser: see [references/agent-browser.md](./references/agent-browser.md#gotchas)
- Electron: see [ui/electron.md](./ui/electron.md#electron-gotchas)
- osascript: see [references/osascript.md](./references/osascript.md#gotchas)
@@ -2,7 +2,7 @@
**App name:** `Discord` | **Process name:** `Discord`
See [osascript-common.md](../osascript-common.md) for shared patterns.
See [references/osascript.md](../../references/osascript.md) for shared patterns.
## Activate & Navigate
@@ -92,6 +92,6 @@ echo "Screenshot saved to /tmp/discord-test-result.png"
## Script
```bash
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "!ping"
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
./.agents/skills/agent-testing/bot/discord/test-discord-bot.sh "bot-testing" "!ping"
./.agents/skills/agent-testing/bot/discord/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
```
@@ -60,5 +60,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../../scripts/capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
@@ -21,7 +21,7 @@ So the test surface is three layers:
curl -sS -m4 -o /dev/null -w '%{http_code}\n' \
"http://127.0.0.1:1234/api/v1/server/info?password=<PW>" # expect 200
```
- **Electron dev running with CDP**: `./.agents/skills/local-testing/scripts/electron-dev.sh start`
- **Electron dev running with CDP**: `./.agents/skills/agent-testing/scripts/electron-dev.sh start`
- The **iMessage Desktop branch** checked out (the `imessageBridge` IPC group
and `@lobechat/chat-adapter-imessage` must be compiled into the main bundle).
Run `pnpm install --ignore-scripts` at the repo root **and** in `apps/desktop/`
@@ -31,7 +31,7 @@ So the test surface is three layers:
## Fast path: automated script
```bash
./.agents/skills/local-testing/bot/imessage/test-imessage-bridge.sh '<bluebubbles_password>' [bb_url] [cdp_port]
./.agents/skills/agent-testing/bot/imessage/test-imessage-bridge.sh '<bluebubbles_password>' [bb_url] [cdp_port]
```
Asserts the whole flow and self-cleans (unique `applicationId` per run, removes
@@ -136,7 +136,7 @@ Verifies the leg the bridge uses to _reply_: `BlueBubblesApiClient.sendText`
`POST /api/v1/message/text`. Run the helper against your own number:
```bash
./.agents/skills/local-testing/bot/imessage/send-imessage-test.sh '<bb_password>' '+<E164>' # e.g. +15551234567
./.agents/skills/agent-testing/bot/imessage/send-imessage-test.sh '<bb_password>' '+<E164>' # e.g. +15551234567
```
**Gotcha that bites everyone:** with `method=apple-script` and a _new_
@@ -2,7 +2,7 @@
**App name:** `Lark` or `飞书` | **Process name:** `Lark` or `飞书`
See [osascript-common.md](../osascript-common.md) for shared patterns.
See [references/osascript.md](../../references/osascript.md) for shared patterns.
## Activate & Navigate
@@ -56,6 +56,6 @@ screencapture /tmp/lark-bot-response.png
## Script
```bash
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "@MyBot hello"
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "Help me with this" 30
./.agents/skills/agent-testing/bot/lark/test-lark-bot.sh "bot-testing" "@MyBot hello"
./.agents/skills/agent-testing/bot/lark/test-lark-bot.sh "bot-testing" "Help me with this" 30
```
@@ -80,5 +80,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../../scripts/capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
@@ -2,7 +2,7 @@
**App name:** `QQ` | **Process name:** `QQ`
See [osascript-common.md](../osascript-common.md) for shared patterns.
See [references/osascript.md](../../references/osascript.md) for shared patterns.
## Activate & Navigate
@@ -57,6 +57,6 @@ screencapture /tmp/qq-bot-response.png
## Script
```bash
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "bot-testing" "Hello bot" 15
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "MyBot" "/help" 10
./.agents/skills/agent-testing/bot/qq/test-qq-bot.sh "bot-testing" "Hello bot" 15
./.agents/skills/agent-testing/bot/qq/test-qq-bot.sh "MyBot" "/help" 10
```
@@ -72,5 +72,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../../scripts/capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
@@ -2,7 +2,7 @@
**App name:** `Slack` | **Process name:** `Slack`
See [osascript-common.md](../osascript-common.md) for shared patterns.
See [references/osascript.md](../../references/osascript.md) for shared patterns.
## Activate & Navigate
@@ -68,6 +68,6 @@ screencapture /tmp/slack-bot-response.png
## Script
```bash
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "@mybot hello"
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
./.agents/skills/agent-testing/bot/slack/test-slack-bot.sh "bot-testing" "@mybot hello"
./.agents/skills/agent-testing/bot/slack/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
```
@@ -60,5 +60,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../../scripts/capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
@@ -2,7 +2,7 @@
**App name:** `Telegram` | **Process name:** `Telegram`
See [osascript-common.md](../osascript-common.md) for shared patterns.
See [references/osascript.md](../../references/osascript.md) for shared patterns.
## Activate & Navigate
@@ -75,6 +75,6 @@ curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=5" | j
## Script
```bash
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "MyTestBot" "/start"
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "GPTBot" "Hello" 60
./.agents/skills/agent-testing/bot/telegram/test-telegram-bot.sh "MyTestBot" "/start"
./.agents/skills/agent-testing/bot/telegram/test-telegram-bot.sh "GPTBot" "Hello" 60
```
@@ -75,5 +75,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../../scripts/capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
@@ -2,7 +2,7 @@
**App name:** `微信` or `WeChat` | **Process name:** `WeChat`
See [osascript-common.md](../osascript-common.md) for shared patterns.
See [references/osascript.md](../../references/osascript.md) for shared patterns.
## Activate & Navigate
@@ -76,6 +76,6 @@ screencapture /tmp/wechat-bot-response.png
## Script
```bash
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "文件传输助手" "test message" 5
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
./.agents/skills/agent-testing/bot/wechat/test-wechat-bot.sh "文件传输助手" "test message" 5
./.agents/skills/agent-testing/bot/wechat/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
```
@@ -81,5 +81,5 @@ echo "[$APP] Waiting ${WAIT}s for bot response..."
sleep "$WAIT"
echo "[$APP] Capturing screenshot..."
"$SCRIPT_DIR/../capture-app-window.sh" "$APP" "$SCREENSHOT"
"$SCRIPT_DIR/../../scripts/capture-app-window.sh" "$APP" "$SCREENSHOT"
echo "[$APP] Done! Screenshot saved to $SCREENSHOT"
+152
View File
@@ -0,0 +1,152 @@
# CLI Backend Verification
Default surface for verifying **backend changes** (TRPC routers, services,
models, migrations) end-to-end: fastest loop, text-assertable output, zero UI
flakiness.
## When to use
- Verifying TRPC router / service / model changes end-to-end
- Testing new API fields or response structure changes
- Validating CLI command output after backend modifications
- Debugging data flow issues between server and CLI
## Prerequisites
| Requirement | Details |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Dev server | `localhost:3010` — see [../references/dev-server.md](../references/dev-server.md) |
| CLI source | `apps/cli/` — runs from source, no rebuild; standalone `node_modules` — run `pnpm install` inside `apps/cli/` (root install does not cover it) |
| CLI dev mode | `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated settings |
| Auth | Seeded API key first; Device Code Flow only as fallback — see [../references/auth.md](../references/auth.md) |
All CLI dev commands run from `apps/cli/`. Subsequent examples use `$CLI`:
```bash
source ../../.records/env/agent-testing-cli.env
CLI="bun src/index.ts"
```
## Workflow
### Step 1 — Server up?
See [../references/dev-server.md](../references/dev-server.md) for the health
check, start, and restart commands. Server-side code changes require a restart.
### Step 2 — Auth ready?
```bash
./.agents/skills/agent-testing/scripts/setup-auth.sh status
```
If the CLI is not ready in the seeded local environment:
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
source .records/env/agent-testing-cli.env
./.agents/skills/agent-testing/scripts/setup-auth.sh cli-seed
```
If the target environment is not seeded, use the interactive fallback:
```bash
cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3010
```
Seeded API-key auth does not store credentials. It writes local settings under
`$HOME/.lobehub-dev` and requires the generated env file to be sourced before
CLI commands. Details:
[../references/auth.md](../references/auth.md).
### Step 3 — Test with CLI commands
CLI runs from source, so CLI-side code changes take effect immediately without
rebuilding:
```bash
cd apps/cli
$CLI <command>
```
Capture output for the report as you go (e.g. `$CLI task list | tee "$DIR/assets/task-list.txt"`).
### Step 4 — Clean up test data
```bash
$CLI task delete < id > -y
$CLI agent delete < id > -y
```
### Step 5 — Report
Finish with a structured report —
[../references/report.md](../references/report.md). CLI evidence = exact
command + trimmed output.
## Common testing patterns
### Task system
```bash
$CLI task list
$CLI task create -n "Root Task" -i "Test instruction"
$CLI task create -n "Child Task" -i "Sub instruction" --parent T-1
$CLI task view T-1
$CLI task tree T-1
$CLI task edit T-1 --status running
$CLI task comment T-1 -m "Test comment"
$CLI task delete T-1 -y
```
### Agent system
```bash
$CLI agent list
$CLI agent view <agent-id>
$CLI agent run <agent-id> -m "Test prompt"
```
### Document & knowledge base
```bash
$CLI doc list
$CLI doc create -t "Test Doc" -c "Content here"
$CLI doc view <doc-id>
$CLI kb list
$CLI kb tree <kb-id>
```
### Model & provider
```bash
$CLI model list
$CLI provider list
$CLI provider test <provider-id>
```
## Dev-test cycle
```
1. Make code changes (service/model/router/type)
|
2. Run unit tests (fast feedback)
bunx vitest run --silent='passed-only' '<test-file>'
|
3. Restart dev server (if server-side changes — see dev-server.md)
|
4. CLI verification (end-to-end)
$CLI <command>
|
5. Clean up test data + write the report
```
## Troubleshooting
| Issue | Solution |
| --------------------------- | ------------------------------------------------------------------------------------------------------ |
| `No authentication found` | Source `.records/env/agent-testing-cli.env`, or run device-code `login --server http://localhost:3010` |
| `UNAUTHORIZED` on API calls | Re-run `init-dev-env.sh seed-user` and re-source the env file; for device-code fallback, re-run login |
| `ECONNREFUSED` | Dev server not running — see dev-server.md |
| CLI shows old data/behavior | Server needs restart to pick up code changes |
| Login opens wrong server | Must use `--server` flag (env var doesn't work) |
@@ -0,0 +1,257 @@
# agent-browser CLI Reference
Generic reference for the `agent-browser` CLI — automate Chromium-based apps (Electron, Chrome, web) via Chrome DevTools Protocol. LobeHub-specific patterns live in [../ui/electron.md](../ui/electron.md) and [../ui/web.md](../ui/web.md); authentication recipes live in [auth.md](./auth.md).
Use `agent-browser` to automate Chromium-based apps via Chrome DevTools Protocol.
Install via `npm i -g agent-browser`, `brew install agent-browser`, or `cargo install agent-browser`. Run `agent-browser install` to download Chrome. Run `agent-browser upgrade` to update.
## Core Workflow
Every browser automation follows this pattern:
1. **Navigate**: `agent-browser open <url>`
2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
3. **Interact**: Use refs to click, fill, select
4. **Re-snapshot**: After navigation or DOM changes, get fresh refs
```bash
agent-browser open https://example.com/form
agent-browser snapshot -i
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit"
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i # Check result
```
## Command Chaining
```bash
# Chain open + wait + snapshot in one call
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
```
Use `&&` when you don't need to read intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs, then interact).
## Essential Commands
```bash
# Navigation
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser close # Close browser
agent-browser close --all # Close all active sessions
# Snapshot
agent-browser snapshot -i # Interactive elements with refs (recommended)
agent-browser snapshot -s "#selector" # Scope to CSS selector
# Interaction (use @refs from snapshot)
agent-browser click @e1 # Click element
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser fill @e2 "text" # Clear and type text
agent-browser type @e2 "text" # Type without clearing
agent-browser select @e1 "option" # Select dropdown option
agent-browser check @e1 # Check checkbox
agent-browser press Enter # Press key
agent-browser keyboard type "text" # Type at current focus (no selector)
agent-browser keyboard inserttext "text" # Insert without key events
agent-browser scroll down 500 # Scroll page
agent-browser scroll down 500 --selector "div.content" # Scroll within container
# Get information
agent-browser get text @e1 # Get element text
agent-browser get url # Get current URL
agent-browser get title # Get page title
agent-browser get cdp-url # Get CDP WebSocket URL
# Wait
agent-browser wait @e1 # Wait for element
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --url "**/page" # Wait for URL pattern
agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Welcome" # Wait for text to appear
agent-browser wait --fn "!document.body.innerText.includes('Loading...')" # Wait for text to disappear
agent-browser wait "#spinner" --state hidden # Wait for element to disappear
# Downloads
agent-browser download @e1 ./file.pdf # Click element to trigger download
agent-browser wait --download ./output.zip # Wait for any download to complete
# Network
agent-browser network requests # Inspect tracked requests
agent-browser network requests --type xhr,fetch # Filter by resource type
agent-browser network requests --method POST # Filter by HTTP method
agent-browser network route "**/api/*" --abort # Block matching requests
agent-browser network har start # Start HAR recording
agent-browser network har stop ./capture.har # Stop and save HAR file
# Viewport & Device Emulation
agent-browser set viewport 1920 1080 # Set viewport size (default: 1280x720)
agent-browser set viewport 1920 1080 2 # 2x retina
agent-browser set device "iPhone 14" # Emulate device (viewport + user agent)
# Capture
agent-browser screenshot # Screenshot to temp dir
agent-browser screenshot --full # Full page screenshot
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
agent-browser pdf output.pdf # Save as PDF
# Clipboard
agent-browser clipboard read # Read text from clipboard
agent-browser clipboard write "text" # Write text to clipboard
agent-browser clipboard copy # Copy current selection
agent-browser clipboard paste # Paste from clipboard
# Dialogs (alert, confirm, prompt, beforeunload)
agent-browser dialog accept # Accept dialog
agent-browser dialog accept "input" # Accept prompt dialog with text
agent-browser dialog dismiss # Dismiss/cancel dialog
agent-browser dialog status # Check if dialog is open
# Diff (compare page states)
agent-browser diff snapshot # Compare current vs last snapshot
agent-browser diff screenshot --baseline before.png # Visual pixel diff
agent-browser diff url <url1> <url2> # Compare two pages
# Streaming
agent-browser stream enable # Start WebSocket streaming
agent-browser stream status # Inspect streaming state
agent-browser stream disable # Stop streaming
```
## Batch Execution
```bash
echo '[
["open", "https://example.com"],
["snapshot", "-i"],
["click", "@e1"],
["screenshot", "result.png"]
]' | agent-browser batch --json
```
## Authentication
```bash
# Option 1: Auth vault (credentials stored encrypted)
echo "$PASSWORD" | agent-browser auth save myapp --url https://app.example.com/login --username user --password-stdin
agent-browser auth login myapp
# Option 2: Session name (auto-save/restore cookies + localStorage)
agent-browser --session-name myapp open https://app.example.com/login
agent-browser close # State auto-saved
agent-browser --session-name myapp open https://app.example.com/dashboard # Auto-restored
# Option 3: Persistent profile
agent-browser --profile ~/.myapp open https://app.example.com/login
# Option 4: State file
agent-browser state save auth.json
agent-browser state load auth.json
```
### LobeHub dev server — inject better-auth cookie
`agent-browser --headed` on macOS can create an off-screen Chromium window, blocking manual login. For a local LobeHub dev server (e.g. `localhost:3010`), copy the `better-auth.session_token` cookie out of a **Network request** in the user's own Chrome DevTools and load it via `state load`. See [auth.md](./auth.md) for the full recipe.
## Semantic Locators (Alternative to Refs)
```bash
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find role button click --name "Submit"
agent-browser find placeholder "Search" type "query"
agent-browser find testid "submit-btn" click
```
## JavaScript Evaluation (eval)
```bash
# Simple expressions
agent-browser eval 'document.title'
# Complex JS: use --stdin with heredoc (RECOMMENDED)
agent-browser eval --stdin << 'EVALEOF'
JSON.stringify(
Array.from(document.querySelectorAll("img"))
.filter(i => !i.alt)
.map(i => ({ src: i.src.split("/").pop(), width: i.width }))
)
EVALEOF
# Base64 encoding (avoids all shell escaping issues)
agent-browser eval -b "$(echo -n 'document.title' | base64)"
```
## Ref Lifecycle
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after clicking links/buttons that navigate, form submissions, or dynamic content loading.
## Annotated Screenshots (Vision Mode)
```bash
agent-browser screenshot --annotate
# Output includes the image path and a legend:
# [1] @e1 button "Submit"
# [2] @e2 link "Home"
agent-browser click @e2 # Click using ref from annotated screenshot
```
## Parallel Sessions
```bash
agent-browser --session site1 open https://site-a.com
agent-browser --session site2 open https://site-b.com
agent-browser session list
```
## Connect to Existing Chrome
```bash
agent-browser --auto-connect snapshot # Auto-discover running Chrome
agent-browser --cdp 9222 snapshot # Explicit CDP port
```
## iOS Simulator (Mobile Safari)
```bash
agent-browser device list
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
agent-browser -p ios snapshot -i
agent-browser -p ios tap @e1
agent-browser -p ios swipe up
agent-browser -p ios screenshot mobile.png
agent-browser -p ios close
```
## Observability Dashboard
```bash
agent-browser dashboard install
agent-browser dashboard start # Background server on port 4848
agent-browser dashboard stop
```
## Cloud Providers
Use `-p <provider>` to run against cloud browsers: `agentcore`, `browserbase`, `browserless`, `browseruse`, `kernel`.
## Browser Engine Selection
```bash
agent-browser --engine lightpanda open example.com # 10x faster, 10x less memory
```
## Gotchas
- **Daemon can get stuck** — if commands hang, `agent-browser close --all` or `pkill -f agent-browser` to reset
- **HMR invalidates everything** — after code changes, refs break. Re-snapshot or restart
- **`snapshot -i` doesn't find contenteditable** — use `snapshot -i -C` for rich text editors
- **`fill` doesn't work on contenteditable** — use `type` for chat inputs
- **Screenshots go to `~/.agent-browser/tmp/screenshots/`** — read them with the `Read` tool
- **Dialogs block all commands** — if commands time out, check `agent-browser dialog status`
- **Default timeout is 25s** — override with `AGENT_BROWSER_DEFAULT_TIMEOUT` (ms) or use explicit waits
- **Shell quoting corrupts eval** — use `eval --stdin <<'EVALEOF'` for complex JS
@@ -19,13 +19,13 @@ works for any LobeHub streaming session.
```bash
# 1. Start Electron with CDP
./.agents/skills/local-testing/scripts/electron-dev.sh start
./.agents/skills/agent-testing/scripts/electron-dev.sh start
# 2. Navigate to a chat, switch runtime to Cloud Sandbox (gateway mode)
# 3. Install the probe + helpers
agent-browser --cdp 9222 eval --stdin \
< .agents/skills/local-testing/scripts/agent-gateway/probe.js
< .agents/skills/agent-testing/scripts/agent-gateway/probe.js
# 4. Send a tool-call message — manually or via type+press
agent-browser --cdp 9222 eval "window.__PROBE_EVENT('SENT')"
@@ -34,15 +34,15 @@ agent-browser --cdp 9222 eval "window.__PROBE_EVENT('SENT')"
# rightmost inactive tab as AWAY — edit ROUND_TRIPS / DWELL_MS in the
# file if you want different timing)
agent-browser --cdp 9222 eval --stdin \
< .agents/skills/local-testing/scripts/agent-gateway/tab-switch.js
< .agents/skills/agent-testing/scripts/agent-gateway/tab-switch.js
# 6. Wait for streaming to finish, then dump
agent-browser --cdp 9222 eval --stdin \
< .agents/skills/local-testing/scripts/agent-gateway/probe-dump.js \
< .agents/skills/agent-testing/scripts/agent-gateway/probe-dump.js \
> /tmp/probe.json
# 7. Analyze
node .agents/skills/local-testing/scripts/agent-gateway/analyze.mjs /tmp/probe.json
node .agents/skills/agent-testing/scripts/agent-gateway/analyze.mjs /tmp/probe.json
```
The analyzer prints three sections: EVENTS, TIMELINE, REGRESSIONS. If
@@ -0,0 +1,166 @@
# Auth Setup for Local Agent Testing
**Auth is the gate for all automated testing.** Complete
[Step 0.0](../SKILL.md#00-resolve-the-current-test-environment) first so
`SERVER_URL` and ports are resolved, then verify auth before writing any test
step.
Initialize helpers first:
```bash
SCRIPT="./.agents/skills/agent-testing/scripts/setup-auth.sh"
TEST_ENV="./.agents/skills/agent-testing/scripts/test-env.sh"
eval "$($TEST_ENV --exports)"
```
Quick reference after initialization:
| Command | Purpose |
| ------------------------------ | -------------------------------------------------- |
| `$SCRIPT status` | Check all surfaces (server + CLI + web + Electron) |
| `$SCRIPT status --surface web` | Check only the Web surface gate |
| `$SCRIPT cli-seed` | Configure CLI API-key auth from the seeded key |
| `$SCRIPT cli` | Interactive CLI device-code login (user must run) |
| `$SCRIPT open-chrome` | Open Chrome at `SERVER_URL` with DevTools |
| `$SCRIPT web-seed` | Sign in the seeded user and inject cookies |
| `pbpaste \| $SCRIPT web` | Inject a copied Cookie header into agent-browser |
| `$SCRIPT web-verify` | Live-check agent-browser session auth |
Use `localhost` for Web auth; better-auth cookies are stored for `localhost`,
not `127.0.0.1`.
## Per-surface overview
| Surface | Mechanism | Persistence | Human interaction |
| -------- | ---------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------- |
| CLI | Seeded API key or OIDC Device Code Flow | `.records/env/agent-testing-cli.env` + `$HOME/.lobehub-dev` | No for seed path; yes for device-code fallback |
| Web | Seeded better-auth login or cookie copy | `~/.lobehub-agent-testing/web-state.json` + agent-browser session | No for seed path; copy cookie only as fallback |
| Electron | App's own login state | Electron user-data dir | Log in once manually in the app |
| Bot | Native apps (Discord/WeChat/…) logged in | Each app's own session | Once per app |
## CLI — Seeded API key
For the self-contained no-root-`.env` dev environment, seed the baseline user
and API key once:
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
source .records/env/agent-testing-cli.env
./.agents/skills/agent-testing/scripts/setup-auth.sh cli-seed
```
The seed step writes `LOBE_API_KEY` for humans and maps it to the CLI's current
auth variable, `LOBEHUB_CLI_API_KEY`. It also sets `LOBEHUB_SERVER` so CLI
commands hit the local server without needing a stored device-code token.
Use this for automated CLI verification:
```bash
cd apps/cli
source ../../.records/env/agent-testing-cli.env
bun src/index.ts <command>
```
## CLI — Device Code Flow fallback
Use device-code login only when testing against a non-seeded environment.
Credentials are isolated from the user's real CLI config via
`LOBEHUB_CLI_HOME=.lobehub-dev`, which the current CLI stores under
`$HOME/.lobehub-dev`.
```bash
cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3010
```
- The `--server` flag is required — an env var does NOT work and login will hit
the wrong server without it.
- Check state without logging in: `setup-auth.sh status` (verifies
`LOBEHUB_CLI_API_KEY` when present, otherwise checks the stored server URL).
- `UNAUTHORIZED` on API calls means the token expired — re-run login.
## Web — seeded better-auth login
The Web test surface is `agent-browser --session lobehub-dev`. The user's
ordinary Chrome is only a cookie source; Chrome screenshots, Chrome Network
records, and Chrome logged-in state do not prove the agent-browser test session
is authenticated.
For the seeded local dev environment, use the automatic path:
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
./.agents/skills/agent-testing/scripts/setup-auth.sh web-seed
```
`web-seed` posts the seeded email/password to
`/api/auth/sign-in/email`, stores the returned cookie jar under
`~/.lobehub-agent-testing/`, converts it to Playwright `storageState`, loads it
into the `agent-browser` session, and verifies the session does not land on
`/signin`.
## Web — manual cookie injection fallback
`agent-browser --headed` on macOS often creates the Chromium window off-screen —
the user can't see or interact with it, so manual login inside the agent-browser
session fails. Instead, copy the **better-auth session cookie** out of the
user's own logged-in Chrome and inject it as a Playwright-style state file.
Do **not** use this on production URLs — only local dev. Treat the cookie as a
secret: don't paste it into shared logs, PRs, or commit it anywhere.
### Web — decision flow
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`.
### Using the authenticated session
```bash
agent-browser --session lobehub-dev open "$SERVER_URL/"
agent-browser --session lobehub-dev snapshot -i | head -20
```
### Notes
- `storageState` doesn't enforce the HttpOnly flag on load — the script stores
cookies with `httpOnly: false`, which is fine for local dev and sidesteps a
CDP-context quirk where HttpOnly cookies sometimes fail to attach.
- The state file is kept at `~/.lobehub-agent-testing/web-state.json` so
`setup-auth.sh status` can report web-auth readiness across sessions.
### Common failure modes
| Symptom | Cause | Fix |
| --------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Still redirects to `/signin` after injection | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
| Script reports `no better-auth cookies found` | User pasted the wrong value, or the cookie parser regressed | Keep the raw `Cookie:` header as-is; run `scripts/setup-auth.test.sh` if the input looks valid |
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-inject |
| Domain mismatch | Cookie domain must be `localhost` literally, no leading dot for local dev | — |
## Electron
The desktop app keeps its own persistent login state in its user-data
directory — log in once manually inside the app and it survives restarts of
`electron-dev.sh`. No injection needed. The standard check (do NOT hand-roll a
store eval) once Electron is up with CDP:
```bash
./.agents/skills/agent-testing/scripts/app-probe.sh auth
# → {"ok":true,"isSignedIn":true,"userId":"user_xxx"}
```
`setup-auth.sh status` runs this probe automatically when CDP 9222 is
reachable.
## Scope
These recipes only cover **local dev** authentication. They do not:
- Work for production — production cookies are `Secure; HttpOnly; Domain=.lobehub.com`
and must be delivered over HTTPS.
- Replace real OAuth flows — tests that must exercise the login UI itself need a
real Chromium with `--remote-debugging-port` or a bot account.
- Flow cookies back to the user's Chrome — injection is one-way.
@@ -0,0 +1,98 @@
# Local Dev Server
Single source of truth for starting / restarting the backend that all test
surfaces (CLI, Electron, Web) hit.
## Resolve ports first
Run `test-env.sh` as described in
[SKILL.md Step 0.0](../SKILL.md#00-resolve-the-current-test-environment)
before starting or probing any local test surface.
## Ports & modes
| Command | What it runs | Port source |
| ------------------- | --------------------------------------------------------- | ------------------- |
| `pnpm run dev:next` | Next.js backend (API + auth) | `PORT` |
| `bun run dev` | Full-stack (Next.js + Vite SPA, via `devStartupSequence`) | `PORT` + `SPA_PORT` |
| `bun run dev:spa` | Vite SPA only, proxies API to `PORT` | `SPA_PORT` |
In the **cloud repo** (where this repo is the `lobehub/` submodule), local
worktree names map to fallback defaults only when `.env` and shell env do not
provide values:
| Workspace directory | Default `SERVER_URL` |
| ------------------- | -------------------------------- |
| `lobehub` | `http://localhost:3010` |
| `lobehub-cloud` | `http://localhost:3020` |
| `lobehub-cloud-1` | `http://localhost:3021` |
| `lobehub-cloud-N` | `http://localhost:$((3020 + N))` |
`test-env.sh` and `setup-auth.sh` both use the resolved env first and these
worktree defaults only as fallback. Treat the dev-server terminal output as the
final source of truth when testing a non-standard port, then export it for every
agent-testing command:
```bash
export SERVER_URL=http://localhost:<port-from-dev-output>
```
## Health check
```bash
curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
```
## Start / restart
```bash
# Start backend only.
# With root .env: use the existing local config.
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
# Without root .env:
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
# Local QStash. Run in a separate terminal only when testing workflow paths.
./.agents/skills/agent-testing/scripts/init-dev-env.sh qstash
# Restart — required to pick up server-side code changes
lsof -ti:"$PORT" | xargs kill
pnpm run dev:next
# or, when no root .env exists:
# ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
```
## When a server restart is needed
Next.js hot-reload may not pick up changes in workspace packages — restart when
in doubt.
| Change location | Restart? |
| ----------------------------------------------- | -------- |
| `apps/server/src/` (routers, services, modules) | Yes |
| `src/server/` (agent-hono, workflows-hono) | Yes |
| `packages/database/` (models) | Yes |
| `packages/types/` | Yes |
| `packages/prompts/` | Yes |
| `apps/cli/` (CLI runs from source) | No |
## Troubleshooting
| Issue | Solution |
| ------------------------- | --------------------------------------------------------------------------------------------- |
| `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 |
| 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
gate. Do not block local product-chain verification on marketplace API auth
unless the change explicitly targets marketplace behavior.
@@ -12,13 +12,13 @@ General-purpose screen recording tool for the Electron app. Captures CDP screens
```bash
# Start recording (Electron must be running with CDP)
.agents/skills/local-testing/scripts/record-app-screen.sh start [output_name]
.agents/skills/agent-testing/scripts/record-app-screen.sh start [output_name]
# Stop recording and assemble video
.agents/skills/local-testing/scripts/record-app-screen.sh stop
.agents/skills/agent-testing/scripts/record-app-screen.sh stop
# Check if recording is active
.agents/skills/local-testing/scripts/record-app-screen.sh status
.agents/skills/agent-testing/scripts/record-app-screen.sh status
```
### Arguments
@@ -74,10 +74,10 @@ The `.records/` directory is at the project root and is gitignored.
```bash
# Start Electron
.agents/skills/local-testing/scripts/electron-dev.sh start
.agents/skills/agent-testing/scripts/electron-dev.sh start
# Start recording
.agents/skills/local-testing/scripts/record-app-screen.sh start my-test
.agents/skills/agent-testing/scripts/record-app-screen.sh start my-test
# Run automation
agent-browser --cdp 9222 click @e61
@@ -86,14 +86,14 @@ agent-browser --cdp 9222 press Enter
sleep 10
# Stop and get results
.agents/skills/local-testing/scripts/record-app-screen.sh stop
.agents/skills/agent-testing/scripts/record-app-screen.sh stop
# → .records/my-test.mp4 + .records/my-test/*.png
```
### Gateway Streaming Demo
```bash
.agents/skills/local-testing/scripts/electron-dev.sh start
.agents/skills/agent-testing/scripts/electron-dev.sh start
# Inject gateway URL
agent-browser --cdp 9222 eval --stdin << 'EOF'
@@ -106,19 +106,19 @@ agent-browser --cdp 9222 eval --stdin << 'EOF'
EOF
# Record
.agents/skills/local-testing/scripts/record-app-screen.sh start gateway-demo
.agents/skills/agent-testing/scripts/record-app-screen.sh start gateway-demo
# Navigate to agent, send message, wait for completion...
# (automation commands here)
.agents/skills/local-testing/scripts/record-app-screen.sh stop
.agents/skills/agent-testing/scripts/record-app-screen.sh stop
open .records/gateway-demo.mp4
```
### Check Active Recording
```bash
.agents/skills/local-testing/scripts/record-app-screen.sh status
.agents/skills/agent-testing/scripts/record-app-screen.sh status
# [record] Active recording
# Frames: 42 captured (running: yes)
# Screenshots: 14 captured (running: yes)
@@ -0,0 +1,186 @@
# Structured Test Reports
Every automated test session ends with a structured, evidence-backed report.
A chat-only summary is not an acceptable deliverable: the report is what the
user (or a reviewer, or a later agent) audits without replaying the session.
## Location & layout
Reports live under `.records/reports/` (gitignored, like all `.records/`
output):
```
.records/reports/<YYYYMMDD-HHMMSS>-<slug>/
├── report.md # human-readable report (case table with inline screenshots, verdict)
├── result.json # machine-readable results (pass/fail counts, score)
└── assets/ # evidence: screenshots, HAR files, CLI transcripts
```
## Workflow
1. **Scaffold up front** — before running the first test step:
```bash
DIR=$(./.agents/skills/agent-testing/scripts/report-init.sh < slug > "<title>")
```
The script creates the directory, pre-fills branch / commit / date in both
files, and prints the directory path. The scaffold uses the compact report
shape below; translate its headings and table labels to the user's language
before delivery if needed.
2. **Collect evidence as you test** — every asserted behavior gets one evidence
item in `$DIR/assets/`:
- UI (static state): `agent-browser screenshot` or `capture-app-window.sh`,
then **verify the screenshot with the Read tool before citing it** —
never cite an image you haven't looked at.
- UI (time-based behavior): **screenshot vs GIF is a judgment you must
make per case.** If the assertion is about change over time — streaming
output, a ticking timer, loading/progress states, animations,
appear/disappear transitions — a static screenshot cannot prove it.
Record a frame sequence and synthesize a GIF:
```bash
# start recording (background), trigger the behavior, wait for it to finish
../scripts/record-gif.sh "$DIR/assets/case2-streaming.gif" 12 2 &
GIF_PID=$!
# ... drive the scenario ...
wait $GIF_PID
```
Embed it like an image: `![case 2](assets/case2-streaming.gif)`. Verify
at least the first/last frames visually (Read the GIF) before citing.
- CLI: exact command + trimmed output (`$CLI task list | tee "$DIR/assets/task-list.txt"`).
- Network: `agent-browser network requests` dumps or HAR files.
3. **Fill `report.md` as you go** — don't reconstruct from memory at the end.
The primary evidence belongs in the case table itself: each row should pair
the assertion with the screenshot/GIF or non-visual artifact that proves it,
so readers can scan the result without jumping between sections. UI evidence
must render inline with Markdown image syntax; a plain link or file path is
not acceptable as primary visual evidence.
4. **Set the verdict** in both `report.md` and `result.json`, then link the
report directory in your final answer to the user. If UI evidence exists,
list the key screenshot/GIF links in the final chat response. Use Markdown
link text as the evidence caption, for example:
`[Image #1 - observed outcome](<report-dir>/assets/case1.png)`.
## Report language (hard rule)
**`report.md` MUST be written in the language the user is conversing in** —
the whole file, headings included. If the conversation is in Chinese, the
report is in Chinese; do not mix English prose into it. The scaffold headings
are placeholders — translate them when filling if the user is not conversing in
the scaffold language. Exceptions that stay as-is: code/commands, identifiers,
log excerpts, and `result.json` (its keys and status values are machine-read
and stay English; the `title` and case `name` fields follow the user's
language).
## report.md sections
Default report shape:
| Section | Content |
| ---------------- | -------------------------------------------------------------------------------------------- |
| **Scope** | What changed / what is being verified; branch, commit, date, surface, entry URL/page, focus |
| **Cases** | Compact table: `# \| Case \| Result \| Key observation \| Evidence` |
| **Verdict** | Overall verdict first (`pass` / `partial` / `fail`), then the concise reasons and follow-ups |
| **Verification** | Commands or automated checks run in this session, with trimmed results |
| **Score** | Pass/fail/blocked counts, optional 0100 score |
The case table is the main reading surface. Prefer one clear row per user
scenario or regression assertion, and put the screenshot/GIF directly in the
`Evidence` cell:
```markdown
| # | Case | Result | Key observation | Evidence |
| --- | ------------------------ | ------ | ----------------------------------------------------------------- | ------------------------------------------------ |
| 1 | Create a new page | pass | Title and body persisted after refresh | ![created page](assets/new-page-created.png) |
| 2 | Respect requested length | fail | Requested about 600 Chinese characters; final body was about 1286 | ![final article](assets/write-article-final.png) |
```
## Inline visual evidence
Screenshots and GIFs must be embedded so the report shows the image inline:
```markdown
![case 1 result](assets/case1-result.png)
![streaming response](assets/case2-streaming.gif)
```
Do **not** use these as the primary evidence for UI cases:
```markdown
[case 1 result](assets/case1-result.png)
assets/case1-result.png
file:///tmp/case1-result.png
```
Links are acceptable for non-visual artifacts such as CLI transcripts, HAR
files, or long logs. For videos, embed a representative screenshot/GIF inline in
the case row and link the full video as supplemental evidence.
Avoid the old wide table with separate `steps`, `expected`, and `actual`
columns unless the test is purely non-visual and truly needs that breakdown.
For UI reports, those columns make screenshot-backed reading harder. Put
procedural detail in the row's key observation only when it changes the
interpretation of the result.
Use an extra evidence/detail section only when the inline table cannot carry
the material cleanly, such as long CLI transcripts, HAR summaries, or multiple
screenshots for one case. In that situation, keep the table evidence cell as an
inline visual proof for UI cases or a concise link for non-visual artifacts,
then put the longer material under `Verification` or a brief
`Additional Evidence` section.
Status values: `pass` / `fail` / `blocked` (couldn't run — e.g. auth or env
missing; a blocked case is not a pass).
## result.json schema
```json
{
"branch": "feat/task-tree",
"cases": [
{
"id": "1",
"name": "task tree returns nested children",
"surface": "cli",
"status": "pass",
"evidence": ["assets/task-tree.txt"]
}
],
"commit": "abc1234",
"createdAt": "2026-06-11T15:30:00+08:00",
"summary": {
"total": 1,
"passed": 1,
"failed": 0,
"blocked": 0,
"score": 100,
"verdict": "pass"
},
"surfaces": ["cli"],
"title": "Verify task tree API"
}
```
`score` is optional — use it when the verdict has a subjective component (UI
polish, copy quality); omit it for purely binary runs. `verdict` is the single
word the user reads first: `pass`, `fail`, or `partial`.
## Rules
- **No evidence, no claim** — every `pass`/`fail` in the case table must link
at least one asset. UI cases must inline-embed their primary screenshot/GIF;
non-visual CLI/network cases may link transcripts, HAR files, or logs.
- **Screenshots must be visually verified** with the Read tool before being
cited.
- **Report failures faithfully** — a failing case with clear evidence is a good
report; a vague green one is not.
- If coverage was cut (cases skipped, surfaces not exercised), say so in the
Verdict section — silent truncation reads as "covered everything".
@@ -11,7 +11,7 @@
// 6. ROLLBACKS — msgN / childN / role drops in the active-topic timeline
//
// Usage:
// bun run .agents/skills/local-testing/scripts/agent-gateway/analyze-events.ts <dump.json>
// bun run .agents/skills/agent-testing/scripts/agent-gateway/analyze-events.ts <dump.json>
import { readFileSync } from 'node:fs';
@@ -5,16 +5,16 @@
// streaming-replay test fixtures.
//
// Commands:
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts install
// bun run .agents/skills/agent-testing/scripts/agent-gateway/run.ts install
// Bundle probe-events.ts and inject into the CDP-attached browser.
// Re-installing clears all buffers and re-patches WebSocket / fetch.
//
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts dump [name]
// bun run .agents/skills/agent-testing/scripts/agent-gateway/run.ts dump [name]
// Stop the timeline timer, fetch the capture as JSON, write it to
// `.agent-gateway/<name>-<YYYYMMDD-HHmmss>.json`. `name` defaults to
// `dump`. Prints the absolute path written.
//
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts analyze [path]
// bun run .agents/skills/agent-testing/scripts/agent-gateway/run.ts analyze [path]
// Run analyze-events.ts on the dump. `path` defaults to the most
// recently modified file in `.agent-gateway/`.
//
@@ -28,7 +28,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
// .agents/skills/local-testing/scripts/agent-gateway/ → 5 levels up
// .agents/skills/agent-testing/scripts/agent-gateway/ → 5 levels up
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '../../../../..');
const DUMP_DIR = path.join(PROJECT_ROOT, '.agent-gateway');
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# app-probe.sh — standardized probes for a running LobeHub app (Electron via
# CDP, or a web agent-browser session). Use these instead of hand-rolling
# `window.__LOBE_STORES` eval snippets — especially the auth check.
#
# Usage:
# app-probe.sh auth # { isSignedIn, userId } from the user store
# app-probe.sh route # current SPA route
# app-probe.sh ops # running chat operations (type / status / startTime)
# app-probe.sh goto <path> # navigate the SPA to a route (full reload), e.g. goto /agent/agt_xxx
# app-probe.sh errors-install # install a console.error interceptor
# app-probe.sh errors # dump errors captured since errors-install
#
# Target selection (default: Electron over CDP 9222):
# AB_TARGET="--cdp 9222" # Electron (default; CDP_PORT also honored)
# AB_TARGET="--session lobehub-dev" # web agent-browser session
#
# Common routes (desktop SPA): / /agent/<agentId> /agent/<agentId>/<topicId>
# /task /task/<taskId> /page /settings /community
set -euo pipefail
AB_TARGET="${AB_TARGET:---cdp ${CDP_PORT:-9222}}"
run_eval() {
# shellcheck disable=SC2086
agent-browser $AB_TARGET eval --stdin
}
case "${1:-}" in
auth)
run_eval << 'EVALEOF'
(function () {
var stores = window.__LOBE_STORES;
if (!stores || !stores.user) return JSON.stringify({ ok: false, reason: 'no user store — app not loaded yet?' });
var u = stores.user();
return JSON.stringify({ ok: !!u.isSignedIn, isSignedIn: !!u.isSignedIn, userId: (u.user && u.user.id) || null });
})()
EVALEOF
;;
route)
run_eval << 'EVALEOF'
location.pathname + location.search + location.hash
EVALEOF
;;
ops)
run_eval << 'EVALEOF'
(function () {
var stores = window.__LOBE_STORES;
if (!stores || !stores.chat) return JSON.stringify({ ok: false, reason: 'no chat store — open a conversation first' });
var ops = Object.values(stores.chat().operations || {});
var running = ops.filter(function (o) { return o.status === 'running'; });
return JSON.stringify({
ok: true,
running: running.map(function (o) { return { startTime: o.metadata && o.metadata.startTime, type: o.type }; }),
runningCount: running.length,
total: ops.length,
});
})()
EVALEOF
;;
goto)
TARGET_PATH="${2:?Usage: app-probe.sh goto <path>}"
# shellcheck disable=SC2086
agent-browser $AB_TARGET eval "location.href = '$TARGET_PATH'" > /dev/null
sleep 2
bash "${BASH_SOURCE[0]}" route
;;
errors-install)
run_eval << 'EVALEOF'
(function () {
window.__CAPTURED_ERRORS = [];
var orig = console.error;
console.error = function () {
var msg = Array.from(arguments).map(function (a) {
if (a instanceof Error) return a.message;
return typeof a === 'object' ? JSON.stringify(a) : String(a);
}).join(' ');
window.__CAPTURED_ERRORS.push(msg);
orig.apply(console, arguments);
};
return 'installed';
})()
EVALEOF
;;
errors)
run_eval << 'EVALEOF'
JSON.stringify(window.__CAPTURED_ERRORS || 'interceptor not installed — run errors-install first')
EVALEOF
;;
*)
echo "Usage: $0 {auth|route|ops|goto <path>|errors-install|errors}" >&2
exit 2
;;
esac
+407
View File
@@ -0,0 +1,407 @@
#!/usr/bin/env bash
# init-dev-env.sh — self-contained local dev env for agent testing.
#
# This script initializes the env needed to run LobeHub's normal local dev
# server without depending on a root .env file. It follows the same shape as
# the e2e bootstrap (Postgres + migrations + auth/key-vault/S3 test env), but
# starts the repo's dev server, not the standalone e2e server.
#
# Guardrail: if repo-root .env exists, every non-help command exits immediately.
# Existing local config always wins.
#
# 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 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
#
# Overrides:
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres QSTASH_DEV_PORT=8080
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
ROOT_ENV_FILE="$REPO_ROOT/.env"
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}"
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}"
QSTASH_DEV_PORT="${QSTASH_DEV_PORT:-8080}"
QSTASH_LOCAL_TOKEN="${QSTASH_LOCAL_TOKEN:-eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0=}"
QSTASH_LOCAL_CURRENT_SIGNING_KEY="${QSTASH_LOCAL_CURRENT_SIGNING_KEY:-sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r}"
QSTASH_LOCAL_NEXT_SIGNING_KEY="${QSTASH_LOCAL_NEXT_SIGNING_KEY:-sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs}"
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; }
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; }
note() { printf ' %s\n' "$1"; }
guard_no_root_env() {
if [[ -f "$ROOT_ENV_FILE" ]]; then
bad "root .env exists: $ROOT_ENV_FILE"
note "Use the existing local configuration instead of init-dev-env.sh."
note "Start normally from repo root, e.g. pnpm run dev:next or bun run dev."
exit 1
fi
}
apply_env() {
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}"
export DATABASE_DRIVER="${DATABASE_DRIVER:-node}"
export DATABASE_URL
export FEATURE_FLAGS="${FEATURE_FLAGS:--agent_self_iteration}"
export KEY_VAULTS_SECRET="${KEY_VAULTS_SECRET:-r2gbBPKyJ8ZRKCLKt+I3DImfcL+wGxaQyRC56xtm9Uk=}"
export NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION="${NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION:-0}"
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=6144}"
export PORT="${PORT:-$SERVER_PORT}"
export QSTASH_CURRENT_SIGNING_KEY="${QSTASH_CURRENT_SIGNING_KEY:-$QSTASH_LOCAL_CURRENT_SIGNING_KEY}"
export QSTASH_DEV_PORT
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 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}"
export S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY:-agent-testing-secret-key}"
}
env_keys() {
printf '%s\n' \
APP_URL \
AUTH_EMAIL_VERIFICATION \
AUTH_SECRET \
DATABASE_DRIVER \
DATABASE_URL \
FEATURE_FLAGS \
KEY_VAULTS_SECRET \
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION \
NODE_OPTIONS \
PORT \
QSTASH_CURRENT_SIGNING_KEY \
QSTASH_DEV_PORT \
QSTASH_NEXT_SIGNING_KEY \
QSTASH_TOKEN \
QSTASH_URL \
S3_ACCESS_KEY_ID \
S3_BUCKET \
S3_ENDPOINT \
S3_SECRET_ACCESS_KEY
}
print_env() {
apply_env
while IFS= read -r key; do
printf 'export %s=%q\n' "$key" "${!key}"
done < <(env_keys)
}
write_env() {
local file="${1:-$ENV_FILE_DEFAULT}"
apply_env
mkdir -p "$(dirname "$file")"
{
printf '# Source this file before starting LobeHub local dev server.\n'
printf '# Generated by %s\n' "$0"
while IFS= read -r key; do
printf 'export %s=%q\n' "$key" "${!key}"
done < <(env_keys)
} > "$file"
ok "wrote env file: $file"
note "source it with: source $file"
}
require_docker() {
if ! command -v docker > /dev/null 2>&1; then
bad "docker CLI is not available"
note "Install/start Docker Desktop, or provide DATABASE_URL for an existing Postgres."
return 1
fi
}
wait_for_db() {
printf ' waiting for Postgres'
until docker exec "$DB_CONTAINER" pg_isready -U postgres > /dev/null 2>&1; do
printf '.'
sleep 2
done
printf '\n'
}
start_db() {
require_docker
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
ok "Postgres container already running: $DB_CONTAINER"
elif docker ps -a --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
docker start "$DB_CONTAINER" > /dev/null
ok "started existing Postgres container: $DB_CONTAINER"
else
docker run -d \
--name "$DB_CONTAINER" \
-e POSTGRES_PASSWORD=postgres \
-p "${DB_PORT}:5432" \
paradedb/paradedb:latest > /dev/null
ok "created Postgres container: $DB_CONTAINER"
fi
wait_for_db
}
migrate_db() {
apply_env
cd "$REPO_ROOT"
bun run db:migrate
}
seed_user() {
apply_env
export AGENT_TESTING_API_KEY
export AGENT_TESTING_CLI_ENV_FILE="${AGENT_TESTING_CLI_ENV_FILE:-$CLI_ENV_FILE_DEFAULT}"
cd "$REPO_ROOT"
node <<'NODE'
const bcrypt = require('bcryptjs');
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const pg = require('pg');
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is required to seed the baseline test user.');
}
const TEST_USER = {
email: 'agent-testing@lobehub.com',
fullName: 'Agent Testing User',
id: 'user_agent_testing_001',
password: 'TestPassword123!',
username: 'agent_testing_user',
};
const TEST_API_KEY = {
id: 'api_key_agent_testing_001',
key: process.env.AGENT_TESTING_API_KEY || 'sk-lh-agenttesting0001',
name: 'Agent Testing CLI API Key',
};
const validateApiKeyFormat = (apiKey) => /^sk-lh-[\da-z]{16}$/.test(apiKey);
const hashApiKey = (apiKey) => {
const secret = process.env.KEY_VAULTS_SECRET;
if (!secret) throw new Error('KEY_VAULTS_SECRET is required to seed the baseline API key.');
return crypto.createHmac('sha256', secret).update(apiKey).digest('hex');
};
const encryptWithKeyVaultsSecret = (plaintext) => {
const secret = process.env.KEY_VAULTS_SECRET;
if (!secret) throw new Error('KEY_VAULTS_SECRET is required to seed the baseline API key.');
const rawKey = Buffer.from(secret, 'base64');
if (![16, 24, 32].includes(rawKey.length)) {
throw new Error(
`KEY_VAULTS_SECRET must decode to 16, 24, or 32 bytes, got ${rawKey.length} bytes.`,
);
}
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(`aes-${rawKey.length * 8}-gcm`, rawKey, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
};
const writeCliEnvFile = () => {
const file = process.env.AGENT_TESTING_CLI_ENV_FILE || '.records/env/agent-testing-cli.env';
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
[
'# Source this file before running LobeHub CLI agent tests.',
'# Generated by init-dev-env.sh seed-user',
`export LOBE_API_KEY=${TEST_API_KEY.key}`,
`export LOBEHUB_CLI_API_KEY="${'${LOBE_API_KEY}'}"`,
`export LOBEHUB_SERVER=${process.env.APP_URL}`,
'export LOBEHUB_CLI_HOME=.lobehub-dev',
'',
].join('\n'),
);
return file;
};
const client = new pg.Client({ connectionString: databaseUrl });
(async () => {
if (!validateApiKeyFormat(TEST_API_KEY.key)) {
throw new Error(`Invalid AGENT_TESTING_API_KEY format: ${TEST_API_KEY.key}`);
}
await client.connect();
const now = new Date().toISOString();
const onboarding = JSON.stringify({ finishedAt: now, version: 1 });
const passwordHash = await bcrypt.hash(TEST_USER.password, 10);
const encryptedApiKey = encryptWithKeyVaultsSecret(TEST_API_KEY.key);
const apiKeyHash = hashApiKey(TEST_API_KEY.key);
await client.query(
`INSERT INTO users (id, email, normalized_email, username, full_name, email_verified, onboarding, created_at, updated_at, last_active_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, $8)
ON CONFLICT (id) DO UPDATE SET onboarding = $7, updated_at = $8`,
[
TEST_USER.id,
TEST_USER.email,
TEST_USER.email.toLowerCase(),
TEST_USER.username,
TEST_USER.fullName,
true,
onboarding,
now,
],
);
await client.query(
`INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $6)
ON CONFLICT DO NOTHING`,
[
'agent_testing_account_001',
TEST_USER.id,
TEST_USER.email,
'credential',
passwordHash,
now,
],
);
await client.query(
`INSERT INTO api_keys (id, name, key, key_hash, enabled, expires_at, user_id, workspace_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NULL, $6, NULL, $7, $7)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
key = EXCLUDED.key,
key_hash = EXCLUDED.key_hash,
enabled = EXCLUDED.enabled,
expires_at = NULL,
updated_at = EXCLUDED.updated_at`,
[
TEST_API_KEY.id,
TEST_API_KEY.name,
encryptedApiKey,
apiKeyHash,
true,
TEST_USER.id,
now,
],
);
const cliEnvFile = writeCliEnvFile();
console.log('seeded baseline user:');
console.log(` email: ${TEST_USER.email}`);
console.log(` password: ${TEST_USER.password}`);
console.log('seeded baseline API key:');
console.log(` LOBE_API_KEY: ${TEST_API_KEY.key}`);
console.log(` CLI env: ${cliEnvFile}`);
})()
.finally(() => client.end())
.catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
cmd_status() {
apply_env
echo "agent-testing local dev env:"
note "APP_URL=$APP_URL"
note "DATABASE_URL=$DATABASE_URL"
note "PORT=$PORT"
note "QSTASH_URL=$QSTASH_URL"
if command -v docker > /dev/null 2>&1; then
ok "docker CLI available"
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
ok "managed Postgres running: $DB_CONTAINER"
else
note "managed Postgres is not running: $DB_CONTAINER"
fi
else
bad "docker CLI is not available"
fi
}
cmd_qstash() {
apply_env
cd "$REPO_ROOT"
note "starting local QStash dev server at $QSTASH_URL"
note "keep this process running while testing workflow paths"
exec pnpm run qstash -- -port "$QSTASH_DEV_PORT"
}
cmd_dev_next() {
apply_env
cd "$REPO_ROOT"
exec pnpm run dev:next
}
cmd_dev() {
apply_env
cd "$REPO_ROOT"
exec bun run dev
}
cmd_clean_db() {
require_docker
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
docker stop "$DB_CONTAINER" > /dev/null
fi
if docker ps -a --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
docker rm "$DB_CONTAINER" > /dev/null
ok "removed Postgres container: $DB_CONTAINER"
else
note "Postgres container not found: $DB_CONTAINER"
fi
}
usage() {
sed -n '3,24p' "$0" >&2
}
COMMAND="${1:-status}"
case "$COMMAND" in
help|-h|--help) usage; exit 0 ;;
*) guard_no_root_env ;;
esac
case "$COMMAND" in
env) print_env ;;
write) shift; write_env "${1:-}" ;;
setup-db)
start_db
migrate_db
;;
migrate) migrate_db ;;
seed-user) seed_user ;;
qstash) cmd_qstash ;;
dev-next) cmd_dev_next ;;
dev) cmd_dev ;;
clean-db) cmd_clean_db ;;
status) cmd_status ;;
*)
usage
exit 2
;;
esac
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# record-gif.sh — capture a frame sequence via agent-browser (CDP) and
# synthesize a GIF for embedding in a test report.
#
# Use this whenever the asserted behavior is about CHANGE OVER TIME —
# streaming output, a ticking timer, loading states, animations. A static
# screenshot cannot prove those; a GIF can. Cloud-portable: frames come from
# CDP rendering, no OS-level screen capture.
#
# Usage:
# record-gif.sh <output.gif> <duration_seconds> [fps]
#
# AB_TARGET="--cdp 9222" # Electron (default; CDP_PORT honored)
# AB_TARGET="--session lobehub-dev" # web agent-browser session
# GIF_WIDTH=960 # output width (px), default 960
#
# Requires ffmpeg (`brew install ffmpeg`). Effective fps is capped by
# screenshot latency (~0.3-0.5s per frame); 1-2 fps is the realistic range.
#
# Example — record a 12s run and embed it in the report:
# ./record-gif.sh "$DIR/assets/case2-tray-running.gif" 12 2 &
# GIF_PID=$!
# # ... trigger the streaming behavior ...
# wait $GIF_PID
set -euo pipefail
OUT="${1:?Usage: record-gif.sh <output.gif> <duration_seconds> [fps]}"
DUR="${2:?Usage: record-gif.sh <output.gif> <duration_seconds> [fps]}"
FPS="${3:-2}"
AB_TARGET="${AB_TARGET:---cdp ${CDP_PORT:-9222}}"
GIF_WIDTH="${GIF_WIDTH:-960}"
command -v ffmpeg > /dev/null || {
echo "ffmpeg not found — install with: brew install ffmpeg" >&2
exit 1
}
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
FRAMES=$((DUR * FPS))
INTERVAL=$(python3 -c "print(1 / $FPS)")
for i in $(seq -f '%04g' 1 "$FRAMES"); do
# shellcheck disable=SC2086
agent-browser $AB_TARGET screenshot "$TMP/frame-$i.png" > /dev/null 2>&1 || true
sleep "$INTERVAL"
done
CAPTURED=$(find "$TMP" -name 'frame-*.png' | wc -l | tr -d ' ')
[ "$CAPTURED" -gt 0 ] || {
echo "no frames captured — is the app reachable via $AB_TARGET?" >&2
exit 1
}
ffmpeg -y -loglevel error -framerate "$FPS" -pattern_type glob -i "$TMP/frame-*.png" \
-vf "fps=$FPS,scale=$GIF_WIDTH:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" \
"$OUT"
echo "$OUT ($CAPTURED frames @ ${FPS}fps)"
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# report-init.sh — scaffold a structured test report under .records/reports/.
#
# Format spec and evidence rules: ../references/report.md
#
# Usage:
# report-init.sh <slug> [title]
#
# Prints the report directory path (capture it: DIR=$(report-init.sh my-test)).
set -euo pipefail
SLUG="${1:?Usage: report-init.sh <slug> [title]}"
TITLE="${2:-$SLUG}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
TS="$(date +%Y%m%d-%H%M%S)"
DIR="$REPO_ROOT/.records/reports/$TS-$SLUG"
mkdir -p "$DIR/assets"
BRANCH=$(git -C "$REPO_ROOT" branch --show-current 2> /dev/null || echo "unknown")
COMMIT=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2> /dev/null || echo "unknown")
DATE_HUMAN=$(date '+%Y-%m-%d %H:%M')
DATE_ISO=$(date '+%Y-%m-%dT%H:%M:%S%z')
cat > "$DIR/report.md" << EOF
# 测试报告:$TITLE
## 范围
<!-- 测试目标 / 变更范围 / 重点风险 -->
- 分支:\`$BRANCH\`
- 当前提交:\`$COMMIT\`
- 日期:$DATE_HUMAN
- 表面:<!-- CLI / Electron + CDP / Web / Bot:<platform> -->
- 测试页 / 入口:<!-- e.g. /settings or http://localhost:3010 -->
- 重点:<!-- 本轮最关心的体验、功能或回归点 -->
## 用例
| # | 用例 | 结果 | 关键现象 | 证据 |
| - | ---- | ---- | -------- | ---- |
| 1 | | 待测 | | ![用例 1](assets/case1.png) |
## 结论
整体结论:\`pending\`。
<!-- 用 1-2 段概括用户最需要知道的结果;失败和阻塞必须明确说明影响。 -->
仍需处理 / 跟进:
- <!-- TODO -->
## 本轮验证
<!-- 如有自动化或命令行验证,保留精简命令与结果;没有则写“未运行额外自动化验证”。 -->
\`\`\`bash
# command
\`\`\`
结果:
- <!-- TODO -->
## 评分
- 通过:0
- 失败:0
- 阻塞:0
- 评分:— / 100
EOF
cat > "$DIR/result.json" << EOF
{
"title": "$TITLE",
"createdAt": "$DATE_ISO",
"branch": "$BRANCH",
"commit": "$COMMIT",
"surfaces": [],
"cases": [],
"summary": { "total": 0, "passed": 0, "failed": 0, "blocked": 0, "verdict": "pending" }
}
EOF
echo "$DIR"
+553
View File
@@ -0,0 +1,553 @@
#!/usr/bin/env bash
# setup-auth.sh — one-stop auth setup & check for local agent testing.
#
# Auth is the gate for all automated testing: prepare it BEFORE writing any
# test step. Background and failure modes: ../references/auth.md
#
# Usage:
# setup-auth.sh status # check server + CLI + web + Electron readiness
# setup-auth.sh status --surface web # check only the Web surface gate
# setup-auth.sh cli-seed # configure CLI API-key auth from seeded local env
# setup-auth.sh cli # interactive CLI device-code login (run by a human)
# setup-auth.sh open-chrome # open SERVER_URL in Chrome and show DevTools
# setup-auth.sh web-seed # sign in seeded user and inject cookies automatically
# setup-auth.sh web # stdin = Cookie header -> inject into agent-browser session
# setup-auth.sh web-verify # live-check the agent-browser session is authenticated
#
# Env:
# SERVER_URL (default from test-env.sh) dev server under test
# SESSION (default lobehub-dev) agent-browser session name
# AUTH_DIR (default ~/.lobehub-agent-testing) where web state is persisted
# SEED_EMAIL / SEED_PASSWORD seeded better-auth login
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
workspace_root_for_port() {
local root="$REPO_ROOT"
local name
name="$(basename "$root")"
if [[ "$name" == "lobehub" ]]; then
local parent
parent="$(cd "$root/.." && pwd)"
local parent_name
parent_name="$(basename "$parent")"
if [[ "$parent_name" == lobehub-cloud* ]]; then
root="$parent"
fi
fi
printf '%s\n' "$root"
}
default_server_url() {
local env_resolver resolved
env_resolver="$(dirname "${BASH_SOURCE[0]}")/test-env.sh"
if [[ -x "$env_resolver" ]]; then
resolved="$("$env_resolver" --value SERVER_URL 2> /dev/null || true)"
if [[ -n "$resolved" ]]; then
printf '%s\n' "$resolved"
return 0
fi
fi
local root name suffix port
root="$(workspace_root_for_port)"
name="$(basename "$root")"
case "$name" in
lobehub-cloud)
port=3020
;;
lobehub-cloud-*)
suffix="${name#lobehub-cloud-}"
if [[ "$suffix" =~ ^[0-9]+$ ]]; then
port=$((3020 + 10#$suffix))
else
port=3010
fi
;;
*)
port=3010
;;
esac
printf 'http://localhost:%s\n' "$port"
}
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"
CLI_HOME_NAME="${LOBEHUB_CLI_HOME:-.lobehub-dev}"
CLI_HOME="$HOME/${CLI_HOME_NAME#/}"
CLI_CREDENTIALS_FILE="$CLI_HOME/credentials.json"
SEED_EMAIL="${SEED_EMAIL:-agent-testing@lobehub.com}"
SEED_PASSWORD="${SEED_PASSWORD:-TestPassword123!}"
SEED_API_KEY="${SEED_API_KEY:-${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}}"
CLI_ENV_FILE="${CLI_ENV_FILE:-$REPO_ROOT/.records/env/agent-testing-cli.env}"
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; }
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; }
note() { printf ' %s\n' "$1"; }
usage() {
cat << EOF
Usage:
$0 status [--surface all|cli|web|electron]
$0 cli-seed
$0 cli
$0 open-chrome [--dry-run]
$0 web-seed
$0 web
$0 web-verify
Env:
SERVER_URL=$SERVER_URL
SESSION=$SESSION
AUTH_DIR=$AUTH_DIR
SEED_EMAIL=$SEED_EMAIL
CLI_HOME=$CLI_HOME
EOF
}
check_server() {
local code
code=$(curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/" 2> /dev/null || true)
if [[ "$code" =~ ^[23] ]]; then
ok "dev server reachable at $SERVER_URL"
else
bad "dev server NOT reachable at $SERVER_URL (http_code='$code')"
note "start it: pnpm run dev:next (see references/dev-server.md)"
return 1
fi
}
check_cli() {
local api_key="${LOBEHUB_CLI_API_KEY:-${LOBE_API_KEY:-}}"
if [[ -n "$api_key" ]]; then
local body_file code
body_file="$(mktemp)"
code=$(curl -sS -o "$body_file" -w '%{http_code}' \
-H "Authorization: Bearer $api_key" \
"$SERVER_URL/api/v1/users/me?includeCount=0" 2> /dev/null || true)
if [[ "$code" =~ ^[23] ]]; then
rm -f "$body_file"
ok "CLI API-key auth valid for $SERVER_URL"
return 0
fi
bad "CLI API-key auth failed for $SERVER_URL (http_code='$code')"
note "seed the local API key first:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
note "source $CLI_ENV_FILE"
rm -f "$body_file"
return 1
fi
if [[ -f "$CLI_HOME/settings.json" ]] && grep -q "$SERVER_URL" "$CLI_HOME/settings.json" && [[ -f "$CLI_CREDENTIALS_FILE" ]]; then
ok "CLI device-code credentials configured for $SERVER_URL (creds: $CLI_HOME)"
else
bad "CLI not logged in to $SERVER_URL"
note "automated path:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user && source $CLI_ENV_FILE && $0 cli-seed"
note "interactive fallback:"
note "cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server $SERVER_URL"
return 1
fi
}
check_web() {
if [[ -f "$STATE_FILE" ]]; then
ok "web auth state saved ($STATE_FILE)"
else
bad "no web auth state for agent-browser"
note "for the seeded local user, run: $0 web-seed"
note "or copy the Cookie header from Chrome DevTools (Network tab), then:"
note "pbpaste | $0 web (see references/auth.md)"
return 1
fi
cmd_web_verify --skip-server-check
}
check_agent_browser() {
if command -v agent-browser > /dev/null 2>&1; then
ok "agent-browser available"
else
bad "agent-browser command not found"
note "install or expose agent-browser before Web/Electron UI testing"
return 1
fi
}
check_electron() {
local cdp_port="${CDP_PORT:-9222}"
if ! curl -s -o /dev/null --max-time 2 "http://localhost:$cdp_port/json/version" 2> /dev/null; then
note "electron: not running (CDP $cdp_port unreachable) — start with electron-dev.sh; check skipped"
return 0
fi
local probe result
probe="$(dirname "${BASH_SOURCE[0]}")/app-probe.sh"
result=$(bash "$probe" auth 2> /dev/null || true)
# agent-browser eval returns the JSON string with escaped quotes — normalize.
result="${result//\\/}"
if [[ "$result" == *'"isSignedIn":true'* ]]; then
ok "electron app signed in ($result)"
else
bad "electron app NOT signed in ($result)"
note "log in once manually inside the app (state persists across restarts)"
return 1
fi
}
cmd_status() {
local surface="all"
while [[ $# -gt 0 ]]; do
case "$1" in
--surface)
if [[ $# -lt 2 ]]; then
echo "--surface requires one of: all, cli, web, electron" >&2
return 2
fi
surface="${2:-}"
shift 2
;;
--surface=*)
surface="${1#*=}"
shift
;;
all|cli|web|electron)
surface="$1"
shift
;;
-h|--help)
usage
return 0
;;
*)
echo "unknown status option: $1" >&2
usage >&2
return 2
;;
esac
done
case "$surface" in
all|cli|web|electron) ;;
"")
echo "--surface requires one of: all, cli, web, electron" >&2
return 2
;;
*)
echo "unknown surface: $surface" >&2
usage >&2
return 2
;;
esac
echo "agent-testing auth status (surface=$surface, SERVER_URL=$SERVER_URL):"
local rc=0
case "$surface" in
all)
check_server || rc=1
check_cli || rc=1
check_web || rc=1
check_electron || rc=1
;;
cli)
check_server || rc=1
check_cli || rc=1
;;
web)
check_server || rc=1
check_web || rc=1
;;
electron)
check_electron || rc=1
;;
esac
if [[ $rc -eq 0 ]]; then
echo "$surface auth green — safe to start automated testing on this surface."
else
echo "$surface auth NOT ready — fix the ✘ items before writing any test step."
fi
return $rc
}
cmd_cli() {
echo "Starting CLI device-code login against $SERVER_URL ..."
echo "(opens a browser authorization — must be run by a human in a terminal)"
cd "$REPO_ROOT/apps/cli"
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server "$SERVER_URL"
}
write_cli_seed_env() {
mkdir -p "$(dirname "$CLI_ENV_FILE")"
cat > "$CLI_ENV_FILE" << EOF
# Source this file before running LobeHub CLI agent tests.
# Generated by setup-auth.sh cli-seed
export LOBE_API_KEY=$SEED_API_KEY
export LOBEHUB_CLI_API_KEY="\${LOBE_API_KEY}"
export LOBEHUB_SERVER=$SERVER_URL
export LOBEHUB_CLI_HOME=.lobehub-dev
EOF
}
write_cli_settings() {
mkdir -p "$CLI_HOME"
python3 - "$CLI_HOME/settings.json" "$SERVER_URL" << 'PY'
import json
import os
import sys
path, server_url = sys.argv[1], sys.argv[2]
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
json.dump({"serverUrl": server_url}, f, indent=2)
f.write("\n")
os.chmod(path, 0o600)
PY
}
cmd_cli_seed() {
check_server || return 1
write_cli_seed_env
write_cli_settings
ok "wrote CLI seed env: $CLI_ENV_FILE"
note "source it before CLI commands: source $CLI_ENV_FILE"
note "settings saved at: $CLI_HOME/settings.json"
LOBE_API_KEY="$SEED_API_KEY" LOBEHUB_CLI_API_KEY="$SEED_API_KEY" check_cli
}
cmd_open_chrome() {
local mode="${1:-}"
if [[ "$mode" != "" && "$mode" != "--dry-run" ]]; then
echo "unknown open-chrome option: $mode" >&2
usage >&2
return 2
fi
if [[ "$mode" == "--dry-run" ]]; then
echo "would open Google Chrome at $SERVER_URL/"
echo "would press Cmd+Option+I to open DevTools"
echo "would open DevTools command menu and run 'Show Network'"
return 0
fi
if [[ "$(uname -s)" != "Darwin" ]]; then
bad "open-chrome is macOS-only"
note "open $SERVER_URL/ in your browser and open DevTools manually"
return 1
fi
if ! command -v osascript > /dev/null 2>&1; then
bad "osascript not found"
note "open $SERVER_URL/ in Chrome and press Cmd+Option+I manually"
return 1
fi
SERVER_URL="$SERVER_URL" osascript << 'OSA'
set targetUrl to (system attribute "SERVER_URL") & "/"
tell application "Google Chrome"
activate
if (count of windows) = 0 then
make new window
end if
tell front window to make new tab with properties {URL:targetUrl}
end tell
delay 1
tell application "System Events"
tell process "Google Chrome"
set frontmost to true
keystroke "i" using {command down, option down}
delay 1
keystroke "p" using {command down, shift down}
delay 0.2
keystroke "Show Network"
key code 36
end tell
end tell
OSA
ok "opened Chrome at $SERVER_URL/ and requested DevTools Network panel"
}
cookie_header_from_jar() {
local jar="$1"
awk '
BEGIN { first = 1 }
/^$/ { next }
/^#/ {
if ($0 !~ /^#HttpOnly_/) next
sub(/^#HttpOnly_/, "")
}
NF >= 7 {
if (!first) printf "; "
printf "%s=%s", $6, $7
first = 0
}
END {
if (!first) printf "\n"
}
' "$jar"
}
# Build a Playwright storageState file from a raw Cookie header on stdin,
# keeping only the better-auth cookies. See references/auth.md for why the
# header must come from a Network request (HttpOnly) and why httpOnly=false.
cmd_web() {
mkdir -p "$AUTH_DIR"
local raw
raw="$(cat)"
COOKIE_INPUT="$raw" python3 - "$STATE_FILE" << 'PY'
import json, os, sys, time
raw = os.environ.get("COOKIE_INPUT", "").strip()
cookie_lines = []
for line in raw.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.lower().startswith("cookie:"):
cookie_lines.append(stripped.split(":", 1)[1].strip())
else:
cookie_lines.append(stripped)
raw = "; ".join(cookie_lines)
WANTED = {"better-auth.session_token", "better-auth.session_data", "better-auth.state"}
exp = int(time.time()) + 30 * 24 * 3600 # 30 days
cookies = []
for pair in raw.split(";"):
pair = pair.strip()
if "=" not in pair:
continue
name, _, value = pair.partition("=")
if name not in WANTED:
continue
cookies.append({
"name": name,
"value": value,
"domain": "localhost",
"path": "/",
"expires": exp,
"httpOnly": False,
"secure": False,
"sameSite": "Lax",
})
if not cookies:
sys.stderr.write("no better-auth cookies found in input — paste the raw Cookie header from a Network request\n")
sys.exit(1)
with open(sys.argv[1], "w") as f:
json.dump({"cookies": cookies, "origins": []}, f, indent=2)
print(f"wrote {len(cookies)} cookie(s) to {sys.argv[1]}")
PY
cmd_web_verify
}
cmd_web_seed() {
check_server || return 1
mkdir -p "$AUTH_DIR"
local cookie_jar="$AUTH_DIR/web-seed-cookie.jar"
local response_body="$AUTH_DIR/web-seed-response.json"
local payload code
payload="$(
SEED_EMAIL="$SEED_EMAIL" SEED_PASSWORD="$SEED_PASSWORD" python3 - << 'PY'
import json
import os
print(json.dumps({
"callbackURL": "/",
"email": os.environ["SEED_EMAIL"],
"password": os.environ["SEED_PASSWORD"],
}))
PY
)"
code=$(curl -sS -o "$response_body" -w '%{http_code}' \
-c "$cookie_jar" \
-H 'Content-Type: application/json' \
-X POST "$SERVER_URL/api/auth/sign-in/email" \
--data "$payload" 2> /dev/null || true)
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"
return 1
fi
local cookie_header
cookie_header="$(cookie_header_from_jar "$cookie_jar")"
if [[ -z "$cookie_header" ]]; then
bad "seed sign-in succeeded but no cookies were written to $cookie_jar"
return 1
fi
printf '%s\n' "$cookie_header" | cmd_web
}
cmd_web_verify() {
local skip_server_check="${1:-}"
if [[ "$skip_server_check" != "--skip-server-check" ]]; then
check_server || return 1
fi
if [[ ! -f "$STATE_FILE" ]]; then
bad "no web auth state for agent-browser"
note "for the seeded local user, run: $0 web-seed"
note "or copy the Cookie header from Chrome DevTools (Network tab), then:"
note "pbpaste | $0 web"
return 1
fi
check_agent_browser || return 1
if ! agent-browser --session "$SESSION" state load "$STATE_FILE" > /dev/null; then
bad "failed to load web auth state into agent-browser session '$SESSION'"
return 1
fi
if ! agent-browser --session "$SESSION" open "$SERVER_URL/" > /dev/null; then
bad "failed to open $SERVER_URL in agent-browser session '$SESSION'"
return 1
fi
local url
url=$(agent-browser --session "$SESSION" get url 2> /dev/null || true)
if [[ -z "$url" ]]; then
bad "agent-browser session '$SESSION' did not report a current URL"
return 1
fi
if [[ "$url" == *"/signin"* || "$url" == *"/login"* ]]; then
bad "agent-browser session '$SESSION' NOT authenticated (landed on $url)"
note "re-copy the Cookie header and re-run: pbpaste | $0 web"
return 1
fi
ok "agent-browser session '$SESSION' authenticated (at $url)"
}
case "${1:-status}" in
status)
shift || true
cmd_status "$@"
;;
cli-seed) cmd_cli_seed ;;
cli) cmd_cli ;;
open-chrome)
shift || true
cmd_open_chrome "$@"
;;
web-seed) cmd_web_seed ;;
web) cmd_web ;;
web-verify) cmd_web_verify ;;
-h|--help) usage ;;
*)
echo "Usage: $0 {status|cli-seed|cli|open-chrome|web-seed|web|web-verify}" >&2
exit 2
;;
esac
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
# Smoke tests for setup-auth.sh. Uses a temporary agent-browser stub and local
# HTTP server, so it does not need real browser auth.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$SCRIPT_DIR/setup-auth.sh"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_contains() {
local file="$1"
local text="$2"
grep -Fq "$text" "$file" || fail "expected '$text' in $file"
}
tmp_dir="$(mktemp -d)"
server_pid=""
cleanup() {
if [[ -n "$server_pid" ]]; then
kill "$server_pid" > /dev/null 2>&1 || true
wait "$server_pid" > /dev/null 2>&1 || true
fi
rm -rf "$tmp_dir"
}
trap cleanup EXIT
export HOME="$tmp_dir/home"
port="$(python3 - << 'PY'
import socket
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
print(sock.getsockname()[1])
sock.close()
PY
)"
python3 - "$port" << 'PY' > "$tmp_dir/http.log" 2>&1 &
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import sys
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/api/v1/users/me"):
if self.headers.get("authorization") != "Bearer sk-lh-agenttesting0001":
self.send_response(401)
self.end_headers()
self.wfile.write(b'{"success":false}')
return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"success":true,"data":{"id":"user_agent_testing_001"}}')
return
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
def do_POST(self):
length = int(self.headers.get("content-length") or "0")
if length:
self.rfile.read(length)
if self.path != "/api/auth/sign-in/email":
self.send_response(404)
self.end_headers()
return
self.send_response(200)
self.send_header(
"Set-Cookie",
"better-auth.session_token=seed.token; Path=/; HttpOnly; SameSite=Lax",
)
self.send_header(
"Set-Cookie",
"better-auth.session_data=seed.data; Path=/; HttpOnly; SameSite=Lax",
)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"ok":true}')
def log_message(self, format, *args):
return
ThreadingHTTPServer(("localhost", int(sys.argv[1])), Handler).serve_forever()
PY
server_pid="$!"
server_url="http://localhost:$port"
for _ in {1..50}; do
if curl -s -o /dev/null "$server_url/"; then
break
fi
sleep 0.1
done
curl -s -o /dev/null "$server_url/" || fail "test HTTP server did not start"
mkdir -p "$tmp_dir/bin" "$tmp_dir/auth"
cat > "$tmp_dir/bin/agent-browser" << 'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "--session" ]]; then
shift 2
fi
case "${1:-}" in
state)
[[ "${2:-}" == "load" ]] || exit 2
[[ -f "${3:-}" ]] || exit 1
;;
open)
printf '%s\n' "${2:-}" > "${AGENT_BROWSER_URL_FILE:?}"
;;
get)
[[ "${2:-}" == "url" ]] || exit 2
cat "${AGENT_BROWSER_URL_FILE:?}"
;;
*)
echo "unexpected agent-browser command: $*" >&2
exit 2
;;
esac
SH
chmod +x "$tmp_dir/bin/agent-browser"
export PATH="$tmp_dir/bin:$PATH"
export AUTH_DIR="$tmp_dir/auth"
export SESSION="setup-auth-test"
export SERVER_URL="$server_url"
export AGENT_BROWSER_URL_FILE="$tmp_dir/current-url"
cookie_header="Cookie: foo=bar; better-auth.session_token=test.token; better-auth.session_data=encoded%3D; theme=dark"
printf '%s\n' "$cookie_header" | "$SCRIPT" web > "$tmp_dir/web.out"
python3 - "$AUTH_DIR/web-state.json" << 'PY'
import json, sys
with open(sys.argv[1]) as f:
state = json.load(f)
names = {cookie["name"] for cookie in state["cookies"]}
expected = {"better-auth.session_token", "better-auth.session_data"}
if names != expected:
raise SystemExit(f"unexpected cookies: {sorted(names)}")
PY
"$SCRIPT" web-seed > "$tmp_dir/web-seed.out"
python3 - "$AUTH_DIR/web-state.json" << 'PY'
import json, sys
with open(sys.argv[1]) as f:
state = json.load(f)
values = {cookie["name"]: cookie["value"] for cookie in state["cookies"]}
expected = {
"better-auth.session_token": "seed.token",
"better-auth.session_data": "seed.data",
}
if values != expected:
raise SystemExit(f"unexpected seeded cookies: {values}")
PY
"$SCRIPT" status --surface web > "$tmp_dir/status.out"
assert_contains "$tmp_dir/status.out" "surface=web"
assert_contains "$tmp_dir/status.out" "web auth green"
"$SCRIPT" cli-seed > "$tmp_dir/cli-seed.out"
assert_contains "$tmp_dir/cli-seed.out" "CLI API-key auth valid"
assert_contains "$tmp_dir/cli-seed.out" "settings saved at: $HOME/.lobehub-dev/settings.json"
if "$SCRIPT" status --surface cli > "$tmp_dir/cli-no-env.out"; then
fail "cli status without API key unexpectedly passed"
fi
assert_contains "$tmp_dir/cli-no-env.out" "CLI not logged in"
LOBEHUB_CLI_API_KEY=sk-lh-agenttesting0001 "$SCRIPT" status --surface cli > "$tmp_dir/cli-status.out"
assert_contains "$tmp_dir/cli-status.out" "CLI API-key auth valid"
assert_contains "$tmp_dir/cli-status.out" "cli auth green"
if printf 'foo=bar\n' | "$SCRIPT" web > "$tmp_dir/invalid.out" 2> "$tmp_dir/invalid.err"; then
fail "invalid cookie unexpectedly passed"
fi
assert_contains "$tmp_dir/invalid.err" "no better-auth cookies found"
echo "setup-auth tests passed"
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env bash
# Print the resolved local test environment for agent-testing.
#
# This is intentionally read-only. It mirrors scripts/runWithEnv.mts precedence:
# .env -> .env.$NODE_ENV -> .env.local -> .env.$NODE_ENV.local, then shell env.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
NODE_ENV="${NODE_ENV:-development}"
VALUE_APP_URL=""
VALUE_PORT=""
VALUE_SERVER_URL=""
VALUE_AUTH_TRUSTED_ORIGINS=""
VALUE_SPA_PORT=""
VALUE_MOBILE_SPA_PORT=""
VALUE_DESKTOP_PORT=""
SOURCE_APP_URL=""
SOURCE_PORT=""
SOURCE_SERVER_URL=""
SOURCE_AUTH_TRUSTED_ORIGINS=""
SOURCE_SPA_PORT=""
SOURCE_MOBILE_SPA_PORT=""
SOURCE_DESKTOP_PORT=""
LOADED_ENV_FILES=""
keys() {
printf '%s\n' \
APP_URL \
PORT \
SERVER_URL \
AUTH_TRUSTED_ORIGINS \
SPA_PORT \
MOBILE_SPA_PORT \
DESKTOP_PORT
}
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
workspace_root() {
local root="$REPO_ROOT"
local name
name="$(basename "$root")"
if [[ "$name" == "lobehub" ]]; then
local parent parent_name
parent="$(cd "$root/.." && pwd)"
parent_name="$(basename "$parent")"
if [[ "$parent_name" == lobehub-cloud* ]]; then
root="$parent"
fi
fi
printf '%s\n' "$root"
}
workspace_offset() {
local name="$1"
case "$name" in
lobehub-cloud)
printf '0\n'
;;
lobehub-cloud-*)
local suffix="${name#lobehub-cloud-}"
if [[ "$suffix" =~ ^[0-9]+$ ]]; then
printf '%s\n' "$((10#$suffix))"
else
printf '\n'
fi
;;
*)
printf '\n'
;;
esac
}
default_port() {
local base="$1"
local fallback="$2"
local root name offset
root="$(workspace_root)"
name="$(basename "$root")"
offset="$(workspace_offset "$name")"
if [[ -n "$offset" ]]; then
printf '%s\n' "$((base + offset))"
else
printf '%s\n' "$fallback"
fi
}
url_port() {
local url="$1"
local hostport
hostport="${url#*://}"
hostport="${hostport%%/*}"
if [[ "$hostport" == *:* ]]; then
local port="${hostport##*:}"
if [[ "$port" =~ ^[0-9]+$ ]]; then
printf '%s\n' "$port"
return 0
fi
fi
return 1
}
url_origin() {
local url="$1"
local scheme rest hostport
if [[ "$url" == *"://"* ]]; then
scheme="${url%%://*}"
rest="${url#*://}"
hostport="${rest%%/*}"
printf '%s://%s\n' "$scheme" "$hostport"
else
printf '%s\n' "$url"
fi
}
set_value() {
local key="$1"
local value="$2"
local source="$3"
case "$key" in
APP_URL) VALUE_APP_URL="$value"; SOURCE_APP_URL="$source" ;;
PORT) VALUE_PORT="$value"; SOURCE_PORT="$source" ;;
SERVER_URL) VALUE_SERVER_URL="$value"; SOURCE_SERVER_URL="$source" ;;
AUTH_TRUSTED_ORIGINS) VALUE_AUTH_TRUSTED_ORIGINS="$value"; SOURCE_AUTH_TRUSTED_ORIGINS="$source" ;;
SPA_PORT) VALUE_SPA_PORT="$value"; SOURCE_SPA_PORT="$source" ;;
MOBILE_SPA_PORT) VALUE_MOBILE_SPA_PORT="$value"; SOURCE_MOBILE_SPA_PORT="$source" ;;
DESKTOP_PORT) VALUE_DESKTOP_PORT="$value"; SOURCE_DESKTOP_PORT="$source" ;;
esac
}
value_for() {
case "$1" in
APP_URL) printf '%s\n' "$VALUE_APP_URL" ;;
PORT) printf '%s\n' "$VALUE_PORT" ;;
SERVER_URL) printf '%s\n' "$VALUE_SERVER_URL" ;;
AUTH_TRUSTED_ORIGINS) printf '%s\n' "$VALUE_AUTH_TRUSTED_ORIGINS" ;;
SPA_PORT) printf '%s\n' "$VALUE_SPA_PORT" ;;
MOBILE_SPA_PORT) printf '%s\n' "$VALUE_MOBILE_SPA_PORT" ;;
DESKTOP_PORT) printf '%s\n' "$VALUE_DESKTOP_PORT" ;;
esac
}
source_for() {
case "$1" in
APP_URL) printf '%s\n' "$SOURCE_APP_URL" ;;
PORT) printf '%s\n' "$SOURCE_PORT" ;;
SERVER_URL) printf '%s\n' "$SOURCE_SERVER_URL" ;;
AUTH_TRUSTED_ORIGINS) printf '%s\n' "$SOURCE_AUTH_TRUSTED_ORIGINS" ;;
SPA_PORT) printf '%s\n' "$SOURCE_SPA_PORT" ;;
MOBILE_SPA_PORT) printf '%s\n' "$SOURCE_MOBILE_SPA_PORT" ;;
DESKTOP_PORT) printf '%s\n' "$SOURCE_DESKTOP_PORT" ;;
esac
}
is_tracked_key() {
case "$1" in
APP_URL|PORT|SERVER_URL|AUTH_TRUSTED_ORIGINS|SPA_PORT|MOBILE_SPA_PORT|DESKTOP_PORT) return 0 ;;
*) return 1 ;;
esac
}
parse_env_file() {
local file="$1"
local root="$2"
local label="${file#$root/}"
local line key value
[[ -f "$file" ]] || return 0
if [[ -z "$LOADED_ENV_FILES" ]]; then
LOADED_ENV_FILES="$label"
else
LOADED_ENV_FILES="$LOADED_ENV_FILES, $label"
fi
while IFS= read -r line || [[ -n "$line" ]]; do
line="$(trim "$line")"
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ "$line" == export[[:space:]]* ]]; then
line="$(trim "${line#export}")"
fi
[[ "$line" == *=* ]] || continue
key="$(trim "${line%%=*}")"
value="$(trim "${line#*=}")"
is_tracked_key "$key" || continue
if [[ "$value" == \"*\" && "$value" == *\" && ${#value} -ge 2 ]]; then
value="${value:1:${#value}-2}"
elif [[ "$value" == \'* && "$value" == *\' && ${#value} -ge 2 ]]; then
value="${value:1:${#value}-2}"
fi
set_value "$key" "$value" "$label"
done < "$file"
}
apply_env_files() {
local root="$1"
parse_env_file "$root/.env" "$root"
parse_env_file "$root/.env.$NODE_ENV" "$root"
parse_env_file "$root/.env.local" "$root"
parse_env_file "$root/.env.$NODE_ENV.local" "$root"
}
apply_shell_overrides() {
local key value
while IFS= read -r key; do
if [[ -n "${!key+x}" ]]; then
value="${!key}"
set_value "$key" "$value" "shell"
fi
done < <(keys)
}
resolve_defaults() {
local app_port spa_port mobile_spa_port desktop_port
app_port="$(default_port 3020 3010)"
spa_port="$(default_port 9800 9876)"
mobile_spa_port="$(default_port 3810 3012)"
desktop_port="$(default_port 3030 3015)"
if [[ -z "$VALUE_APP_URL" ]]; then
set_value APP_URL "http://localhost:$app_port" "inferred"
fi
if [[ -z "$VALUE_PORT" ]]; then
if app_port="$(url_port "$VALUE_APP_URL")"; then
set_value PORT "$app_port" "inferred from APP_URL"
else
set_value PORT "$(default_port 3020 3010)" "inferred"
fi
fi
if [[ -z "$VALUE_SERVER_URL" ]]; then
set_value SERVER_URL "$VALUE_APP_URL" "from APP_URL"
fi
if [[ -z "$VALUE_SPA_PORT" ]]; then
set_value SPA_PORT "$spa_port" "inferred"
fi
if [[ -z "$VALUE_MOBILE_SPA_PORT" ]]; then
set_value MOBILE_SPA_PORT "$mobile_spa_port" "inferred"
fi
if [[ -z "$VALUE_DESKTOP_PORT" ]]; then
set_value DESKTOP_PORT "$desktop_port" "inferred"
fi
if [[ -z "$VALUE_AUTH_TRUSTED_ORIGINS" ]]; then
set_value AUTH_TRUSTED_ORIGINS "$(url_origin "$VALUE_APP_URL"),http://localhost:$VALUE_SPA_PORT" "inferred"
fi
}
contains_origin() {
local list="$1"
local expected="$2"
local item
IFS=',' read -r -a items <<< "$list"
for item in "${items[@]}"; do
item="$(trim "$item")"
[[ "$item" == "$expected" ]] && return 0
done
return 1
}
print_exports() {
local key value
while IFS= read -r key; do
value="$(value_for "$key")"
printf 'export %s=%q\n' "$key" "$value"
done < <(keys)
}
print_value() {
local key="$1"
if ! is_tracked_key "$key"; then
echo "unknown key: $key" >&2
exit 2
fi
value_for "$key"
}
print_human() {
local root="$1"
local key value source
echo "agent-testing test env:"
printf ' workspace: %s\n' "$root"
printf ' NODE_ENV: %s\n' "$NODE_ENV"
printf ' env files: %s\n' "${LOADED_ENV_FILES:-none}"
echo
echo "resolved values:"
while IFS= read -r key; do
value="$(value_for "$key")"
source="$(source_for "$key")"
printf ' %-22s %s (%s)\n' "$key=$value" "" "$source"
done < <(keys)
echo
echo "checks:"
local app_origin spa_origin app_port
app_origin="$(url_origin "$VALUE_APP_URL")"
spa_origin="http://localhost:$VALUE_SPA_PORT"
if app_port="$(url_port "$VALUE_APP_URL")" && [[ "$app_port" == "$VALUE_PORT" ]]; then
printf ' OK PORT matches APP_URL (%s)\n' "$VALUE_PORT"
else
printf ' WARN PORT (%s) does not match APP_URL (%s)\n' "$VALUE_PORT" "$VALUE_APP_URL"
fi
if contains_origin "$VALUE_AUTH_TRUSTED_ORIGINS" "$app_origin"; then
printf ' OK AUTH_TRUSTED_ORIGINS includes %s\n' "$app_origin"
else
printf ' WARN AUTH_TRUSTED_ORIGINS is missing %s\n' "$app_origin"
fi
if contains_origin "$VALUE_AUTH_TRUSTED_ORIGINS" "$spa_origin"; then
printf ' OK AUTH_TRUSTED_ORIGINS includes %s\n' "$spa_origin"
else
printf ' WARN AUTH_TRUSTED_ORIGINS is missing %s\n' "$spa_origin"
fi
}
usage() {
cat << EOF
Usage:
$0 # print resolved test environment
$0 --exports # print source-able export lines
$0 --value KEY # print one resolved value
Tracked keys:
APP_URL PORT SERVER_URL AUTH_TRUSTED_ORIGINS SPA_PORT MOBILE_SPA_PORT DESKTOP_PORT
EOF
}
ROOT="$(workspace_root)"
apply_env_files "$ROOT"
apply_shell_overrides
resolve_defaults
case "${1:-}" in
"")
print_human "$ROOT"
;;
--exports)
print_exports
;;
--value)
print_value "${2:-}"
;;
-h|--help)
usage
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
esac
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Smoke tests for test-env.sh.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_eq() {
local actual="$1"
local expected="$2"
[[ "$actual" == "$expected" ]] || fail "expected '$expected', got '$actual'"
}
assert_contains() {
local file="$1"
local text="$2"
grep -Fq "$text" "$file" || fail "expected '$text' in $file"
}
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
mkdir -p "$tmp_dir/lobehub-cloud-1/.agents/skills" "$tmp_dir/lobehub/.agents/skills"
ln -s "$SCRIPT_DIR/.." "$tmp_dir/lobehub-cloud-1/.agents/skills/agent-testing"
ln -s "$SCRIPT_DIR/.." "$tmp_dir/lobehub/.agents/skills/agent-testing"
cloud_script="$tmp_dir/lobehub-cloud-1/.agents/skills/agent-testing/scripts/test-env.sh"
oss_script="$tmp_dir/lobehub/.agents/skills/agent-testing/scripts/test-env.sh"
assert_eq "$("$cloud_script" --value SERVER_URL)" "http://localhost:3021"
assert_eq "$("$cloud_script" --value SPA_PORT)" "9801"
assert_eq "$("$cloud_script" --value MOBILE_SPA_PORT)" "3811"
assert_eq "$("$cloud_script" --value DESKTOP_PORT)" "3031"
assert_eq "$("$oss_script" --value SERVER_URL)" "http://localhost:3010"
cat > "$tmp_dir/lobehub-cloud-1/.env" << 'EOF'
APP_URL=http://localhost:4123
PORT=4123
AUTH_TRUSTED_ORIGINS=http://localhost:4123,http://localhost:9823
SPA_PORT=9823
MOBILE_SPA_PORT=3823
DESKTOP_PORT=3043
EOF
assert_eq "$("$cloud_script" --value SERVER_URL)" "http://localhost:4123"
assert_eq "$("$cloud_script" --value SPA_PORT)" "9823"
"$cloud_script" --exports > "$tmp_dir/exports.out"
assert_contains "$tmp_dir/exports.out" "export APP_URL=http://localhost:4123"
assert_contains "$tmp_dir/exports.out" "export SERVER_URL=http://localhost:4123"
assert_contains "$tmp_dir/exports.out" "export AUTH_TRUSTED_ORIGINS=http://localhost:4123\\,http://localhost:9823"
echo "test-env tests passed"
+154
View File
@@ -0,0 +1,154 @@
# Electron (LobeHub Desktop) UI Testing
Default surface for verifying **pure frontend changes** (components, store logic, styles, interactions) in the primary product shape. Drives the Electron renderer over CDP with `agent-browser` — see [../references/agent-browser.md](../references/agent-browser.md) for the full command reference.
**Auth**: the Electron app keeps its own persistent login state — log in once manually in the app; sessions survive restarts. Run `../scripts/setup-auth.sh status` before testing (see [../references/auth.md](../references/auth.md)).
**Linux / headless (cloud)**: Electron itself runs on Linux, but it has no true headless mode — it needs a display server. In a headless environment wrap the launch with `xvfb-run` (virtual framebuffer). Everything CDP-based keeps working under Xvfb: the `agent-browser --cdp 9222` connection, snapshots, eval, and `agent-browser screenshot` (captured from the renderer via CDP, not the OS screen). What does NOT work on Linux: `capture-app-window.sh` (macOS `screencapture`), osascript, and the ffmpeg recording scripts in their current form.
### Setup / Teardown
Use the `electron-dev.sh` script to manage the Electron dev environment. It handles process lifecycle, waits for SPA readiness, and reliably kills all child processes (main + helpers + vite).
```bash
SCRIPT=".agents/skills/agent-testing/scripts/electron-dev.sh"
# Start Electron dev with CDP (idempotent — skips if already running)
$SCRIPT start
# Check if Electron is running and CDP is reachable
$SCRIPT status
# Kill all Electron-related processes (main + helper + vite)
$SCRIPT stop
# Force fresh restart
$SCRIPT restart
```
After `start` succeeds, connect with: `agent-browser --cdp 9222 snapshot -i`
**Always run `$SCRIPT stop` when done testing**`pkill -f "Electron"` alone won't catch all helper processes.
#### Environment Variables
| Variable | Default | Description |
| ----------------- | ----------------------- | ---------------------------------------- |
| `CDP_PORT` | `9222` | Chrome DevTools Protocol port |
| `ELECTRON_LOG` | `/tmp/electron-dev.log` | Electron process log |
| `ELECTRON_WAIT_S` | `60` | Max seconds to wait for Electron process |
| `RENDERER_WAIT_S` | `60` | Max seconds to wait for SPA to load |
### LobeHub Probes & Quick Navigation
`scripts/app-probe.sh` is the standard fast path into app state — **use it
instead of hand-rolling `__LOBE_STORES` eval snippets** for these common needs:
```bash
PROBE=".agents/skills/agent-testing/scripts/app-probe.sh"
$PROBE auth # login check (Step 0.3) → { isSignedIn, userId }
$PROBE route # current SPA route
$PROBE ops # running chat operations (type / startTime)
$PROBE goto /settings # jump the SPA straight to a route (full reload)
$PROBE errors-install # install console.error interceptor
$PROBE errors # dump captured errors
```
`goto` lets a test enter the state under test directly instead of clicking
through the UI. Common desktop routes:
| Route | Where it lands |
| ----------------------------- | ------------------------------------ |
| `/` | Home (has a chat input) |
| `/agent/<agentId>` | Agent conversation (latest topic) |
| `/agent/<agentId>/<topicId>` | Specific topic in a conversation |
| `/task` · `/task/<taskId>` | Task list / task detail |
| `/page` | Documents (文稿) |
| `/settings` | Settings |
| `/community` | Discover / community |
Targets default to Electron (`--cdp 9222`); set `AB_TARGET="--session <name>"`
for web sessions. For deeper or one-off state inspection, fall back to raw
eval below.
### LobeHub-Specific Patterns
#### Access Zustand Store State
```bash
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
(function() {
var chat = window.__LOBE_STORES.chat();
var ops = Object.values(chat.operations);
return JSON.stringify({
ops: ops.map(function(o) { return { type: o.type, status: o.status }; }),
activeAgent: chat.activeAgentId,
activeTopic: chat.activeTopicId,
});
})()
EVALEOF
```
#### Find and Use the Chat Input
```bash
# The chat input is contenteditable — must use -C flag
agent-browser --cdp 9222 snapshot -i -C 2>&1 | grep "editable"
agent-browser --cdp 9222 click @e48
agent-browser --cdp 9222 type @e48 "Hello world"
agent-browser --cdp 9222 press Enter
```
#### Wait for Agent to Complete
```bash
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
(function() {
var chat = window.__LOBE_STORES.chat();
var ops = Object.values(chat.operations);
var running = ops.filter(function(o) { return o.status === 'running'; });
return running.length === 0 ? 'done' : 'running: ' + running.length;
})()
EVALEOF
```
#### Install Error Interceptor
```bash
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
(function() {
window.__CAPTURED_ERRORS = [];
var orig = console.error;
console.error = function() {
var msg = Array.from(arguments).map(function(a) {
if (a instanceof Error) return a.message;
return typeof a === 'object' ? JSON.stringify(a) : String(a);
}).join(' ');
window.__CAPTURED_ERRORS.push(msg);
orig.apply(console, arguments);
};
return 'installed';
})()
EVALEOF
# Later, check captured errors:
agent-browser --cdp 9222 eval "JSON.stringify(window.__CAPTURED_ERRORS)"
```
## Electron Gotchas
- **Always use `electron-dev.sh stop` to clean up** — `pkill -f "Electron"` only kills the main process; helper processes (GPU, renderer, network) survive. The script finds and kills all of them via PID matching against the project's electron binary path.
- **`npx electron-vite dev` must run from `apps/desktop/`** — running from project root fails silently. The `electron-dev.sh` script handles this automatically.
- **Dev build auto-opens DevTools, which hijacks the CDP target** — `agent-browser --cdp 9222` may attach to the DevTools page (`devtools://…`) instead of the app (`app://renderer/`). Symptom: `get url` returns a `devtools://` URL. Fix: close the DevTools target and reconnect:
```bash
DT_ID=$(curl -s http://localhost:9222/json/list | python3 -c "import json,sys; ts=json.load(sys.stdin); print(next(t['id'] for t in ts if t['type']=='page' and t['url'].startswith('devtools://')))")
curl -s "http://localhost:9222/json/close/$DT_ID" > /dev/null
agent-browser close --all && agent-browser --cdp 9222 get url # expect app://renderer/
```
- **Don't resize the Electron window after load** — resizing triggers full SPA reload
- **Store is at `window.__LOBE_STORES`** not `window.__ZUSTAND_STORES__`
- **Streaming / ticking UI needs GIF evidence** — see `scripts/record-gif.sh`; a static screenshot cannot prove time-based behavior.
+78
View File
@@ -0,0 +1,78 @@
# Web (Full-Stack) Testing
Default surface for **full-stack changes** — a new/changed API plus the UI that
consumes it. The browser is the one surface where network requests and UI state
are observable together, so you can assert both sides of the contract in a
single run.
For pure-frontend changes prefer [electron.md](./electron.md); for
backend-only changes prefer [../cli/index.md](../cli/index.md).
## Prerequisites
- Complete [Step 0.0](../SKILL.md#00-resolve-the-current-test-environment) (resolve ports) and [Step -1](../SKILL.md#step--1--plan-approval-for-non-trivial-tests) (plan approval) first.
- Local dev server running — [../references/dev-server.md](../references/dev-server.md)
- Web auth verified in agent-browser — prefer `setup-auth.sh web-seed`, see [auth decision flow](../references/auth.md#web--decision-flow).
## Option A — agent-browser with seeded auth (recommended)
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
./.agents/skills/agent-testing/scripts/setup-auth.sh web-seed
```
Then drive the verified session:
```bash
SESSION=lobehub-dev
agent-browser --session $SESSION open "$SERVER_URL/"
agent-browser --session $SESSION snapshot -i
# interact via refs — full command reference: ../references/agent-browser.md
```
Use this session as the evidence source. Do not use ordinary Chrome screenshots
or Chrome Network records as proof for Web tests; ordinary Chrome is only a
fallback source for copying cookies into agent-browser when the seeded login is
not available.
### Watch the API while driving the UI
```bash
# After triggering the UI action under test:
agent-browser --session $SESSION network requests --type xhr,fetch
agent-browser --session $SESSION network requests --method POST
# Record a full HAR for the report
agent-browser --session $SESSION network har start
# ... drive the scenario ...
agent-browser --session $SESSION network har stop ./capture.har
```
Assert both layers: the request/response shape (network) and the rendered
result (snapshot/screenshot). Both belong in the report as evidence.
## Option B — real Chrome with remote debugging
For flows that need a real, visible browser (e.g. exercising the login UI
itself):
```bash
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222 \
--user-data-dir=/tmp/chrome-test-profile \
"<URL>" &
sleep 5
agent-browser --cdp 9222 snapshot -i
# Or auto-discover running Chrome with remote debugging
agent-browser --auto-connect snapshot -i
```
## Option C — Debug Proxy (local frontend, production backend)
`bun run dev:spa` prints a **Debug Proxy** URL
(`https://app.lobehub.com/_dangerous_local_dev_proxy?debug-host=…`) that loads
your local Vite SPA inside the online environment — HMR against real server
config. Useful for verifying frontend behavior against production data, **not**
for testing backend changes (the backend is production, not your branch).
+3 -3
View File
@@ -1,6 +1,6 @@
---
name: agent-tracing
description: "Agent tracing CLI for inspecting agent execution snapshots. Use when user mentions 'agent-tracing', 'trace', 'snapshot', wants to debug agent execution, inspect LLM calls, view context engine data, or analyze agent steps. Triggers on agent debugging, trace inspection, or execution analysis tasks."
description: 'Agent tracing CLI for execution snapshots. Use for agent-tracing, traces, snapshots, LLM call inspection, context engine data, agent step analysis, or execution debugging.'
user-invocable: false
---
@@ -216,6 +216,6 @@ When using `--messages`, the output shows three sections (if context engine data
## Integration Points
- **Recording**: `src/server/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
- **Context engine capture**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
- **Recording**: `apps/server/src/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
- **Context engine capture**: `apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
- **Store**: `FileSnapshotStore` reads/writes to `.agent-tracing/` relative to `process.cwd()`
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: builtin-tool
description: Build a new builtin tool package under `packages/builtin-tool-<name>/`. Use when adding a new agent-callable toolset, designing its API surface (manifest / ApiName / Params / State), implementing the Executor + ExecutionRuntime, building the Inspector / Render / Placeholder / Streaming / Intervention / Portal UI, or wiring a tool into the central registries (`packages/builtin-tools/src/{index,identifiers,inspectors,renders,placeholders,streamings,interventions,portals}.ts` and `src/store/tool/slices/builtin/executors/index.ts`). Triggers on "new builtin tool", "add a tool", "tool inspector", "tool render", "tool placeholder", "tool streaming", "tool intervention", "BuiltinToolManifest", "BaseExecutor", "ExecutionRuntime".
description: 'Build LobeHub builtin tool packages. Use when adding agent-callable tools, manifests, executors, runtimes, inspectors, renders, placeholders, streaming, interventions, portals, or tool registries.'
---
# Builtin Tool Authoring Guide
@@ -23,7 +23,7 @@ A builtin tool is a package the agent runtime can call. It ships **five faces**:
| ------------------------------------------------------------------------------------ | --------------------------------------------- |
| Where do files live? What does each face do? Wiring? | [architecture.md](references/architecture.md) |
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | [tool-design.md](references/tool-design.md) |
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui.md](references/ui.md) |
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | [ui/](references/ui/README.md) |
---
@@ -271,7 +271,7 @@ Lists in the same file you may need to touch:
- `defaultToolIds` — added to the agent's tool list by default
- `alwaysOnToolIds` — forced on regardless of user selection (use sparingly)
- `runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `src/server/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
- `runtimeManagedToolIds` — enable state controlled by runtime, not user UI; **must mirror the rules map** in `apps/server/src/modules/Mecha/AgentToolsEngine/index.ts` and `src/helpers/toolEngineering/index.ts`
---
@@ -2,7 +2,7 @@
This doc covers everything that **isn't UI**: the tool's identifier, API surface, manifest, types, system prompt, ExecutionRuntime, and the executor that wires it into the frontend.
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui.md](ui.md).
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see [ui/](ui/README.md).
For where files live and how registries work, see [architecture.md](architecture.md).
---
@@ -156,7 +156,7 @@ export const TaskManifest: BuiltinToolManifest = {
executors: ['client', 'server'],
/* Default human intervention policy for all APIs that don't specify one.
Pair with an Intervention component (see ui.md). */
Pair with an Intervention component (see ui/intervention.md). */
humanIntervention: 'never' | 'always' | { /* extended config */ },
}
```
@@ -1,744 +0,0 @@
# Tool UI Surfaces
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
| Surface | Required? | When the chat shows it | Registered in |
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
The two reference tools to read end-to-end:
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
---
## Tool Render 设计原则(中文草案)
这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。
1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。
2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。
3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args``partialArgs``pluginState`,避免出现空白、跳变或只显示半截参数。
4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `<api>.loading` / `<api>.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}``lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。
5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
8. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。
9. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。
10. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
11. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。
12. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。
13. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox``createStaticStyles``cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。
14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
---
## 0. Shared Style Rules
These apply across every surface.
### 0.1 Use `'use client'` at the top of every component file
Tool surfaces are leaves in the chat tree and must not block server rendering.
### 0.2 Prefer `createStaticStyles + cssVar.*`
Zero-runtime CSS-in-JS — the styles compile once and read CSS variables at runtime.
```tsx
import { createStaticStyles, cssVar } from 'antd-style';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
padding-block: 2px;
padding-inline: 8px;
border-radius: 999px;
color: ${cssVar.colorText};
background: ${cssVar.colorFillTertiary};
`,
}));
```
Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
### 0.3 Use `@lobehub/ui`, not raw `antd`
`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton` all come from `@lobehub/ui`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
Memory note: `@lobehub/ui`'s `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. If you need that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
### 0.4 Always `memo` and set `displayName`
```tsx
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args /* … */ }) => {
/* … */
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
```
### 0.5 Always type with `BuiltinXProps<Args, State>` generics
Don't widen to `any`. The Args generic is the JSON Schema params, the State generic is the executor's `state` field. The two should match `<Name>Params` and `<Name>State` from `types.ts`.
### 0.6 Pull strings from `t('plugin')`
```tsx
const { t } = useTranslation('plugin');
t('builtins.<identifier>.apiName.<api>');
```
Every Inspector should default to `t('builtins.<identifier>.apiName.<api>')` so it shows something while args stream in.
### 0.7 Read store state from `@/store/chat`, not props
Tool surfaces sometimes need cross-cutting state (loading, streaming buffer). Read it inside the component via Zustand selectors, not from props — props only carry args/state/messageId.
---
## 1. Inspector — Header Chip (required)
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
### Props (`BuiltinInspectorProps<Args, State>`)
```ts
interface BuiltinInspectorProps<Arguments = any, State = any> {
apiName: string;
args: Arguments; // final args (only after the assistant stops streaming)
identifier: string;
isArgumentsStreaming?: boolean; // args still arriving
isLoading?: boolean; // args complete, executor running
partialArgs?: Arguments; // partial JSON during streaming
pluginState?: State; // executor's `state` after success
result?: { content: string | null; error?: any };
}
```
### State machine
| Phase | What's available | What to show |
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
### Canonical example — Search
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
```tsx
'use client';
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const query = args?.query || partialArgs?.query || '';
const resultCount = pluginState?.results?.length ?? 0;
const hasResults = resultCount > 0;
if (isArgumentsStreaming && !query) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-web-browsing.apiName.search')}:&nbsp;</span>
{query && <span className={highlightTextStyles.primary}>{query}</span>}
{!isLoading &&
!isArgumentsStreaming &&
pluginState?.results &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
({t('builtins.lobe-web-browsing.inspector.noResults')})
</Text>
))}
</div>
);
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
```
### Inspector rules
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
- **Switch copy by phase.** If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define `<api>.loading` and `<api>.completed` keys and select via `isArgumentsStreaming || isLoading ? loadingKey : completedKey`. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. See `CallSubAgentInspector` for the canonical two-key pattern.
### Inspector registry — `client/Inspector/index.ts`
```ts
import type { BuiltinInspector } from '@lobechat/types';
import { TaskApiName } from '../../types';
import { CreateTaskInspector } from './CreateTask';
import { ListTasksInspector } from './ListTasks';
/* … */
export const TaskInspectors: Record<string, BuiltinInspector> = {
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
/* one entry per ApiName */
};
export { CreateTaskInspector } from './CreateTask';
export { ListTasksInspector } from './ListTasks';
/* re-export each */
```
---
## 2. Render — Rich Result Card (optional)
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
### Props (`BuiltinRenderProps<Args, State, Content>`)
```ts
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
apiName?: string;
args: Arguments; // final params from the LLM
content: Content; // executor's content string (or parsed)
identifier?: string;
messageId: string; // for store lookups
pluginError?: any; // from BuiltinToolResult.error
pluginState?: State; // executor's state
toolCallId?: string;
}
```
### Two patterns
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
```tsx
// client/Render/CrawlSinglePage.tsx
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
import { memo } from 'react';
import PageContent from './PageContent';
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
({ messageId, pluginState, args }) => (
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
),
);
export default CrawlSinglePage;
```
**Pattern B — Folder with subcomponents** (web-browsing Search):
```
client/Render/Search/
├── index.tsx # composes the subcomponents, handles error states
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
├── SearchQuery.tsx # editable query header
└── SearchResult.tsx # result list
```
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
### Error handling in Render
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
```tsx
if (pluginError) {
if (pluginError?.type === 'PluginSettingsInvalid') {
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
}
return (
<Alert
title={pluginError?.message}
type="error"
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
/>
);
}
```
### Render rules
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
### Render registry — `client/Render/index.ts`
```ts
import type { BuiltinRender } from '@lobechat/types';
import { TaskApiName } from '../../types';
import CreateTaskRender from './CreateTask';
import RunTasksRender from './RunTasks';
export const TaskRenders: Record<string, BuiltinRender> = {
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
/* only the APIs with rich result UI — others fall back to text content */
};
export { default as CreateTaskRender } from './CreateTask';
export { default as RunTasksRender } from './RunTasks';
```
### Render display control (rare)
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
---
## 3. Placeholder — Skeleton Between Args and Result (optional)
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
### Props (`BuiltinPlaceholderProps<Args>`)
```ts
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
apiName: string;
args?: T;
identifier: string;
}
```
No `pluginState` — Placeholder lives entirely in the "executing" gap.
### Canonical example — Search Placeholder
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
```tsx
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { SearchIcon } from 'lucide-react';
import { memo } from 'react';
import { useIsMobile } from '@/hooks/useIsMobile';
import { shinyTextStyles } from '@/styles';
const styles = createStaticStyles(({ css, cssVar }) => ({
query: cx(
css`
padding: 4px 8px;
border-radius: 8px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
shinyTextStyles.shinyText,
),
}));
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
const { query } = args || {};
const isMobile = useIsMobile();
return (
<Flexbox gap={8}>
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
<Flexbox horizontal align="center" className={styles.query} gap={8}>
<Icon icon={SearchIcon} />
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
</Flexbox>
<Skeleton.Block active style={{ height: 20, width: 40 }} />
</Flexbox>
<Flexbox horizontal gap={12}>
{[1, 2, 3, 4, 5].map((id) => (
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
))}
</Flexbox>
</Flexbox>
);
});
```
### Placeholder rules
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
### Placeholder registry — `client/Placeholder/index.ts`
```ts
import { WebBrowsingApiName } from '../../types';
import CrawlMultiPages from './CrawlMultiPages';
import CrawlSinglePage from './CrawlSinglePage';
import { Search } from './Search';
export const WebBrowsingPlaceholders = {
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
[WebBrowsingApiName.search]: Search,
};
export { CrawlMultiPages, CrawlSinglePage, Search };
```
---
## 4. Streaming — Live Output During Execution (optional)
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
### Props (`BuiltinStreamingProps<Args>`)
```ts
interface BuiltinStreamingProps<Arguments = any> {
apiName: string;
args: Arguments;
identifier: string;
messageId: string; // use to fetch the streaming buffer from store
toolCallId: string;
}
```
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
### Canonical example — RunCommandStreaming
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
```tsx
'use client';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
interface RunCommandParams {
command?: string;
description?: string;
timeout?: number;
}
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
const { command } = args || {};
if (!command) return null;
return (
<Highlighter
animated
wrap
language="sh"
showLanguage={false}
style={{ padding: '4px 8px' }}
variant="outlined"
>
{command}
</Highlighter>
);
});
RunCommandStreaming.displayName = 'RunCommandStreaming';
```
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
```tsx
const buffer = useChatStore((state) =>
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
);
```
### Streaming rules
- Render `null` until you have something to display (avoids flash).
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
### Streaming registry — `client/Streaming/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import { RunCommandStreaming } from './RunCommand';
import { WriteFileStreaming } from './WriteFile';
export const LocalSystemStreamings = {
[LocalSystemApiName.runCommand]: RunCommandStreaming,
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
};
```
---
## 5. Intervention — Approval / Edit-Before-Run (optional)
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
### Props (`BuiltinInterventionProps<Args>`)
```ts
interface BuiltinInterventionProps<Arguments = any> {
apiName?: string;
args: Arguments;
identifier?: string;
interactionMode?: 'approval' | 'custom';
messageId: string;
/** Called when the user edits the args; the approve action awaits this. */
onArgsChange?: (args: Arguments) => void | Promise<void>;
/** Called on approve / skip / cancel. */
onInteractionAction?: (
action:
| { type: 'submit'; payload: Record<string, unknown> }
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
| { type: 'cancel'; payload?: Record<string, unknown> },
) => Promise<void>;
/** Register a callback to flush pending saves before approval. Returns cleanup. */
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
}
```
### Canonical example — RunCommand Intervention
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
```tsx
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
import { memo } from 'react';
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
const { description, command, timeout } = args;
return (
<Flexbox gap={8}>
<Flexbox horizontal justify="space-between">
{description && <Text>{description}</Text>}
{timeout && (
<Text style={{ fontSize: 12 }} type="secondary">
timeout: {formatTimeout(timeout)}
</Text>
)}
</Flexbox>
{command && (
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
{command}
</Highlighter>
)}
</Flexbox>
);
});
export default RunCommand;
```
### Intervention rules
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
### Intervention registry — `client/Intervention/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import EditLocalFile from './EditLocalFile';
import RunCommand from './RunCommand';
import WriteFile from './WriteFile';
/* … */
export const LocalSystemInterventions = {
[LocalSystemApiName.editLocalFile]: EditLocalFile,
[LocalSystemApiName.runCommand]: RunCommand,
[LocalSystemApiName.writeLocalFile]: WriteFile,
/* one entry per API that needs approval */
};
```
---
## 6. Portal — Full-Screen Detail View (optional)
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
### Props (`BuiltinPortalProps<Args, State>`)
```ts
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
apiName?: string;
arguments: Arguments;
identifier: string;
messageId: string;
state: State;
}
```
### Canonical example — Web-Browsing Portal
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
```tsx
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
import { memo } from 'react';
import { WebBrowsingApiName } from '../../types';
import PageContent from './PageContent';
import PageContents from './PageContents';
import Search from './Search';
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
switch (apiName) {
case WebBrowsingApiName.search:
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
case WebBrowsingApiName.crawlSinglePage: {
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
return <PageContent messageId={messageId} result={result} />;
}
case WebBrowsingApiName.crawlMultiPages:
return (
<PageContents
messageId={messageId}
results={(state as CrawlPluginState).results}
urls={args.urls}
/>
);
}
return null;
});
export default Portal;
```
### Portal rules
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
### Portal registry — `packages/builtin-tools/src/portals.ts`
```ts
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
import { type BuiltinPortal } from '@lobechat/types';
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
};
```
---
## 7. `client/components/` — Shared Subcomponents
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
Examples from `web-browsing/src/client/components/`:
- `CategoryAvatar.tsx` — search category icon
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
Examples from `local-system/src/client/components/`:
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
### Rules
- Live under `client/components/`, exported via `client/components/index.ts`.
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
---
## 8. `client/index.ts` — Package Public API
Re-exports everything the registries need plus useful types/manifest:
```ts
// Inspector — required
export { TaskInspectors } from './Inspector';
// Render — only if any API has one
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
// Placeholder / Streaming / Intervention — only if used
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
export { LocalSystemStreamings } from './Streaming';
export { LocalSystemInterventions } from './Intervention';
// Portal — single export per tool
export { default as WebBrowsingPortal } from './Portal';
// Reusable components if other packages need them
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
// Re-export manifest, identifier, types for convenience
export { TaskManifest, TaskIdentifier } from '../manifest';
export * from '../types';
```
---
## 9. Diagnostic Quick-Lookup
| Symptom | Surface to check | | |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | --- | ------------------------- |
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry | | |
| Header shows the API name but no chips | Inspector missing \`args?.X | | partialArgs?.X\` fallback |
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` | | |
| Empty result card under header | Render returned `<div />` instead of `null` when no data | | |
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions | | |
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry | | |
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` | | |
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName | | |
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) | | |
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` | | |
@@ -0,0 +1,36 @@
# Tool UI Surfaces
A builtin tool can ship up to **six client-side surfaces**, each with a different role in the chat UI. Only `Inspector` is required; the other five are added on demand and registered in their own central files.
| Surface | Required? | When the chat shows it | Registered in |
| ------------ | --------- | --------------------------------------------------------------------- | --------------------------------------------- |
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | `packages/builtin-tools/src/inspectors.ts` |
| Render | Optional | Rich result card below the header, after the call returns | `packages/builtin-tools/src/renders.ts` |
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | `packages/builtin-tools/src/placeholders.ts` |
| Streaming | Optional | Live output during execution (e.g. command stdout) | `packages/builtin-tools/src/streamings.ts` |
| Intervention | Optional | Approval / edit-before-run dialog (when `humanIntervention` triggers) | `packages/builtin-tools/src/interventions.ts` |
| Portal | Optional | Full-screen detail view (right-side or modal) | `packages/builtin-tools/src/portals.ts` |
The two reference tools to read end-to-end:
- **`builtin-tool-web-browsing/src/client/`** — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).
- **`builtin-tool-local-system/src/client/`** — all six surfaces, including `components/` for shared building blocks.
---
## Files in this folder
Read **principles** and **shared-rules** first — they apply to every surface. Then jump to the surface you're building.
| File | What it covers |
| ---------------------------------- | ----------------------------------------------------------------------- |
| [principles.md](principles.md) | Design principles — when each surface exists and how far to take it |
| [shared-rules.md](shared-rules.md) | Cross-surface rules: component skeleton, styling, single-layer surfaces |
| [inspector.md](inspector.md) | Inspector — header chip (required) |
| [render.md](render.md) | Render — rich result card |
| [placeholder.md](placeholder.md) | Placeholder — skeleton between args and result |
| [streaming.md](streaming.md) | Streaming — live output during execution |
| [intervention.md](intervention.md) | Intervention — approval / edit-before-run |
| [portal.md](portal.md) | Portal — full-screen detail view |
| [composition.md](composition.md) | Shared subcomponents (`client/components/`) + package public API |
| [diagnostics.md](diagnostics.md) | Symptom → surface quick-lookup |
@@ -0,0 +1,51 @@
# Composition — Shared Components & Package API
## `client/components/` — Shared Subcomponents
Cross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
Examples from `web-browsing/src/client/components/`:
- `CategoryAvatar.tsx` — search category icon
- `EngineAvatar.tsx` — search engine logo (used in Inspector chip + Render list + Portal header)
- `SearchBar.tsx` — editable query bar (used in Render and Portal)
Examples from `local-system/src/client/components/`:
- `FileItem.tsx` — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)
- `FilePathDisplay.tsx` — path with truncation (used everywhere)
### Rules
- Live under `client/components/`, exported via `client/components/index.ts`.
- Re-export from `client/index.ts` only if other packages need them; otherwise keep internal.
- Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
---
## `client/index.ts` — Package Public API
Re-exports everything the registries need plus useful types/manifest:
```ts
// Inspector — required
export { TaskInspectors } from './Inspector';
// Render — only if any API has one
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
// Placeholder / Streaming / Intervention — only if used
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
export { LocalSystemStreamings } from './Streaming';
export { LocalSystemInterventions } from './Intervention';
// Portal — single export per tool
export { default as WebBrowsingPortal } from './Portal';
// Reusable components if other packages need them
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
// Re-export manifest, identifier, types for convenience
export { TaskManifest, TaskIdentifier } from '../manifest';
export * from '../types';
```
@@ -0,0 +1,15 @@
# Diagnostic Quick-Lookup
| Symptom | Surface to check |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| No header at all on the tool call | Inspector missing from `client/Inspector/index.ts` registry |
| Header shows the API name but no chips | Inspector missing `args?.X \|\| partialArgs?.X` fallback |
| Header doesn't pulse during loading | Missing `shinyTextStyles.shinyText` on `isArgumentsStreaming \|\| isLoading` |
| Empty result card under header | Render returned `<div />` instead of `null` when no data |
| Render looks "complex" / card-in-card | Filled container (`colorFillQuaternary`) wrapping more filled boxes — flatten to single-layer, see [shared-rules.md](shared-rules.md) |
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions |
| Approval dialog never appears | Manifest missing `humanIntervention`, or Intervention not in registry |
| Approval click doesn't wait for inline edit | Missing `registerBeforeApprove(id, flushFn)` |
| Portal opens but blank | Switch in `Portal/index.tsx` doesn't cover the apiName |
| Strings show as `builtins.lobe-foo.apiName.bar` | Missing i18n key in `src/locales/default/plugin.ts` (or not seeded in dev locale files) |
| Wrong color shade on `<Text type="secondary">` | `type='secondary'` is lighter than `colorTextSecondary` — pass via `style={{ color: cssVar.colorTextSecondary }}` |
@@ -0,0 +1,118 @@
# Inspector — Header Chip (required)
**Lifecycle:** Inspector renders for **every phase** of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
**Goal:** keep it to a single line. Show what's happening with as much context as is currently available.
## Props (`BuiltinInspectorProps<Args, State>`)
```ts
interface BuiltinInspectorProps<Arguments = any, State = any> {
apiName: string;
args: Arguments; // final args (only after the assistant stops streaming)
identifier: string;
isArgumentsStreaming?: boolean; // args still arriving
isLoading?: boolean; // args complete, executor running
partialArgs?: Arguments; // partial JSON during streaming
pluginState?: State; // executor's `state` after success
result?: { content: string | null; error?: any };
}
```
## State machine
| Phase | What's available | What to show |
| ----------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
| Args streaming, no useful field yet | `isArgumentsStreaming === true`, `partialArgs.X` undefined | Just the API title with `shinyTextStyles.shinyText` |
| Args streaming, key field arrived | `partialArgs.X` populated | Title + key field chip, still pulse-animated |
| Args complete, executor running | `args` populated, `isLoading === true` | Same as above, still pulse-animated |
| Result arrived | `pluginState` populated, `isLoading === false` | Title + chips + result summary (count, identifier, status) |
## Canonical example — Search
`packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx`:
```tsx
'use client';
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const query = args?.query || partialArgs?.query || '';
const resultCount = pluginState?.results?.length ?? 0;
const hasResults = resultCount > 0;
if (isArgumentsStreaming && !query) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-web-browsing.apiName.search')}:&nbsp;</span>
{query && <span className={highlightTextStyles.primary}>{query}</span>}
{!isLoading &&
!isArgumentsStreaming &&
pluginState?.results &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
({t('builtins.lobe-web-browsing.inspector.noResults')})
</Text>
))}
</div>
);
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
```
## Inspector rules
- Wrap the whole row with `inspectorTextStyles.root` (provides correct flex / line-height baseline).
- Pulse with `shinyTextStyles.shinyText` whenever `isArgumentsStreaming || isLoading`.
- Show the i18n title first so the row is non-empty during the earliest streaming phase.
- Read both `args?.X` and `partialArgs?.X` together — `args` is final, `partialArgs` is in-stream.
- Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with `text-overflow: ellipsis` and have a `max-width` so long values don't blow out the chat bubble.
- Append `pluginState`-derived suffixes only **after** loading finishes — count or "(no results)" should not appear while still searching.
- **Switch copy by phase.** If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define `<api>.loading` and `<api>.completed` keys and select via `isArgumentsStreaming || isLoading ? loadingKey : completedKey`. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. See `CallSubAgentInspector` for the canonical two-key pattern.
## Inspector registry — `client/Inspector/index.ts`
```ts
import type { BuiltinInspector } from '@lobechat/types';
import { TaskApiName } from '../../types';
import { CreateTaskInspector } from './CreateTask';
import { ListTasksInspector } from './ListTasks';
/* … */
export const TaskInspectors: Record<string, BuiltinInspector> = {
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
/* one entry per ApiName */
};
export { CreateTaskInspector } from './CreateTask';
export { ListTasksInspector } from './ListTasks';
/* re-export each */
```
@@ -0,0 +1,88 @@
# Intervention — Approval / Edit-Before-Run (optional)
**Lifecycle:** rendered **before the executor runs** for APIs whose manifest sets `humanIntervention`. The user sees a preview of the args, can edit them, then approves or skips/cancels.
**Add for** destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
## Props (`BuiltinInterventionProps<Args>`)
```ts
interface BuiltinInterventionProps<Arguments = any> {
apiName?: string;
args: Arguments;
identifier?: string;
interactionMode?: 'approval' | 'custom';
messageId: string;
/** Called when the user edits the args; the approve action awaits this. */
onArgsChange?: (args: Arguments) => void | Promise<void>;
/** Called on approve / skip / cancel. */
onInteractionAction?: (
action:
| { type: 'submit'; payload: Record<string, unknown> }
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
| { type: 'cancel'; payload?: Record<string, unknown> },
) => Promise<void>;
/** Register a callback to flush pending saves before approval. Returns cleanup. */
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
}
```
## Canonical example — RunCommand Intervention
`packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx`:
```tsx
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
import { memo } from 'react';
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
const { description, command, timeout } = args;
return (
<Flexbox gap={8}>
<Flexbox horizontal justify="space-between">
{description && <Text>{description}</Text>}
{timeout && (
<Text style={{ fontSize: 12 }} type="secondary">
timeout: {formatTimeout(timeout)}
</Text>
)}
</Flexbox>
{command && (
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
{command}
</Highlighter>
)}
</Flexbox>
);
});
export default RunCommand;
```
## Intervention rules
- **Show a preview, not a form by default.** Editing UI is opt-in via `onArgsChange` and is usually inline (click to edit a code block, etc.).
- For args with debounced edit state (text fields), use `registerBeforeApprove(id, flushFn)` so the approve action waits for the debounce to flush. Always return the cleanup function.
- Call `onInteractionAction({ type: 'submit', payload })` when the user approves; `'skip'` if they skip with a reason; `'cancel'` if they cancel the whole turn.
- Add a corresponding `interventionAudit.ts` in the package root if the tool needs scope/path validation before approval (see `local-system/src/interventionAudit.ts`).
## Intervention registry — `client/Intervention/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import EditLocalFile from './EditLocalFile';
import RunCommand from './RunCommand';
import WriteFile from './WriteFile';
/* … */
export const LocalSystemInterventions = {
[LocalSystemApiName.editLocalFile]: EditLocalFile,
[LocalSystemApiName.runCommand]: RunCommand,
[LocalSystemApiName.writeLocalFile]: WriteFile,
/* one entry per API that needs approval */
};
```
@@ -0,0 +1,93 @@
# Placeholder — Skeleton Between Args and Result (optional)
**Lifecycle:** rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when `pluginState` arrives. Bridges the moment of perceived lag.
**Add for** APIs with noticeable execution time: web search, network crawl, file list, large grep. **Skip for** instant ops (status flips, calculator).
## Props (`BuiltinPlaceholderProps<Args>`)
```ts
interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
apiName: string;
args?: T;
identifier: string;
}
```
No `pluginState` — Placeholder lives entirely in the "executing" gap.
## Canonical example — Search Placeholder
`packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx`:
```tsx
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { SearchIcon } from 'lucide-react';
import { memo } from 'react';
import { useIsMobile } from '@/hooks/useIsMobile';
import { shinyTextStyles } from '@/styles';
const styles = createStaticStyles(({ css, cssVar }) => ({
query: cx(
css`
padding: 4px 8px;
border-radius: 8px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
shinyTextStyles.shinyText,
),
}));
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
const { query } = args || {};
const isMobile = useIsMobile();
return (
<Flexbox gap={8}>
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
<Flexbox horizontal align="center" className={styles.query} gap={8}>
<Icon icon={SearchIcon} />
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
</Flexbox>
<Skeleton.Block active style={{ height: 20, width: 40 }} />
</Flexbox>
<Flexbox horizontal gap={12}>
{[1, 2, 3, 4, 5].map((id) => (
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
))}
</Flexbox>
</Flexbox>
);
});
```
## Placeholder rules
- **Mirror the eventual Render's layout.** When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
- Use `Skeleton.Block` / `Skeleton.Button` from `@lobehub/ui` for placeholder shapes.
- Embed any args you have (e.g. the query text) — context helps the user know what's loading.
- Pulse with `shinyTextStyles.shinyText` if the Placeholder includes literal text.
## Placeholder registry — `client/Placeholder/index.ts`
```ts
import { WebBrowsingApiName } from '../../types';
import CrawlMultiPages from './CrawlMultiPages';
import CrawlSinglePage from './CrawlSinglePage';
import { Search } from './Search';
export const WebBrowsingPlaceholders = {
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
[WebBrowsingApiName.search]: Search,
};
export { CrawlMultiPages, CrawlSinglePage, Search };
```
@@ -0,0 +1,71 @@
# Portal — Full-Screen Detail View (optional)
**Lifecycle:** rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per **tool**, not per API — the Portal switches on `apiName` internally.
**Add for** tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
## Props (`BuiltinPortalProps<Args, State>`)
```ts
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
apiName?: string;
arguments: Arguments;
identifier: string;
messageId: string;
state: State;
}
```
## Canonical example — Web-Browsing Portal
`packages/builtin-tool-web-browsing/src/client/Portal/index.tsx`:
```tsx
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
import { memo } from 'react';
import { WebBrowsingApiName } from '../../types';
import PageContent from './PageContent';
import PageContents from './PageContents';
import Search from './Search';
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
switch (apiName) {
case WebBrowsingApiName.search:
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
case WebBrowsingApiName.crawlSinglePage: {
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
return <PageContent messageId={messageId} result={result} />;
}
case WebBrowsingApiName.crawlMultiPages:
return (
<PageContents
messageId={messageId}
results={(state as CrawlPluginState).results}
urls={args.urls}
/>
);
}
return null;
});
export default Portal;
```
## Portal rules
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see `Search/index.tsx:20-46`).
- Layout assumes more space than the Render — use `Flexbox` with `height={'100%'}` and structure for a side panel viewport.
## Portal registry — `packages/builtin-tools/src/portals.ts`
```ts
import { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
import { type BuiltinPortal } from '@lobechat/types';
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
};
```
@@ -0,0 +1,19 @@
# Tool Render 设计原则(中文草案)
这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。
1. **先保证折叠态可读。** 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。
2. **Inspector 是一句话,不是详情页。** 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。
3. **Inspector 要覆盖执行生命周期。** `args` 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 `args``partialArgs``pluginState`,避免出现空白、跳变或只显示半截参数。
4. **文案要随状态切换时态。** 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 `<api>.loading` / `<api>.completed` 一对键(见 `lobe-agent.apiName.callSubAgent.{loading,completed}``lobe-claude-code.task.{create,list,update,get}.{loading,completed}`),渲染时按 `isArgumentsStreaming || isLoading` 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。
5. **只有结构化结果才需要 Render。** 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。
6. **Render 要帮助用户检查结果,而不是复述参数。** Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。
7. **参数和结果要一起参与渲染。** 好的 Tool UI 通常同时用 `args` 解释意图,用 `pluginState` 展示真实执行结果;但 `pluginState` 只放结果域数据,不要反向塞入可以从 `args` 推导出的内容。
8. **慢操作要有 Placeholder。** 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。
9. **Streaming 只用于连续产物。** 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。
10. **有风险的动作必须 Intervention。** 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。
11. **错误、空态和截断都是正式状态。** Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。
12. **信息密度要克制。** 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。
13. **视觉上融入聊天流。** Tool UI 应该使用 `@lobehub/ui` / base-ui、`Flexbox``createStaticStyles``cssVar.*`,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。具体的样式约定见 [shared-rules.md](shared-rules.md)。
14. **Devtools fixture 是验收入口。** 新增或修改 Tool UI 时,应在 `/devtools` 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。
15. **先做用户会看的 UI,再做调试 UI。** Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
@@ -0,0 +1,101 @@
# Render — Rich Result Card (optional)
**Lifecycle:** rendered **once the result arrives** (after Placeholder/Streaming hand off). Sits below the Inspector header.
**Skip if** the API is read-only or the result is just text — the framework already shows the executor's `content` string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
## Props (`BuiltinRenderProps<Args, State, Content>`)
```ts
interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
apiName?: string;
args: Arguments; // final params from the LLM
content: Content; // executor's content string (or parsed)
identifier?: string;
messageId: string; // for store lookups
pluginError?: any; // from BuiltinToolResult.error
pluginState?: State; // executor's state
toolCallId?: string;
}
```
## Two patterns
**Pattern A — Single-file Render** (web-browsing CrawlSinglePage):
```tsx
// client/Render/CrawlSinglePage.tsx
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
import { memo } from 'react';
import PageContent from './PageContent';
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
({ messageId, pluginState, args }) => (
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
),
);
export default CrawlSinglePage;
```
**Pattern B — Folder with subcomponents** (web-browsing Search):
```
client/Render/Search/
├── index.tsx # composes the subcomponents, handles error states
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
├── SearchQuery.tsx # editable query header
└── SearchResult.tsx # result list
```
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
## Error handling in Render
Renders are the canonical place to surface `pluginError` because the chat doesn't auto-render typed errors:
```tsx
if (pluginError) {
if (pluginError?.type === 'PluginSettingsInvalid') {
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
}
return (
<Alert
title={pluginError?.message}
type="error"
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
/>
);
}
```
## Render rules
- **Return `null`** if there's nothing useful to draw yet (avoids empty cards during stream).
- Use `pluginState` for server-truth (ids, counts, server-assigned status) and `args` for what the LLM asked. **Combine — neither alone is enough.**
- For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
- **Keep the Render single-layer** — the tool card is already your surface, so don't open with your own filled container and then nest more filled boxes inside it. See [shared-rules.md](shared-rules.md) → "Stay single-layer".
- For modals from a Render, use `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
## Render registry — `client/Render/index.ts`
```ts
import type { BuiltinRender } from '@lobechat/types';
import { TaskApiName } from '../../types';
import CreateTaskRender from './CreateTask';
import RunTasksRender from './RunTasks';
export const TaskRenders: Record<string, BuiltinRender> = {
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
/* only the APIs with rich result UI — others fall back to text content */
};
export { default as CreateTaskRender } from './CreateTask';
export { default as RunTasksRender } from './RunTasks';
```
## Render display control (rare)
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a `RenderDisplayControl` to `packages/builtin-tools/src/displayControls.ts`. See `ClaudeCodeRenderDisplayControls` for the pattern.
@@ -0,0 +1,89 @@
# Shared Style Rules
These apply across every surface.
## The component skeleton
Every surface file is the same shape, so internalize it once instead of re-deriving it per rule. The skeleton below bakes in five mechanical conventions — copy it and fill the body:
```tsx
'use client'; // (a) leaves of the chat tree must not block server rendering
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
// (b) type with BuiltinXProps<Args, State> — never widen to `any`.
// Args = the JSON Schema params, State = the executor's `state` field;
// they should match <Name>Params / <Name>State from types.ts.
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args, pluginState }) => {
const { t } = useTranslation('plugin'); // (c) all strings from the `plugin` namespace
// (d) cross-cutting state (loading, streaming buffer) comes from the store,
// not props — props only carry args/state/messageId.
// const buffer = useChatStore((s) => chatToolSelectors.streamingBuffer(messageId)(s));
return <span>{t('builtins.<identifier>.apiName.search')}</span>;
},
);
SearchInspector.displayName = 'SearchInspector'; // (e) always memo + displayName
export default SearchInspector;
```
- **(c)** Default an Inspector to `t('builtins.<identifier>.apiName.<api>')` so the row is non-empty while args stream in.
- **(d)** Read the store via Zustand selectors inside the component; see [streaming.md](streaming.md) for the buffer selector.
## Styling: `createStaticStyles + cssVar.*`, `@lobehub/ui` over `antd`
Zero-runtime CSS-in-JS — styles compile once and read CSS variables at runtime:
```tsx
import { createStaticStyles, cssVar } from 'antd-style';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
padding-block: 2px;
padding-inline: 8px;
border-radius: 999px;
color: ${cssVar.colorText};
background: ${cssVar.colorFillTertiary};
`,
}));
```
- Fall back to `createStyles + token` only when you need runtime token computation (rare). Inline `style={{ color: cssVar.colorTextSecondary }}` is fine for one-off dynamic values.
- Components come from `@lobehub/ui` (`Block`, `Text`, `Flexbox`, `Highlighter`, `Alert`, `Tooltip`, `Skeleton`), not raw `antd`. Modals come from `@lobehub/ui/base-ui` (`createModal`, `useModalContext`, `confirmModal`) — see the **modal** skill.
- Note: `<Text type='secondary'>` is a lighter shade than `colorTextSecondary`. For that exact token color, write `<Text style={{ color: cssVar.colorTextSecondary }}>`.
## Stay single-layer — don't nest filled cards
The framework already wraps every Render / Intervention in a tool card, so that card **is** your surface. A Render that opens with its own `background: ${cssVar.colorFillQuaternary}` container is already one card deep; put another filled box inside it (`colorBgContainer` / `colorFillTertiary`) and you get the card-in-card look that reads as "complex" — two or three stacked fills for what is really a flat list of fields.
- **The outermost wrapper carries no fill.** Use a flat container with only `padding-block: 4px` for breathing room; let the tool card provide the card. (See `Agent/index.tsx`'s `container`.)
- **At most one filled box, and only to delineate real content** — a Markdown preview, a diff, a code/result block. Labels, keyvalue fields, question/answer text, chips: render flat on the surface, separated by spacing or a hairline divider (`height: 1px; background: ${cssVar.colorFillSecondary}`), not by wrapping each in its own box.
- **A box on a flat surface needs a visible fill.** Once the outer fill is gone, an inner `colorBgContainer` box can vanish against the tool card (same color). Use `colorFillTertiary` for the one content box so it still reads as delineated.
- Don't wrap a single value in a box just to give it padding — that's the redundant-nesting smell (a `detailCard` around a `value` box around one string).
```tsx
// ❌ card-in-card: filled container wrapping a filled preview box
container: css`
padding: 12px;
background: ${cssVar.colorFillQuaternary};
`,
previewBox: css`
background: ${cssVar.colorBgContainer};
`,
// ✅ single-layer: flat container, one visible content box
container: css`
padding-block: 4px;
`,
previewBox: css`
background: ${cssVar.colorFillTertiary};
`,
```
For the common "icon + file/title header, then one content box" shape, reuse `ToolResultCard` from `@lobechat/shared-tool-ui/components` instead of rebuilding it — it's already single-layer (flat wrapper, one `colorFillTertiary` content box) and is what CC `Read` / `Grep` / `Glob` / `Write` / `WebSearch` / `WebFetch` render through.
The exception is a deliberate **panel** pattern — an `<Block variant="outlined">` with a header bar + list rows (CC `TodoWrite` / `Task`). There the single outlined block is the panel and the header fill is a header bar, not a nested card. One structured panel is fine; stacked decorative fills are not.
@@ -0,0 +1,83 @@
# Streaming — Live Output During Execution (optional)
**Lifecycle:** rendered **while the executor is still running** for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
**Add for** long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
## Props (`BuiltinStreamingProps<Args>`)
```ts
interface BuiltinStreamingProps<Arguments = any> {
apiName: string;
args: Arguments;
identifier: string;
messageId: string; // use to fetch the streaming buffer from store
toolCallId: string;
}
```
Note there's **no `state` or `result` prop** — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via `chatToolSelectors.streamingContent(messageId)` or similar).
## Canonical example — RunCommandStreaming
`packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx`:
```tsx
'use client';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
interface RunCommandParams {
command?: string;
description?: string;
timeout?: number;
}
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
const { command } = args || {};
if (!command) return null;
return (
<Highlighter
animated
wrap
language="sh"
showLanguage={false}
style={{ padding: '4px 8px' }}
variant="outlined"
>
{command}
</Highlighter>
);
});
RunCommandStreaming.displayName = 'RunCommandStreaming';
```
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
```tsx
const buffer = useChatStore((state) =>
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
);
```
## Streaming rules
- Render `null` until you have something to display (avoids flash).
- For terminal-style output, use `Highlighter` with `animated` to show typing-like effect.
- The Streaming component must **unmount cleanly** when execution ends — typically the framework swaps it out for the Render automatically.
## Streaming registry — `client/Streaming/index.ts`
```ts
import { LocalSystemApiName } from '../..';
import { RunCommandStreaming } from './RunCommand';
import { WriteFileStreaming } from './WriteFile';
export const LocalSystemStreamings = {
[LocalSystemApiName.runCommand]: RunCommandStreaming,
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
};
```
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: chat-sdk
description: "Build multi-platform chat bots with the Chat SDK (`chat` npm package) — Slack, Teams, Google Chat, Discord, GitHub, Linear. Use when building a chat bot, handling mentions / messages / reactions / slash commands / cards / modals / streaming, setting up a webhook handler, or sending interactive cards / streaming AI responses to a chat platform. Triggers on `@chat-adapter`, 'chat sdk', 'chat bot', 'slack bot', 'teams bot', 'discord bot', 'webhook handler', 'cross-platform bot'."
description: 'Build multi-platform chat bots with the chat SDK. Use for Slack, Teams, Google Chat, Discord, GitHub, Linear bots, webhooks, mentions, slash commands, cards, modals, or streaming responses.'
user-invocable: false
---
-218
View File
@@ -1,218 +0,0 @@
---
name: cli-backend-testing
description: >
CLI + Backend integration testing workflow. Use when verifying backend API changes
(TRPC routers, services, models) via the LobeHub CLI against a local dev server.
Triggers on 'cli test', 'test with cli', 'verify with cli', 'local cli test',
'backend test with cli', or when needing to validate server-side changes end-to-end.
---
# CLI + Backend Integration Testing
Standard workflow for verifying backend changes using the LobeHub CLI (`lh`) against a local dev server.
## When to Use
- Verifying TRPC router / service / model changes end-to-end
- Testing new API fields or response structure changes
- Validating CLI command output after backend modifications
- Debugging data flow issues between server and CLI
## Prerequisites
| Requirement | Details |
| ------------ | ------------------------------------------------------------- |
| Dev server | `localhost:3011` (Next.js) |
| CLI source | `lobehub/apps/cli/` |
| CLI dev mode | Uses `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated credentials |
| Auth | Device Code Flow login to local server |
## Quick Reference
All CLI dev commands run from `lobehub/apps/cli/`:
```bash
# Shorthand for all commands below
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
```
## Workflow
### Step 1: Ensure Dev Server is Running
Check if the dev server is already running:
```bash
curl -s -o /dev/null -w '%{http_code}' http://localhost:3011/ 2> /dev/null
```
- **If reachable** (returns any HTTP status): server is running. Skip to Step 2.
- **If unreachable**: start the server:
```bash
# From cloud repo root
pnpm run dev:next
```
To **restart** (pick up server-side code changes):
```bash
lsof -ti:3011 | xargs kill
pnpm run dev:next
```
**Important:** Server-side code changes in the submodule (`lobehub/src/server/`, `lobehub/packages/`) require a server restart. Next.js hot-reload may not pick up changes in submodule packages.
### Step 2: Check CLI Authentication
Check if dev credentials already exist:
```bash
cat lobehub/apps/cli/.lobehub-dev/settings.json 2> /dev/null
```
- **If file exists and contains `"serverUrl": "http://localhost:3011"`**: already authenticated. Skip to Step 3.
- **If file missing or points to wrong server**: login is needed. Ask the user to run:
```bash
! cd lobehub/apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3011
```
> Login requires interactive browser authorization (OIDC Device Code Flow), so the user must run it themselves via `!` prefix. After login, credentials are saved to `lobehub/apps/cli/.lobehub-dev/` and persist across sessions.
### Step 3: Test with CLI Commands
CLI runs from source (`bun src/index.ts`), so CLI-side code changes take effect immediately without rebuilding.
```bash
cd lobehub/apps/cli
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
```
### Step 4: Clean Up Test Data
Delete any test data created during verification:
```bash
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts task delete < id > -y
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts agent delete < id > -y
```
## Common Testing Patterns
### Task System
```bash
# List tasks
$CLI task list
# Create test data with nesting
$CLI task create -n "Root Task" -i "Test instruction"
$CLI task create -n "Child Task" -i "Sub instruction" --parent T-1
# View task detail (tests getTaskDetail service)
$CLI task view T-1
# View task tree
$CLI task tree T-1
# Test lifecycle
$CLI task edit T-1 --status running
$CLI task comment T-1 -m "Test comment"
# Clean up
$CLI task delete T-1 -y
```
### Agent System
```bash
# List agents
$CLI agent list
# View agent detail
$CLI agent view <agent-id>
# Run agent (tests agent execution pipeline)
$CLI agent run <agent-id> -m "Test prompt"
```
### Document & Knowledge Base
```bash
# List documents
$CLI doc list
# Create and view
$CLI doc create -t "Test Doc" -c "Content here"
$CLI doc view <doc-id>
# Knowledge base
$CLI kb list
$CLI kb tree <kb-id>
```
### Model & Provider
```bash
# List models and providers
$CLI model list
$CLI provider list
# Test provider connectivity
$CLI provider test <provider-id>
```
## Dev-Test Cycle
The standard cycle for backend development:
```
1. Make code changes (service/model/router/type)
|
2. Run unit tests (fast feedback)
bunx vitest run --silent='passed-only' '<test-file>'
|
3. Restart dev server (if server-side changes)
lsof -ti:3011 | xargs kill && pnpm run dev:next
|
4. CLI verification (end-to-end)
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
5. Clean up test data
```
### When Server Restart is Needed
| Change Location | Restart? |
| ----------------------------------------- | -------- |
| `lobehub/src/server/` (routers, services) | Yes |
| `lobehub/packages/database/` (models) | Yes |
| `lobehub/packages/types/` | Yes |
| `lobehub/packages/prompts/` | Yes |
| `lobehub/apps/cli/` (CLI code) | No |
| `src/` (cloud overrides) | Yes |
### When Server Restart is NOT Needed
CLI runs from source via `bun src/index.ts`, so any changes to `lobehub/apps/cli/src/` take effect immediately on next command invocation.
## Troubleshooting
| Issue | Solution |
| --------------------------- | --------------------------------------------------------------------- |
| `No authentication found` | Run `login --server http://localhost:3011` |
| `UNAUTHORIZED` on API calls | Token expired; re-run login |
| `ECONNREFUSED` | Dev server not running; start with `pnpm run dev:next` |
| CLI shows old data/behavior | Server needs restart to pick up code changes |
| `EADDRINUSE` on port 3011 | Server already running; kill with `lsof -ti:3011 \| xargs kill` |
| Login opens wrong server | Must use `--server http://localhost:3011` flag (env var doesn't work) |
## Credential Isolation
| Mode | Credential Dir | Server |
| ---------- | -------------------------------- | ----------------- |
| Dev | `lobehub/apps/cli/.lobehub-dev/` | `localhost:3011` |
| Production | `~/.lobehub/` | `app.lobehub.com` |
The two environments are completely isolated. Dev mode credentials are gitignored.
+5 -5
View File
@@ -111,7 +111,7 @@ Generate video from text prompt. This is an async operation.
**Source**: `apps/cli/src/commands/generate/video.ts`
```bash
lh gen video "A cat playing piano" -m <model> -p <provider> [options]
lh gen video "A cat playing piano" -m < model > -p < provider > [options]
```
| Option | Description | Required |
@@ -259,13 +259,13 @@ Image and video generation use an async task pattern:
UUID from the `async_tasks` table, not `gen_xxx`
- Returns `{ status, error, generation }` (generation includes asset URLs on success)
- Before querying, calls `checkTimeoutTasks` which marks tasks as `error` if they have been
`pending` or `processing` for more than ~5 minutes (`ASYNC_TASK_TIMEOUT = 298s`)
`pending` or `processing` for more than \~5 minutes (`ASYNC_TASK_TIMEOUT = 298s`)
**Server routes**:
- `src/server/routers/lambda/image/index.ts` — image creation (uses `authedProcedure` + `serverDatabase`)
- `src/server/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
- `src/server/routers/lambda/generation.ts` — status checking
- `apps/server/src/routers/lambda/image/index.ts` — image creation (uses `authedProcedure` + `serverDatabase`)
- `apps/server/src/routers/lambda/video/index.ts` — video creation (uses `authedProcedure` + `serverDatabase`)
- `apps/server/src/routers/lambda/generation.ts` — status checking
- `packages/database/src/models/asyncTask.ts``AsyncTaskModel` including `checkTimeoutTasks`
**Note**: Image/video routes do NOT use the `keyVaults` middleware — they read API keys from the database via `initModelRuntimeFromDB` or `createAsyncCaller`.
@@ -1,6 +1,6 @@
---
name: data-fetching-architecture
description: Standardized data-fetching pipeline guide — Service layer + Zustand Store + SWR. Use when implementing a data-fetching feature, creating a `xxxService`, adding a `useFetchXxx` hook, wiring `useClientDataSWR`, or migrating ad-hoc `useEffect + fetch` to the standard pipeline. Triggers on `lambdaClient`, `useClientDataSWR`, `xxxService`, `useFetchXxx`, 'data fetching', 'fetch architecture', 'service layer', 'SWR hook', 'migrate useEffect'.
description: 'LobeHub data-fetching pipeline guide. Use for service layer, Zustand store, SWR, lambdaClient, useClientDataSWR, useFetchXxx hooks, or migrating useEffect fetches.'
user-invocable: false
---
+61 -1
View File
@@ -1,11 +1,71 @@
---
name: db-migrations
description: 'Use when generating or regenerating Drizzle migration files, changing database schema tables or columns, resolving migration sequence conflicts after rebase, reviewing migration SQL for idempotent patterns, or renaming migration files.'
description: 'Use for Drizzle migrations: schema/table/column changes, migration generation or regeneration, sequence conflicts after rebase, idempotent SQL review, or migration renames.'
user-invocable: false
---
# Database Migrations Guide
## Development-stage schema changes
Schema changes churn during feature development. When the schema changes before the migration has shipped, do not hand-edit the existing migration SQL to chase the new schema shape. Delete the draft migration artifacts added by this branch (SQL file, matching snapshot, and matching journal entry), then run the generator again and re-apply the normal migration review steps below.
For example, if this branch's draft migration is `0110_add_verify_tables_and_ai_infra_id`:
```bash
# 1. Delete the draft SQL and its snapshot
rm packages/database/migrations/0110_add_verify_tables_and_ai_infra_id.sql
rm packages/database/migrations/meta/0110_snapshot.json
# 2. Remove the matching 0110 entry from the journal's "entries" array
# packages/database/migrations/meta/_journal.json
# 3. Regenerate from the current schema
bun run db:generate
```
This keeps the generated SQL, snapshot, and journal aligned with the actual schema. Manual SQL edits are reserved for review-time hardening such as idempotent clauses, custom extension SQL, and meaningful filename/tag updates.
Before release, if a feature branch accumulated multiple development-only migrations, consolidate them into one migration when possible. Production does not need to replay every intermediate draft shape, and fewer migrations reduce deploy-time risk.
For example, if this branch added `0110`, `0111`, and `0112`, delete all three drafts and regenerate a single migration:
```bash
# 1. Delete every draft SQL and snapshot this branch added
rm packages/database/migrations/011{0,1,2}_*.sql
rm packages/database/migrations/meta/011{0,1,2}_snapshot.json
# 2. Remove the 0110/0111/0112 entries from the journal's "entries" array
# packages/database/migrations/meta/_journal.json
# 3. Regenerate one migration covering the full schema delta
bun run db:generate
```
Do not make a migration compatible with earlier development-only versions of the same branch. While the migration has not shipped, there is no production history to preserve. Fix local/dev databases directly with whatever SQL is simplest (drop the draft table, rename a column, delete draft rows), then regenerate the branch migration from the current schema.
For example, if an earlier draft on this branch created `signup_attempt_id` and you have since renamed it to `user_signup_log_id`, do not add a compatibility `ALTER ... RENAME` to the migration. Just fix the dev DB directly (see the `access-pg` skill for the `bun -e` + `pg` pattern), then regenerate:
```bash
# Fix the dev DB to match the new schema (simplest SQL wins)
set -a && source .env && set +a && bun -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
await client.query("ALTER TABLE user_signup_logs DROP COLUMN signup_attempt_id");
await client.end();
'
# Regenerate so the migration reflects only the final shape
bun run db:generate
```
After a migration has reached production or the target default branch, treat it as immutable: add a follow-up migration instead of rewriting it.
## Rebase conflicts
When a rebase conflicts in migration files, keep the upstream/default-branch migrations and remove all migrations introduced by the current feature branch. Complete the rebase, then regenerate this branch's migration from the rebased schema. This avoids merging two independent snapshots or hand-splicing journal entries.
## Step 1: Generate Migrations
```bash
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: debug-package
description: "Guide for the `debug` npm package and LobeHub log namespaces (lobe-server:*, lobe-desktop:*, lobe-client:*, lobe-*-router:*). Use whenever adding a `debug(...)` logger, picking a namespace for new server/desktop/client/router code, troubleshooting why DEBUG=lobe-* logs don't show up, or when the user asks to 'add logging', 'add a logger', 'instrument this', 'trace this call', 'why isn't my log printing', or mentions `debug(`, `DEBUG=`, `localStorage.debug`, or log format specifiers like %O / %o / %s / %d in a LobeHub codebase."
description: 'LobeHub debug package and log namespace guide. Use when adding debug() logging, choosing lobe-* namespaces, troubleshooting DEBUG output, localStorage.debug, or log format specifiers.'
user-invocable: false
---
@@ -57,7 +57,7 @@ process.env.DEBUG = 'lobe-*';
## Example
```typescript
// src/server/routers/edge/market/index.ts
// apps/server/src/routers/edge/market/index.ts
import debug from 'debug';
const log = debug('lobe-edge-router:market');
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: docs-changelog
description: "Writing guide for website changelog pages under `docs/changelog/*.mdx` (NOT GitHub Release notes — those live in the `version-release` skill). Use when creating or editing a product update post in EN/ZH. Triggers on `docs/changelog/*.mdx`, 'changelog post', 'product update post', 'add a changelog', '更新日志', 'changelog 文案'."
description: 'Write website changelog pages under docs/changelog/*.mdx. Use for EN/ZH product update posts, changelog posts, update-log copy, or docs changelog edits; not GitHub Release notes.'
---
# Docs Changelog Writing Guide
+184 -4
View File
@@ -1,21 +1,29 @@
---
name: drizzle
description: "Drizzle ORM schema authoring and query style for LobeHub (postgres, strict mode). Use when editing anything under `src/database/schemas/`, defining `pgTable` columns/indexes/junction tables, spreading `...timestamps`, generating `createInsertSchema`/`$inferSelect`/`$inferInsert` types, writing `db.select().from(...).leftJoin(...)` queries, or deciding when to split a relational `with:` into two queries. Triggers on `pgTable`, `db.select`, `db.query`, `eq()`/`and()`/`inArray()`, `uniqueIndex`, `primaryKey`, `references({ onDelete })`, 'add a column', 'new table', 'foreign key', 'junction table', 'schema field'. For migration files specifically, see the `db-migrations` skill."
description: 'LobeHub Drizzle ORM schema and query style. Use for pgTable schemas, indexes, joins, inferred types, db.select/db.query, schema fields, foreign keys, junction tables, or postgres query patterns.'
user-invocable: false
---
# Drizzle ORM Schema Style Guide
> **Adding a Model or Repository?** Ship a sibling test in the same PR — every new
> file under `packages/database/src/models/**` or `src/repositories/**` needs a
> matching `__tests__/<name>.test.ts`. See the **testing** skill
> (`.agents/skills/testing/references/db-model-test.md`) for the `getTestDB()`
> integration pattern, user-isolation tests, the BM25 `describe.skipIf(!isServerDB)`
> guard, and schema gotchas. CI's coverage patch gate won't reliably catch a brand-new
> untested file, so this is on you.
## Configuration
- Config: `drizzle.config.ts`
- Schemas: `src/database/schemas/`
- Migrations: `src/database/migrations/`
- Schemas: `packages/database/src/schemas/`
- Migrations: `packages/database/migrations/`
- Dialect: `postgresql` with `strict: true`
## Helper Functions
Location: `src/database/schemas/_helpers.ts`
Location: `packages/database/src/schemas/_helpers.ts`
- `timestamptz(name)`: Timestamp with timezone
- `createdAt()`, `updatedAt()`, `accessedAt()`: Standard timestamp columns
@@ -25,16 +33,42 @@ Location: `src/database/schemas/_helpers.ts`
- **Tables**: Plural snake_case (`users`, `session_groups`)
- **Columns**: snake_case (`user_id`, `created_at`)
- **New tables**: Check nearby existing tables before naming a new one. Preserve
the established noun family and suffix. For example, if the user-scoped table
is `user_xxx_logs`, the workspace-scoped counterpart should be
`workspace_xxx_logs`, not `workspace_xxx_records` or another new synonym.
```typescript
// ✅ Good: follows the existing user/workspace table family.
export const userSignupLogs = pgTable('user_signup_logs', { ... });
export const workspaceSignupLogs = pgTable('workspace_signup_logs', { ... });
// ❌ Bad: introduces a new suffix for the same concept.
export const workspaceSignupRecords = pgTable('workspace_signup_records', { ... });
```
## Column Definitions
### Primary Keys
Do not use auto-incrementing primary keys (`serial`, `bigserial`, generated
identity columns). They create sequence-state problems during cross-database
migrations, restores, and data copy jobs. Prefer text IDs from application
generators (`idGenerator`, `createNanoId`) or `uuid` for internal tables.
Keep `$defaultFn(...)` when a table normally owns ID generation. Callers can
still pass an explicit `id`; the default only runs when the insert omits it. Do
not remove the default just because one flow needs to supply a request-scoped ID.
```typescript
// ✅ Good: app-generated text ID; explicit inserts can still override it.
id: text('id')
.primaryKey()
.$defaultFn(() => idGenerator('agents'))
.notNull(),
// ❌ Bad: sequence state is fragile across DB migrations and restores.
id: serial('id').primaryKey(),
```
ID prefixes make entity types distinguishable. For internal tables, use `uuid`.
@@ -53,6 +87,80 @@ userId: text('user_id')
...timestamps, // Spread from _helpers.ts
```
### Optional and Undefined Values
Do not introduce artificial sentinel strings for missing values, such as
`unknown`, unless the domain already has that explicit state and existing code
uses it consistently. Prefer nullable columns, optional TypeScript fields, or a
separate concrete status enum when the value is genuinely absent.
```typescript
// ✅ Good: absent until the final stage writes a real decision.
export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error';
finalDecision: varchar('final_decision', { length: 32 }).$type<UserSignupLogFinalDecision>(),
// ❌ Bad: invents a new state that callers now need to handle everywhere.
export type UserSignupLogFinalDecision = 'allow' | 'block' | 'error' | 'unknown';
finalDecision: varchar('final_decision', { length: 32 })
.$type<UserSignupLogFinalDecision>()
.notNull()
.default('unknown');
```
### Field Descriptions
For columns whose meaning is not obvious from the name alone, add JSDoc on the
schema field. Include a concrete example when it clarifies the stored value or
the lifecycle moment that writes it. This is especially important for external
IDs, lifecycle statuses, denormalized snapshots, JSONB signals, and fields whose
name could mean either a request ID or a persisted row ID.
```typescript
// ✅ Good: explain the table's business object first, then only document
// non-obvious lifecycle or risk-control fields.
/**
* User signup logs - one row per signup flow, collecting stage-level
* risk-control decisions before and after the auth provider creates a user.
*/
export const userSignupLogs = pgTable('user_signup_logs', {
/** Final signup outcome reason, for example user_created, llm_block, or guard_error */
finalReason: text('final_reason'),
/** Aggregated risk level derived from stage decisions, for example block -> high */
riskLevel: varchar('risk_level', { length: 16 }).$type<UserSignupLogRiskLevel>(),
/** Ordered stage-level decisions and metadata grouped by signup review stage */
stageResults: jsonb('stage_results').$type<UserSignupLogStageResults>(),
});
// ❌ Bad: comments restate obvious column names without adding domain meaning.
/** User email */
email: text('email'),
```
### JSONB Types
Avoid `Record<string, unknown>` or similarly loose JSONB types for schema
columns. Define a concrete interface that describes the expected JSON shape, even
when most properties are optional. This keeps callers, migrations, and review
queries aligned on the same data contract.
```typescript
interface UserSignupLogMetadata {
payloadPath?: string;
requestPath?: string;
}
metadata: jsonb('metadata').$type<UserSignupLogMetadata>(),
```
```typescript
// ❌ Bad: hides the contract and makes downstream access untyped.
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
```
### Indexes
```typescript
@@ -174,6 +282,78 @@ const rows = await this.db
.groupBy(agentEvalDatasets.id);
```
### Raw SQL and Advanced Queries
Prefer Drizzle builders whenever the query reads clearly with `select`,
`insert().select()`, `update().from()`, joins, CTEs, and `groupBy` — this keeps
table/column references tied to schema, so changes surface as TypeScript errors.
Within a builder, expression-level `sql<T>` is fine for features lacking a helper
(JSON path, casts, aggregates, `CASE`, `NOW()`). Row locks are clauses, not
expressions — use `.for('update')`, never raw `FOR UPDATE`.
Use `COALESCE` only when null-handling is part of required DB semantics (nullable
JSONB append/merge, "keep first non-null"). Don't scatter
`COALESCE(excluded.col, current.col)` across ordinary upsert scalars just to avoid
an update object — build `set` from defined values only, and hide any remaining
SQL behind named helpers (`appendJsonbArray`, `mergeJsonbObject`, `keepFirstValue`)
so the method reads as business intent, not SQL plumbing.
```typescript
// ✅ Scalars included only when present; SQL hidden behind a named helper.
const updateValues = compactUndefined({
email: record.email ?? undefined,
ip: record.ip ?? undefined,
});
await db.insert(userSignupLogs).values(values).onConflictDoUpdate({
set: { ...updateValues, stageResults: appendStageResult(stage, result), updatedAt: now },
target: userSignupLogs.id,
});
// ❌ Every scalar becomes SQL plumbing.
set: {
email: sql`COALESCE(excluded.email, ${userSignupLogs.email})`,
ip: sql`COALESCE(excluded.ip, ${userSignupLogs.ip})`,
}
```
When refactoring raw SQL:
- Preserve query shape on latency-sensitive paths. If raw SQL is one roundtrip,
don't split it into multiple depth-based queries just to drop `execute`.
- Use `$with(...)` + `insert().select()` / `update().from()` for multi-step
single-roundtrip writes Drizzle can express.
- Don't rely on `execute<MyRow>(sql...)` for safety — it types rows but doesn't keep
selected columns in sync with schema changes.
- If only a PostgreSQL feature Drizzle can't express works, keep the raw SQL and
tighten it: schema refs in interpolations, explicit user scope, a narrow row
interface, and regression tests.
Recursive CTEs are the canonical "keep raw" case — there's no clean `WITH RECURSIVE`
builder, and a rewrite would add depth-based roundtrips:
```typescript
interface TaskTreeRow {
id: string;
parent_task_id: string | null;
}
// execute<T> acceptable: no clean WITH RECURSIVE builder. Keep schema refs in the
// interpolations and scope every leg to the user.
const { rows } = await db.execute<TaskTreeRow>(sql`
WITH RECURSIVE task_tree AS (
SELECT ${tasks.id}, ${tasks.parentTaskId}
FROM ${tasks}
WHERE ${tasks.id} = ${rootTaskId} AND ${tasks.createdByUserId} = ${userId}
UNION ALL
SELECT ${tasks.id}, ${tasks.parentTaskId}
FROM ${tasks}
JOIN task_tree ON ${tasks.parentTaskId} = task_tree.id
WHERE ${tasks.createdByUserId} = ${userId}
)
SELECT * FROM task_tree
`);
```
### One-to-Many (Separate Queries)
When you need a parent record with its children, use two queries instead of relational `with:`:
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: heterogeneous-agent
description: Guide for implementing and debugging LobeHub heterogeneous agent integrations such as Claude Code, Codex, and future external CLI agents. Use when working on adapter event mapping, Electron IPC transport, renderer persistence, tool-call chaining, subagent threads, resume/session handling, or regressions like mixed multi-tool messages, broken step boundaries, stuck tool loading, and orphan tool messages. Triggers on 'heterogeneous agent', 'hetero agent', '异构 agent', 'claude code adapter', 'codex adapter', 'external agent CLI', '孤立 tool 消息', 'raw Codex trace', or adapter/executor bugs.
description: 'Implement or debug LobeHub heterogeneous agents. Use for Claude Code/Codex adapters, external CLI agents, event mapping, IPC, persistence, tool-call chains, sessions, traces, or adapter bugs.'
---
# Heterogeneous Agent Development
@@ -241,6 +241,6 @@ When the bug comes from a real trace, distill it into the closest existing test
3. Add or update the narrowest failing test near the broken layer.
4. Fix the smallest layer that can explain the symptom.
5. Re-run focused tests.
6. Only then do an Electron smoke test with the `local-testing` skill if UI confirmation is still needed.
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.
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: hotkey
description: "Adding or editing keyboard shortcuts in LobeHub. Use when registering a new hotkey, changing a key combo, scoping a shortcut to chat vs global, or wiring a hotkey hook + tooltip. Covers the 5-step flow: add to `HotkeyEnum` in `src/types/hotkey.ts`, register in `HOTKEYS_REGISTRATION` (`src/const/hotkeys.ts`) with `combineKeys([Key.Mod, …])`, add i18n in `src/locales/default/hotkey.ts`, expose via `useHotkeyById` in `src/hooks/useHotkeys/`, and render `<Tooltip hotkey={…}>`. Triggers on `HotkeyEnum`, `HOTKEYS_REGISTRATION`, `useHotkeyById`, `combineKeys`, `Key.Mod`/`Key.Shift`, 'add a hotkey', 'add a shortcut', '加快捷键', '快捷键', 'Cmd+K', 'keyboard shortcut', 'hotkey scope', 'hotkey conflict'."
description: 'Add or edit LobeHub keyboard shortcuts. Use for HotkeyEnum, HOTKEYS_REGISTRATION, combineKeys, useHotkeyById, tooltip hotkeys, shortcut scope, conflicts, or Cmd/Ctrl key combos.'
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: i18n
description: "LobeHub internationalization with react-i18next. Use when adding any user-facing string in `.tsx`/`.ts` files, creating or renaming a key under `src/locales/default/{namespace}.ts`, deciding the `{feature}.{context}.{action}` flat-key pattern, wiring a new namespace into `src/locales/default/index.ts`, or translating zh-CN/en-US JSON for dev preview. Triggers on `useTranslation`, `t('foo.bar')`, `i18next.t`, `{{variable}}` interpolation, hardcoded UI strings (zh or en) that should be extracted, 'add i18n', '加 i18n key', '翻译', 'locale key', 'namespace', 'pnpm i18n'."
description: 'LobeHub i18n with react-i18next. Use for user-facing strings, locale keys, namespaces, useTranslation, t(), interpolation, zh-CN/en-US previews, hardcoded UI copy, or pnpm i18n.'
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: linear
description: "Linear issue management. Use when the user mentions LOBE-xxx issue IDs (e.g. LOBE-4540), says 'linear' / 'linear issue' / 'link linear', or when creating PRs that reference Linear issues. Covers retrieving issues, updating status, adding completion comments, and creating sub-issue trees."
description: 'Linear issue management. Use for LOBE-xxx issues, Linear links, PRs referencing Linear, retrieving issues, updating status, completion comments, or sub-issue trees.'
user-invocable: false
---
-561
View File
@@ -1,561 +0,0 @@
---
name: local-testing
description: >
Local app and bot testing. Uses agent-browser CLI for Electron/web app UI testing,
and osascript (AppleScript) for controlling native macOS apps (WeChat, Discord, Telegram, Slack, Lark/飞书, QQ)
to test bots. Triggers on 'local test', 'test in electron', 'test desktop', 'test bot',
'bot test', 'test in discord', 'test in telegram', 'test in slack', 'test in weixin',
'test in wechat', 'test in lark', 'test in feishu', 'test in qq',
'manual test', 'osascript', or UI/bot verification tasks.
---
# Local App & Bot Testing
Two approaches for local testing on macOS:
| Approach | Tool | Best For |
| --------------------------- | ------------------- | ---------------------------------------------------- |
| **agent-browser + CDP** | `agent-browser` CLI | Electron apps, web apps (DOM access, JS eval) |
| **osascript (AppleScript)** | `osascript -e` | Native macOS apps (WeChat, Discord, Telegram, Slack) |
---
# Part 1: agent-browser (Electron / Web Apps)
Use `agent-browser` to automate Chromium-based apps via Chrome DevTools Protocol.
Install via `npm i -g agent-browser`, `brew install agent-browser`, or `cargo install agent-browser`. Run `agent-browser install` to download Chrome. Run `agent-browser upgrade` to update.
## Core Workflow
Every browser automation follows this pattern:
1. **Navigate**: `agent-browser open <url>`
2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
3. **Interact**: Use refs to click, fill, select
4. **Re-snapshot**: After navigation or DOM changes, get fresh refs
```bash
agent-browser open https://example.com/form
agent-browser snapshot -i
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit"
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i # Check result
```
## Command Chaining
```bash
# Chain open + wait + snapshot in one call
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
```
Use `&&` when you don't need to read intermediate output. Run commands separately when you need to parse output first (e.g., snapshot to discover refs, then interact).
## Essential Commands
```bash
# Navigation
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser close # Close browser
agent-browser close --all # Close all active sessions
# Snapshot
agent-browser snapshot -i # Interactive elements with refs (recommended)
agent-browser snapshot -s "#selector" # Scope to CSS selector
# Interaction (use @refs from snapshot)
agent-browser click @e1 # Click element
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser fill @e2 "text" # Clear and type text
agent-browser type @e2 "text" # Type without clearing
agent-browser select @e1 "option" # Select dropdown option
agent-browser check @e1 # Check checkbox
agent-browser press Enter # Press key
agent-browser keyboard type "text" # Type at current focus (no selector)
agent-browser keyboard inserttext "text" # Insert without key events
agent-browser scroll down 500 # Scroll page
agent-browser scroll down 500 --selector "div.content" # Scroll within container
# Get information
agent-browser get text @e1 # Get element text
agent-browser get url # Get current URL
agent-browser get title # Get page title
agent-browser get cdp-url # Get CDP WebSocket URL
# Wait
agent-browser wait @e1 # Wait for element
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --url "**/page" # Wait for URL pattern
agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Welcome" # Wait for text to appear
agent-browser wait --fn "!document.body.innerText.includes('Loading...')" # Wait for text to disappear
agent-browser wait "#spinner" --state hidden # Wait for element to disappear
# Downloads
agent-browser download @e1 ./file.pdf # Click element to trigger download
agent-browser wait --download ./output.zip # Wait for any download to complete
# Network
agent-browser network requests # Inspect tracked requests
agent-browser network requests --type xhr,fetch # Filter by resource type
agent-browser network requests --method POST # Filter by HTTP method
agent-browser network route "**/api/*" --abort # Block matching requests
agent-browser network har start # Start HAR recording
agent-browser network har stop ./capture.har # Stop and save HAR file
# Viewport & Device Emulation
agent-browser set viewport 1920 1080 # Set viewport size (default: 1280x720)
agent-browser set viewport 1920 1080 2 # 2x retina
agent-browser set device "iPhone 14" # Emulate device (viewport + user agent)
# Capture
agent-browser screenshot # Screenshot to temp dir
agent-browser screenshot --full # Full page screenshot
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
agent-browser pdf output.pdf # Save as PDF
# Clipboard
agent-browser clipboard read # Read text from clipboard
agent-browser clipboard write "text" # Write text to clipboard
agent-browser clipboard copy # Copy current selection
agent-browser clipboard paste # Paste from clipboard
# Dialogs (alert, confirm, prompt, beforeunload)
agent-browser dialog accept # Accept dialog
agent-browser dialog accept "input" # Accept prompt dialog with text
agent-browser dialog dismiss # Dismiss/cancel dialog
agent-browser dialog status # Check if dialog is open
# Diff (compare page states)
agent-browser diff snapshot # Compare current vs last snapshot
agent-browser diff screenshot --baseline before.png # Visual pixel diff
agent-browser diff url <url1> <url2> # Compare two pages
# Streaming
agent-browser stream enable # Start WebSocket streaming
agent-browser stream status # Inspect streaming state
agent-browser stream disable # Stop streaming
```
## Batch Execution
```bash
echo '[
["open", "https://example.com"],
["snapshot", "-i"],
["click", "@e1"],
["screenshot", "result.png"]
]' | agent-browser batch --json
```
## Authentication
```bash
# Option 1: Auth vault (credentials stored encrypted)
echo "$PASSWORD" | agent-browser auth save myapp --url https://app.example.com/login --username user --password-stdin
agent-browser auth login myapp
# Option 2: Session name (auto-save/restore cookies + localStorage)
agent-browser --session-name myapp open https://app.example.com/login
agent-browser close # State auto-saved
agent-browser --session-name myapp open https://app.example.com/dashboard # Auto-restored
# Option 3: Persistent profile
agent-browser --profile ~/.myapp open https://app.example.com/login
# Option 4: State file
agent-browser state save auth.json
agent-browser state load auth.json
```
### LobeHub dev server — inject better-auth cookie
`agent-browser --headed` on macOS can create an off-screen Chromium window, blocking manual login. For a local LobeHub dev server (e.g. `localhost:3011`), copy the `better-auth.session_token` cookie out of a **Network request** in the user's own Chrome DevTools and load it via `state load`. See [references/agent-browser-login.md](./references/agent-browser-login.md) for the full recipe.
## Semantic Locators (Alternative to Refs)
```bash
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find role button click --name "Submit"
agent-browser find placeholder "Search" type "query"
agent-browser find testid "submit-btn" click
```
## JavaScript Evaluation (eval)
```bash
# Simple expressions
agent-browser eval 'document.title'
# Complex JS: use --stdin with heredoc (RECOMMENDED)
agent-browser eval --stdin << 'EVALEOF'
JSON.stringify(
Array.from(document.querySelectorAll("img"))
.filter(i => !i.alt)
.map(i => ({ src: i.src.split("/").pop(), width: i.width }))
)
EVALEOF
# Base64 encoding (avoids all shell escaping issues)
agent-browser eval -b "$(echo -n 'document.title' | base64)"
```
## Ref Lifecycle
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after clicking links/buttons that navigate, form submissions, or dynamic content loading.
## Annotated Screenshots (Vision Mode)
```bash
agent-browser screenshot --annotate
# Output includes the image path and a legend:
# [1] @e1 button "Submit"
# [2] @e2 link "Home"
agent-browser click @e2 # Click using ref from annotated screenshot
```
## Parallel Sessions
```bash
agent-browser --session site1 open https://site-a.com
agent-browser --session site2 open https://site-b.com
agent-browser session list
```
## Connect to Existing Chrome
```bash
agent-browser --auto-connect snapshot # Auto-discover running Chrome
agent-browser --cdp 9222 snapshot # Explicit CDP port
```
## iOS Simulator (Mobile Safari)
```bash
agent-browser device list
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
agent-browser -p ios snapshot -i
agent-browser -p ios tap @e1
agent-browser -p ios swipe up
agent-browser -p ios screenshot mobile.png
agent-browser -p ios close
```
## Observability Dashboard
```bash
agent-browser dashboard install
agent-browser dashboard start # Background server on port 4848
agent-browser dashboard stop
```
## Cloud Providers
Use `-p <provider>` to run against cloud browsers: `agentcore`, `browserbase`, `browserless`, `browseruse`, `kernel`.
## Browser Engine Selection
```bash
agent-browser --engine lightpanda open example.com # 10x faster, 10x less memory
```
## Electron (LobeHub Desktop)
### Setup / Teardown
Use the `electron-dev.sh` script to manage the Electron dev environment. It handles process lifecycle, waits for SPA readiness, and reliably kills all child processes (main + helpers + vite).
```bash
SCRIPT=".agents/skills/local-testing/scripts/electron-dev.sh"
# Start Electron dev with CDP (idempotent — skips if already running)
$SCRIPT start
# Check if Electron is running and CDP is reachable
$SCRIPT status
# Kill all Electron-related processes (main + helper + vite)
$SCRIPT stop
# Force fresh restart
$SCRIPT restart
```
After `start` succeeds, connect with: `agent-browser --cdp 9222 snapshot -i`
**Always run `$SCRIPT stop` when done testing** — `pkill -f "Electron"` alone won't catch all helper processes.
#### Environment Variables
| Variable | Default | Description |
| ----------------- | ----------------------- | ---------------------------------------- |
| `CDP_PORT` | `9222` | Chrome DevTools Protocol port |
| `ELECTRON_LOG` | `/tmp/electron-dev.log` | Electron process log |
| `ELECTRON_WAIT_S` | `60` | Max seconds to wait for Electron process |
| `RENDERER_WAIT_S` | `60` | Max seconds to wait for SPA to load |
### LobeHub-Specific Patterns
#### Access Zustand Store State
```bash
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
(function() {
var chat = window.__LOBE_STORES.chat();
var ops = Object.values(chat.operations);
return JSON.stringify({
ops: ops.map(function(o) { return { type: o.type, status: o.status }; }),
activeAgent: chat.activeAgentId,
activeTopic: chat.activeTopicId,
});
})()
EVALEOF
```
#### Find and Use the Chat Input
```bash
# The chat input is contenteditable — must use -C flag
agent-browser --cdp 9222 snapshot -i -C 2>&1 | grep "editable"
agent-browser --cdp 9222 click @e48
agent-browser --cdp 9222 type @e48 "Hello world"
agent-browser --cdp 9222 press Enter
```
#### Wait for Agent to Complete
```bash
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
(function() {
var chat = window.__LOBE_STORES.chat();
var ops = Object.values(chat.operations);
var running = ops.filter(function(o) { return o.status === 'running'; });
return running.length === 0 ? 'done' : 'running: ' + running.length;
})()
EVALEOF
```
#### Install Error Interceptor
```bash
agent-browser --cdp 9222 eval --stdin << 'EVALEOF'
(function() {
window.__CAPTURED_ERRORS = [];
var orig = console.error;
console.error = function() {
var msg = Array.from(arguments).map(function(a) {
if (a instanceof Error) return a.message;
return typeof a === 'object' ? JSON.stringify(a) : String(a);
}).join(' ');
window.__CAPTURED_ERRORS.push(msg);
orig.apply(console, arguments);
};
return 'installed';
})()
EVALEOF
# Later, check captured errors:
agent-browser --cdp 9222 eval "JSON.stringify(window.__CAPTURED_ERRORS)"
```
## Chrome / Web Apps
```bash
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222 \
--user-data-dir=/tmp/chrome-test-profile \
"<URL>" &
sleep 5
agent-browser --cdp 9222 snapshot -i
# Or auto-discover running Chrome with remote debugging
agent-browser --auto-connect snapshot -i
```
---
# Part 2: osascript (Native macOS App Bot Testing)
Use AppleScript via `osascript` to control native macOS desktop apps for bot testing. Works with any app that supports macOS Accessibility, no CDP or Chromium needed.
The pattern is the same for every platform:
1. **Activate** the app (`tell application "X" to activate`)
2. **Navigate** to a channel/chat (Quick Switcher `Cmd+K` or Search `Cmd+F`)
3. **Send** a message (clipboard paste `Cmd+V` + Enter)
4. **Wait** for the bot response
5. **Screenshot** for verification (`screencapture` + `Read` tool)
## Per-Platform References
Pick the file for your target platform — each contains activation, navigation, send-message, and verification snippets specific to that app:
Each channel has its own folder under `bot/<channel>/` containing an `index.md`
(activation, navigation, send-message, and verification snippets specific to
that app) and its test script:
| Platform | Reference | Quick switcher |
| ------------- | ------------------------------------------------ | -------------- |
| Discord | [bot/discord/index.md](./bot/discord/index.md) | `Cmd+K` |
| Slack | [bot/slack/index.md](./bot/slack/index.md) | `Cmd+K` |
| Telegram | [bot/telegram/index.md](./bot/telegram/index.md) | `Cmd+F` |
| WeChat / 微信 | [bot/wechat/index.md](./bot/wechat/index.md) | `Cmd+F` |
| Lark / 飞书 | [bot/lark/index.md](./bot/lark/index.md) | `Cmd+K` |
| QQ | [bot/qq/index.md](./bot/qq/index.md) | `Cmd+F` |
For **shared osascript patterns** (activate, type, paste, screenshot, read accessibility, common workflow template, gotchas), see [bot/osascript-common.md](./bot/osascript-common.md). Read this first if you're new to osascript automation.
## Bridge-based channels (no native app)
Some channels have no native app to drive with osascript — they connect through
a local bridge inside the Desktop app. These are tested with agent-browser
(IPC + UI) plus the bridge's own HTTP/REST endpoints, not osascript:
| Channel | Reference | What it drives |
| -------- | ------------------------------------------------ | -------------------------------------------------------- |
| iMessage | [bot/imessage/index.md](./bot/imessage/index.md) | `imessageBridge.*` IPC + local bridge + BlueBubbles REST |
For iMessage there is a one-shot regression script — see `test-imessage-bridge.sh` below.
---
# Scripts
**App / recording scripts** in `.agents/skills/local-testing/scripts/`:
| Script | Usage |
| ------------------------- | --------------------------------------------------- |
| `electron-dev.sh` | Manage Electron dev env (start/stop/status/restart) |
| `record-electron-demo.sh` | Record Electron app demo with ffmpeg |
| `record-app-screen.sh` | Record app screen (video + screenshots, start/stop) |
**Bot scripts** live under `.agents/skills/local-testing/bot/`, one folder per
channel (alongside that channel's `index.md`). The shared
`capture-app-window.sh` sits at the `bot/` root:
| Script | Usage |
| ---------------------------------- | ------------------------------------------------------------------- |
| `capture-app-window.sh` | Capture screenshot of a specific app window (used by bot tests) |
| `discord/test-discord-bot.sh` | Send message to Discord bot via osascript |
| `slack/test-slack-bot.sh` | Send message to Slack bot via osascript |
| `telegram/test-telegram-bot.sh` | Send message to Telegram bot via osascript |
| `wechat/test-wechat-bot.sh` | Send message to WeChat bot via osascript |
| `lark/test-lark-bot.sh` | Send message to Lark / 飞书 bot via osascript |
| `qq/test-qq-bot.sh` | Send message to QQ bot via osascript |
| `imessage/test-imessage-bridge.sh` | Regression-test the iMessage BlueBubbles bridge (IPC + HTTP) |
| `imessage/send-imessage-test.sh` | Send one real iMessage (desktop → BB → iMessage) and verify it sent |
### Window Screenshot Utility
`capture-app-window.sh` captures a screenshot of a specific app window using `screencapture -l <windowID>`. It uses Swift + CGWindowList to find the window by process name, so screenshots work correctly even when the window is on an external monitor or behind other windows.
```bash
# Standalone usage
./.agents/skills/local-testing/bot/capture-app-window.sh "Discord" /tmp/discord.png
./.agents/skills/local-testing/bot/capture-app-window.sh "Slack" /tmp/slack.png
./.agents/skills/local-testing/bot/capture-app-window.sh "WeChat" /tmp/wechat.png
```
All bot test scripts use this utility automatically for their screenshots.
### Bot Test Scripts
All bot test scripts share the same interface:
```bash
./scripts/test-<platform>-bot.sh <channel_or_contact> <message> [wait_seconds] [screenshot_path]
```
Examples:
```bash
# Discord — test a bot in #bot-testing channel
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "!ping"
./.agents/skills/local-testing/bot/discord/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
# Slack — test a bot in #bot-testing channel
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "@mybot hello"
./.agents/skills/local-testing/bot/slack/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
# Telegram — test a bot by username
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "MyTestBot" "/start"
./.agents/skills/local-testing/bot/telegram/test-telegram-bot.sh "GPTBot" "Hello" 60
# WeChat — test a bot or send to a contact
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "文件传输助手" "test message" 5
./.agents/skills/local-testing/bot/wechat/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
# Lark/飞书 — test a bot in a group chat
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "@MyBot hello"
./.agents/skills/local-testing/bot/lark/test-lark-bot.sh "bot-testing" "Help me with this" 30
# QQ — test a bot in a group or direct chat
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "bot-testing" "Hello bot" 15
./.agents/skills/local-testing/bot/qq/test-qq-bot.sh "MyBot" "/help" 10
```
Each script: activates the app, navigates to the channel/contact, pastes the message via clipboard, sends, waits, and takes a screenshot. Use the `Read` tool on the screenshot for visual verification.
### iMessage bridge regression script
`test-imessage-bridge.sh` does **not** follow the osascript bot interface — it
drives the Desktop bridge's IPC + HTTP layers and asserts the result, then
self-cleans. Needs BlueBubbles running and Electron up with CDP.
```bash
./.agents/skills/local-testing/bot/imessage/test-imessage-bridge.sh '<bluebubbles_password>' [bb_url] [cdp_port]
# defaults: bb_url=http://127.0.0.1:1234 cdp_port=9222 — exit 0 = all green
```
It guards the connect/configure flow (testConfig happy + reject paths, first-time
`upsertConfig` save, bridge running + webhook registered, local-server secret
enforcement). See [bot/imessage/index.md](./bot/imessage/index.md)
for the full manual UI flow and known bugs.
---
# Screen Recording
Record automated demos using `record-app-screen.sh` (start/stop lifecycle, CDP screenshots + ffmpeg assembly). See [references/record-app-screen.md](references/record-app-screen.md) for full documentation.
```bash
./.agents/skills/local-testing/scripts/electron-dev.sh start
./.agents/skills/local-testing/scripts/record-app-screen.sh start my-demo
# ... run automation ...
./.agents/skills/local-testing/scripts/record-app-screen.sh stop
```
Outputs to `.records/` directory (gitignored): `<name>.mp4` (video) + `<name>/` (screenshots every 3s).
---
# Gotchas
### agent-browser
- **Daemon can get stuck** — if commands hang, `agent-browser close --all` or `pkill -f agent-browser` to reset
- **HMR invalidates everything** — after code changes, refs break. Re-snapshot or restart
- **`snapshot -i` doesn't find contenteditable** — use `snapshot -i -C` for rich text editors
- **`fill` doesn't work on contenteditable** — use `type` for chat inputs
- **Screenshots go to `~/.agent-browser/tmp/screenshots/`** — read them with the `Read` tool
- **Dialogs block all commands** — if commands time out, check `agent-browser dialog status`
- **Default timeout is 25s** — override with `AGENT_BROWSER_DEFAULT_TIMEOUT` (ms) or use explicit waits
- **Shell quoting corrupts eval** — use `eval --stdin <<'EVALEOF'` for complex JS
### Electron-specific
- **Always use `electron-dev.sh stop` to clean up**`pkill -f "Electron"` only kills the main process; helper processes (GPU, renderer, network) survive. The script finds and kills all of them via PID matching against the project's electron binary path.
- **`npx electron-vite dev` must run from `apps/desktop/`** — running from project root fails silently. The `electron-dev.sh` script handles this automatically.
- **Don't resize the Electron window after load** — resizing triggers full SPA reload
- **Store is at `window.__LOBE_STORES`** not `window.__ZUSTAND_STORES__`
### osascript
See [bot/osascript-common.md](./bot/osascript-common.md#gotchas) for the full osascript gotchas list (accessibility permissions, `keystroke` non-ASCII issues, locale-specific app names, rate limiting, etc.).
@@ -1,110 +0,0 @@
# Log `agent-browser` into a local LobeHub dev server
`agent-browser --headed` on macOS often creates the Chromium window off-screen — the user can't see or interact with it, so manual login inside the agent-browser session fails. Instead of sharing the user's real Chrome profile, copy the **better-auth session cookie** out of a request in DevTools and inject it into the agent-browser session as a Playwright-style state file.
## When to use
- You need `agent-browser` to reach an authenticated page on `http://localhost:<port>` (e.g. `localhost:3011`).
- The user already has a logged-in tab of the same dev server in their own Chrome.
- Spawning a headed Chromium to let the user log in manually is unreliable (window off-screen, no interaction).
Do **not** use this on production URLs — only local dev. Treat the cookie as a secret: don't paste it into shared logs, PRs, or commit it anywhere.
## Step 1 — Ask the user to copy the cookie from a Network request, NOT `document.cookie`
`document.cookie` will not return HttpOnly cookies, which is exactly where better-auth puts its session. Instruct the user:
1. Open the logged-in tab (`http://localhost:<port>/…`) in their own Chrome.
2. `Cmd+Option+I`**Network** tab.
3. Refresh, click any same-origin request (e.g. the top-level document request).
4. In the right pane under **Request Headers**, right-click the `Cookie:` line → **Copy value** (or copy the entire header).
5. Paste the string into chat.
You only need the better-auth pieces. Everything else (Clerk, `LOBE_LOCALE`, HMR hash, theme vars) is noise and can stay. The minimum viable set is:
```
better-auth.session_token=<value>; better-auth.state=<value>
```
## Step 2 — Build a Playwright-style state file
`agent-browser state load` expects Playwright's `storageState` format: a JSON with a `cookies` array and an `origins` array.
```bash
cat > /tmp/mkstate.py << 'PY'
import json, sys, time
# Read the Cookie header from stdin (allows optional "Cookie: " prefix).
raw = sys.stdin.read().strip()
if raw.lower().startswith("cookie:"):
raw = raw.split(":", 1)[1].strip()
# Keep only better-auth cookies. Extend this set if the app genuinely needs more.
WANTED = {"better-auth.session_token", "better-auth.state"}
cookies = []
exp = int(time.time()) + 30 * 24 * 3600 # 30 days
for pair in raw.split("; "):
if "=" not in pair:
continue
name, _, value = pair.partition("=")
if name not in WANTED:
continue
cookies.append({
"name": name,
"value": value,
"domain": "localhost",
"path": "/",
"expires": exp,
"httpOnly": False,
"secure": False,
"sameSite": "Lax",
})
if not cookies:
sys.stderr.write("no better-auth cookies found in input\n")
sys.exit(1)
print(json.dumps({"cookies": cookies, "origins": []}, indent=2))
PY
# Feed the copied Cookie header in via env var or heredoc.
printf '%s' "$COOKIE_HEADER" | python3 /tmp/mkstate.py > /tmp/state.json
```
**Note on `httpOnly`**: the real cookie in the user's browser is HttpOnly, but `storageState` doesn't enforce the flag on load — it just attaches the value. Storing with `httpOnly: false` is fine for local dev and sidesteps a CDP-context quirk where HttpOnly cookies sometimes fail to attach.
## Step 3 — Load state and navigate
```bash
SESSION="my-test" # any stable session name
agent-browser --session "$SESSION" state load /tmp/state.json
agent-browser --session "$SESSION" open "http://localhost:3011/"
agent-browser --session "$SESSION" get url
# Expect NOT /signin?callbackUrl=… — if you still see signin, cookie didn't apply.
```
## Step 4 — Verify
```bash
agent-browser --session "$SESSION" snapshot -i | head -20
# Look for the user's avatar/name in the sidebar, or absence of the signin form.
```
## Common failure modes
| Symptom | Cause | Fix |
| ----------------------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------- |
| Still redirects to `/signin` after `state load` | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
| `state load` reports 0 cookies | Separator wrong, or user pasted URL-decoded value | Keep the raw `Cookie:` header as-is; split on `"; "` |
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-load |
| Domain mismatch | Use `domain: "localhost"` literally, no leading dot for local dev | — |
## Scope
Only covers authenticating an **agent-browser** session into a **local** LobeHub dev server. It does not:
- Work for production — production cookies are `Secure; HttpOnly; Domain=.lobehub.com` and must be delivered over HTTPS.
- Replace real OAuth flows — tests that must exercise the login UI need a real Chromium with `--remote-debugging-port` or a bot account.
- Flow cookies back to the user's Chrome — injection is one-way (into agent-browser only).
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: microcopy
description: UI copy and microcopy guidelines. Use when writing UI text, buttons, error messages, empty states, onboarding, or any user-facing copy. Triggers on i18n translation, UI text writing, or copy improvement tasks. Supports both Chinese and English.
description: 'UI copy and microcopy guidelines. Use for user-facing copy, buttons, errors, empty states, onboarding, i18n wording, translation, or copy improvements in Chinese or English.'
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: modal
description: "LobeHub imperative-modal conventions. Use whenever creating, editing, opening, or migrating a modal/dialog/popup — prefer `createModal` / `confirmModal` / `useModalContext` from `@lobehub/ui/base-ui` (headless) over the legacy root `@lobehub/ui` `createModal` (antd Modal props) and over any declarative `open` state + `<Modal />` pattern. Covers required `ModalHost` mounting, the `Content` + `index.tsx` file layout, `content` vs `children` slot, i18n inside `createModal()` (`import { t } from 'i18next'`), and migration notes. Triggers on `createModal`, `confirmModal`, `useModalContext`, `ModalHost`, `antd Modal`, `<Modal open>`, 'open a modal', 'popup', 'dialog', 'confirm dialog', '弹框', '弹窗', '确认框', 'migrate to base-ui'."
description: 'LobeHub imperative modal conventions. Use when creating or migrating modals, dialogs, popups, confirm flows, ModalHost wiring, createModal, confirmModal, useModalContext, or base-ui modal APIs.'
user-invocable: false
---
@@ -0,0 +1,69 @@
---
name: model-bank-metadata
description: 'Backfill and maintain model-bank metadata (knowledgeCutoff, family, generation). Use when adding models, fixing cutoff/family data, running a metadata sweep across aiModels providers, or researching official knowledge cutoffs.'
user-invocable: false
---
# Model-Bank Metadata (knowledgeCutoff / family / generation)
How to populate and maintain the three structured metadata fields on `packages/model-bank/src/aiModels/*.ts` model cards, at single-model scale (new model PR) or repo-wide scale (sweep across \~80 provider files / \~1900 entries).
## Field semantics
| Field | Format | Meaning |
| ----------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `knowledgeCutoff` | `'YYYY-MM'` (or `'YYYY'` if only the year is published) | World-knowledge cutoff. When a vendor distinguishes a **"reliable knowledge cutoff"** from the broader training-data cutoff (Anthropic does), always use the **reliable** one. |
| `family` | lowercase slug (`claude`, `gpt`, `o-series`, `qwen`, `deepseek`, `llama`, `glm`, …) | Model lineage, finer than `organization`. Lets the UI group models and match the same model across aggregator providers. |
| `generation` | family slug + version (`claude-4.6`, `gpt-5.2`, `qwen3.5`, `llama-3.1`) | Generation within the family. Only set when confidently derivable from the model line's naming. Rolling aliases (`qwen-max`, `deepseek-chat`, `gemini-flash-latest`) get `family` only. |
All three are optional. **The cardinal rule: only fill what an authoritative source states or naming rules derive — never guess.** An empty field is correct for vendors that publish nothing.
No DB migration is ever needed for these: builtin models are merged from model-bank at read time (`repositories/aiInfra/index.ts` spreads the whole card), so new card fields flow to the client automatically.
## Sourcing rules for knowledgeCutoff
Accept only:
- Vendor official docs (platform.openai.com / developers.openai.com, docs.x.ai, ai.google.dev, docs.anthropic.com / platform.claude.com)
- Official Hugging Face org model cards (huggingface.co/meta-llama/..., etc.)
- Official tech reports / system cards / launch blog posts
Reject:
- **Third-party aggregator sites** (aiknowledgecutoff.com and similar) — proven to copy one model's value across a whole family. A Cohere sweep once claimed `2024-06` for four distinct base models; none of the cited Cohere pages said that, and the only cutoff Cohere actually publishes is Feb 2023 for the 08-2024 Command R/R+ refresh.
- **AWS Bedrock model cards as sole source** — proven to conflate launch date with knowledge cutoff (DeepSeek R1's card lists both as "Jan 2025"). If Bedrock is the only place a value appears, leave the field empty.
- Inference from `releasedAt` — a release date is not a cutoff.
Variant inheritance: dated snapshots (`-2024-08-06`), speed/price tiers of the same checkpoint, quantizations (`-fp8`, `-awq`), context-length variants (`-32k`), ollama `:NNb` tags, and cloud-prefixed ids (`anthropic.`/`us.`/`global.` Bedrock ids) share their base model's cutoff. **Distills do not inherit** from teacher or base — use the distill's own published value or leave empty. **Sizes within one generation can genuinely differ**: Llama 3 8B is Mar 2023 while 70B is Dec 2023 (per Meta's own card) — don't "fix" that to one family-wide value.
Vendors that publish no cutoffs (leave empty, don't chase): Qwen, DeepSeek, GLM/Zhipu, ERNIE, Doubao, Hunyuan, SenseNova, Spark, MiniMax, StepFun, Yi (mostly), Moonshot.
Known per-vendor footguns:
- **Anthropic**: Opus 4.6 reliable cutoff is `2025-05`, Sonnet 4.6 is `2025-08` — easy to swap. Claude 3.7 is `2024-10` (system card: trained through Nov 2024, knowledge cutoff end of Oct 2024). Cite system cards / the models overview, not the Help Center article (a living page that drops retired models — citation rot).
- **xAI**: docs.x.ai has one blanket sentence covering grok-3/grok-4; mini variants are not named there. Grok 4.20/4.3 have no official cutoff anywhere.
- **OpenAI**: per-model docs pages (developers.openai.com/api/docs/models/<id>) state cutoffs explicitly, including snapshot differences (gpt-4-1106-preview `2023-04` vs gpt-4-0125-preview `2023-12`).
## family/generation derivation
Rule-based, no research needed: `scripts/derive-family.ts` holds the per-family regex rules. Traps already encoded there — keep them when extending:
- Date suffixes are not versions: `claude-sonnet-4-20250514` is generation `claude-4`, not `claude-4.2`.
- Size suffixes are not versions: `llama-3-8b``llama-3` (not `llama-3.8`); `gemma-7b-it` is **gemma-1** (not gemma-7).
- Vendor spelling variants: `qwen2p5` = qwen2.5, `llama-v3p1` = llama-3.1, ollama `:NNb` tags, Bedrock `us.`/`global.`/`anthropic.` prefixes.
- `claude-X.0` normalizes to `claude-X`.
- Fable/Mythos-class ids (`claude-fable-5`) don't match the opus/sonnet/haiku regex — they are the Mythos class — `family: 'claude-mythos'`, `generation: 'mythos-5'` (set manually; the launch page calls Fable 5 "the generally available Mythos-class model").
## Repo-wide sweep workflow
1. **Extract ids**: `bun .agents/skills/model-bank-metadata/scripts/extract-model-ids.ts` → unique normalized chat-model ids (normalization = last path segment, lowercased). Non-chat types (image/video/embedding/tts) have no knowledge cutoff — skip them.
2. **Research (multi-agent)**: chunk ids by family (≤50 per chunk) and fan out one research agent per chunk (Workflow tool), each returning `{id, cutoff, source}` with the sourcing rules above baked into the prompt, **plus** one adversarial verify agent per chunk that re-fetches cited sources and refutes unsupported claims. The verify pass is load-bearing: it caught the Cohere aggregator copy-paste and the AWS launch-date conflation.
3. **Policy filter**: before applying, drop entries whose only source is a rejected category (check the returned `sources` map — e.g. drop everything sourced to aws.amazon.com).
4. **Apply**: `bun scripts/apply-cutoffs.ts <map.json>` and `bun scripts/apply-family.ts <map.json>` (run from repo root). Both are idempotent codemods keyed on normalized id — aggregator providers get the same values automatically; entries that already have the field are skipped. They rely on the uniform prettier formatting of the data files (entries start ` {` / end ` },`, fields at 4-space indent).
5. **Verify**: `cd packages/model-bank && bunx vitest run src/aiModels/__tests__/index.test.ts && bunx tsc --noEmit`.
## Maintenance rules
- **New model PRs** should fill all three fields inline, citing the official source in the PR body (see the Anthropic entries in `anthropic.ts` for reference values).
- **After resolving merge conflicts** in model-bank data files, sanity-check that metadata didn't vanish: `git grep -c knowledgeCutoff -- 'packages/model-bank/src/aiModels/*.ts'` before vs after. A three-way stack of model PRs once silently dropped all 10 Anthropic cutoffs during conflict resolution.
- Dirty ids exist in aggregator data (a sambanova id once carried a trailing tab). The codemods match ids verbatim — if a map key won't apply, check for invisible characters before assuming the model is missing.
@@ -0,0 +1,73 @@
/**
* One-off codemod: apply a canonical { normalizedModelId: 'YYYY-MM' } map onto
* packages/model-bank/src/aiModels/*.ts, inserting `knowledgeCutoff` after the
* `id:` line of every chat-model entry that matches and doesn't already have one.
*
* Relies on the uniform prettier formatting of these files:
* - each model entry starts with ` {` and ends with ` },` (2-space indent)
* - fields are at 4-space indent: ` id: '...'`, ` type: 'chat'`
*
* Usage: bun /tmp/apply-cutoffs.ts /tmp/cutoff-map.json
*/
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const mapPath = process.argv[2];
if (!mapPath) throw new Error('usage: bun apply-cutoffs.ts <map.json>');
const map: Record<string, string> = JSON.parse(readFileSync(mapPath, 'utf8'));
const dir = 'packages/model-bank/src/aiModels';
const normalize = (id: string) => id.split('/').pop()!.toLowerCase();
let touchedFiles = 0;
let inserted = 0;
const matchedIds = new Set<string>();
for (const file of readdirSync(dir).filter((f) => f.endsWith('.ts'))) {
const path = join(dir, file);
const lines = readFileSync(path, 'utf8').split('\n');
const out: string[] = [];
let changed = false;
let i = 0;
while (i < lines.length) {
if (lines[i] !== ' {') {
out.push(lines[i]);
i++;
continue;
}
// collect one model entry block
const start = i;
let end = i;
while (end < lines.length && lines[end] !== ' },') end++;
const block = lines.slice(start, end + 1);
const idLineIdx = block.findIndex((l) => /^ {4}id: '/.test(l));
const isChat = block.some((l) => /^ {4}type: 'chat',?$/.test(l));
const hasCutoff = block.some((l) => /^ {4}knowledgeCutoff:/.test(l));
if (idLineIdx >= 0 && isChat && !hasCutoff) {
const rawId = block[idLineIdx].match(/^ {4}id: '(.+)',$/)?.[1];
const norm = rawId ? normalize(rawId) : undefined;
const cutoff = norm ? map[norm] : undefined;
if (cutoff && /^\d{4}(?:-\d{2})?$/.test(cutoff)) {
block.splice(idLineIdx + 1, 0, ` knowledgeCutoff: '${cutoff}',`);
inserted++;
changed = true;
matchedIds.add(norm!);
}
}
out.push(...block);
i = end + 1;
}
if (changed) {
writeFileSync(path, out.join('\n'));
touchedFiles++;
}
}
console.log(`inserted ${inserted} knowledgeCutoff fields across ${touchedFiles} files`);
console.log(`map ids used: ${matchedIds.size}/${Object.keys(map).length}`);
const unused = Object.keys(map).filter((k) => !matchedIds.has(k));
if (unused.length) console.log('unused map keys (first 20):', unused.slice(0, 20));
@@ -0,0 +1,49 @@
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const map: Record<string, { family: string; generation?: string }> = JSON.parse(
readFileSync('/tmp/family-map.json', 'utf8'),
);
const dir = 'packages/model-bank/src/aiModels';
const normalize = (id: string) => id.split('/').pop()!.toLowerCase();
let inserted = 0;
let touchedFiles = 0;
for (const file of readdirSync(dir).filter((f) => f.endsWith('.ts'))) {
const path = join(dir, file);
const lines = readFileSync(path, 'utf8').split('\n');
const out: string[] = [];
let changed = false;
let i = 0;
while (i < lines.length) {
if (lines[i] !== ' {') {
out.push(lines[i]);
i++;
continue;
}
let end = i;
while (end < lines.length && lines[end] !== ' },') end++;
const block = lines.slice(i, end + 1);
const idLineIdx = block.findIndex((l) => /^ {4}id: '/.test(l));
const isChat = block.some((l) => /^ {4}type: 'chat',?$/.test(l));
const hasFamily = block.some((l) => /^ {4}family:/.test(l));
if (idLineIdx >= 0 && isChat && !hasFamily) {
const rawId = block[idLineIdx].match(/^ {4}id: '(.+)',$/)?.[1];
const r = rawId ? map[normalize(rawId)] : undefined;
if (r) {
const add = [` family: '${r.family}',`];
if (r.generation) add.push(` generation: '${r.generation}',`);
block.splice(idLineIdx, 0, ...add);
inserted++;
changed = true;
}
}
out.push(...block);
i = end + 1;
}
if (changed) {
writeFileSync(path, out.join('\n'));
touchedFiles++;
}
}
console.log(`annotated ${inserted} model entries across ${touchedFiles} files`);
@@ -0,0 +1,237 @@
/* eslint-disable regexp/no-unused-capturing-group */
/**
* Rule-based derivation of { family, generation } from normalized model ids.
* Principle: only fill what is confidently derivable; otherwise omit.
*
* Usage: bun /tmp/derive-family.ts # print distinct pairs for review
* bun /tmp/derive-family.ts --emit # write /tmp/family-map.json
*/
import { readFileSync, writeFileSync } from 'node:fs';
const ids: string[] = JSON.parse(readFileSync('/tmp/model-ids.json', 'utf8'));
type R = { family: string; generation?: string };
const derive = (id: string): R | undefined => {
// strip cloud/bedrock prefixes for matching
const m = id.replace(/^(us\.|global\.|eu\.|apac\.)?(anthropic\.|meta\.|cohere\.|azure-)/, '');
// ---- anthropic ----
if (m.startsWith('claude')) {
// family = product-line tier (claude-opus/sonnet/haiku/instant); bare claude-2.x has no tier
const tier = m.match(/(opus|sonnet|haiku|instant)/)?.[1];
const family = tier ? `claude-${tier}` : 'claude';
let g = m.match(/^claude-(?:opus|sonnet|haiku)-(\d)[.-](\d)(?!\d)/); // claude-opus-4-8 / claude-haiku-4.5
if (g) return { family, generation: `claude-${g[1]}.${g[2]}` };
g = m.match(/^claude-(?:opus|sonnet|haiku)-(\d)(?!\d)/); // claude-opus-4
if (g) return { family, generation: `claude-${g[1]}` };
g = m.match(/^claude-(\d)[.-](\d)(?!\d)/); // claude-3-5-haiku / claude-3.7-sonnet / claude-2.1
if (g) return { family, generation: g[2] === '0' ? `claude-${g[1]}` : `claude-${g[1]}.${g[2]}` };
g = m.match(/^claude-(\d)(?!\d)/); // claude-3-haiku
if (g) return { family, generation: `claude-${g[1]}` };
if (m.startsWith('claude-instant')) return { family: 'claude-instant' };
if (/^claude-v?2/.test(m)) return { family: 'claude', generation: 'claude-2' };
return { family };
}
// ---- openai ----
if (/^(gpt-oss|gpt_oss)/.test(m) || m.startsWith('gpt-oss:'))
return { family: 'gpt-oss', generation: 'gpt-oss' };
if (/^(chatgpt-4o|gpt-4o)/.test(m)) return { family: 'gpt', generation: 'gpt-4o' };
if (/^gpt-(3\.5|35)/.test(m)) return { family: 'gpt', generation: 'gpt-3.5' };
if (m.startsWith('gpt-audio')) return { family: 'gpt', generation: 'gpt-audio' };
{
const g = m.match(/^gpt-(\d)\.(\d)/); // gpt-4.1 / gpt-5.2
if (g) return { family: 'gpt', generation: `gpt-${g[1]}.${g[2]}` };
const g2 = m.match(/^gpt-(\d)(?!\d)/); // gpt-4 / gpt-5
if (g2) return { family: 'gpt', generation: `gpt-${g2[1]}` };
}
{
const g = m.match(/^o([134])(-|$)/); // o1 / o3 / o4
if (g) return { family: 'o-series', generation: `o${g[1]}` };
}
if (/^(codex|computer-use-preview)/.test(m)) return { family: 'gpt' };
// ---- google ----
{
const g = m.match(/^gemini-(\d+(?:\.\d+)?)/);
if (g) return { family: 'gemini', generation: `gemini-${g[1]}` };
if (/^gemini-(pro|flash)/.test(m)) return { family: 'gemini' }; // rolling aliases
if (m.startsWith('gemma')) {
if (/^gemma-?\db/.test(m)) return { family: 'gemma', generation: 'gemma-1' };
const v = m.match(/^gemma-?(\d)(?!b)/);
return { family: 'gemma', generation: v ? `gemma-${v[1]}` : undefined };
}
if (/^(codegemma|learnlm|palm)/.test(m)) return { family: m.match(/^[a-z]+/)![0] };
}
// ---- qwen ----
if (m.startsWith('qwq')) return { family: 'qwen', generation: 'qwq' };
if (m.startsWith('qvq')) return { family: 'qwen', generation: 'qvq' };
if (m.startsWith('codeqwen')) return { family: 'qwen' };
if (m.startsWith('qwen')) {
const g =
m.match(/^qwen-?([123](?:\.\d+)?)(?![0-9b])/) || // qwen3.5-plus / qwen-3-14b / qwen2-7b / qwen1.5
m.match(/^qwen([23](?:\.\d+)?):/) || // qwen2.5:72b
m.match(/^qwen([23])p(\d)/); // qwen2p5 -> handled below
if (/^qwen(\d)p(\d)/.test(m)) {
const p = m.match(/^qwen(\d)p(\d)/)!;
return { family: 'qwen', generation: `qwen${p[1]}.${p[2]}` };
}
if (g) return { family: 'qwen', generation: `qwen${g[1]}` };
return { family: 'qwen' }; // qwen-max/plus/turbo/vl rolling aliases
}
// ---- deepseek ----
if (/^(deepseek|azure-deepseek|pro-deepseek)/.test(m) || m.startsWith('deepseek_')) {
const s = m.replace(/^pro-/, '').replaceAll('_', '-');
if (s.startsWith('deepseek-r1-distill'))
return { family: 'deepseek', generation: 'deepseek-r1-distill' };
if (s.startsWith('deepseek-r1')) return { family: 'deepseek', generation: 'deepseek-r1' };
const g = s.match(/^deepseek-(?:chat-)?v(\d(?:\.\d)?)/);
if (g) return { family: 'deepseek', generation: `deepseek-v${g[1]}` };
if (/^deepseek-(coder-v2|coder)/.test(s))
return { family: 'deepseek', generation: 'deepseek-coder' };
return { family: 'deepseek' }; // deepseek-chat / reasoner rolling aliases
}
// ---- meta llama ----
if (m.startsWith('codellama')) return { family: 'llama', generation: 'codellama' };
if (/^(meta-)?llama|^l3(\d)?-|^llava/.test(m)) {
if (m.startsWith('llava')) return { family: 'llava' };
const s = m.replace(/^meta-/, '');
const g =
s.match(/^llama-?([234])(?:[.-](\d))?(?![0-9b])/) || // llama-3.1 / llama3.3 / llama-4
s.match(/^llama-?v([234])p?(\d)?/) || // llama-v3p1
s.match(/^llama([234])[.:-](\d)?/);
if (g) {
const gen = g[2] ? `llama-${g[1]}.${g[2]}` : `llama-${g[1]}`;
return { family: 'llama', generation: gen };
}
if (m.startsWith('l3-')) return { family: 'llama', generation: 'llama-3' };
if (m.startsWith('l31-')) return { family: 'llama', generation: 'llama-3.1' };
return { family: 'llama' };
}
// ---- zhipu ----
if (/^(zai-)?glm/.test(m)) {
const s = m.replace(/^zai-/, '');
if (s.startsWith('glm-z1')) return { family: 'glm', generation: 'glm-z1' };
if (s.startsWith('glm-zero')) return { family: 'glm', generation: 'glm-zero' };
const g = s.match(/^glm-(\d(?:\.\d)?)/);
if (g) return { family: 'glm', generation: `glm-${g[1]}` };
return { family: 'glm' };
}
if (/^(charglm|codegeex|emohaa)/.test(m)) return { family: m.match(/^[a-z]+/)![0] };
// ---- mistral ----
if (
/^(open-)?(mistral|mixtral|ministral|codestral|devstral|magistral|pixtral|mathstral|labs-devstral|labs-leanstral|open-codestral)/.test(
m,
)
) {
const fam = m.replace(/^(open-|labs-)/, '').match(/^[a-z]+/)![0];
return { family: fam };
}
// ---- xai ----
if (m.startsWith('grok')) {
const g = m.match(/^grok-(\d(?:\.\d+)?)/);
return { family: 'grok', generation: g ? `grok-${g[1]}` : undefined };
}
// ---- moonshot ----
if (m.startsWith('kimi')) {
const g = m.match(/^kimi-k(\d(?:\.\d)?)/);
return { family: 'kimi', generation: g ? `kimi-k${g[1]}` : undefined };
}
if (m.startsWith('moonshot-kimi-k2')) return { family: 'kimi', generation: 'kimi-k2' };
if (m.startsWith('moonshot-v1')) return { family: 'kimi', generation: 'moonshot-v1' };
// ---- minimax ----
if (m.startsWith('minimax')) {
if (m.startsWith('minimax-text')) return { family: 'minimax', generation: 'minimax-text-01' };
const g = m.match(/^minimax-m(\d(?:\.\d)?)/);
return { family: 'minimax', generation: g ? `minimax-m${g[1]}` : undefined };
}
if (m.startsWith('abab')) return { family: 'minimax', generation: 'abab' };
// ---- baidu ----
if (m.startsWith('ernie')) {
if (m.startsWith('ernie-x1')) return { family: 'ernie', generation: 'ernie-x1' };
const g = m.match(/^ernie-(\d\.\d)/);
return { family: 'ernie', generation: g ? `ernie-${g[1]}` : undefined };
}
if (m.startsWith('qianfan')) return { family: 'qianfan' };
// ---- bytedance ----
if (m.startsWith('doubao')) {
const g = m.match(/^doubao-seed-(\d[.-]\d|\d)/) || m.match(/^doubao-(\d\.\d)/);
return { family: 'doubao', generation: g ? `doubao-${g[1].replace('-', '.')}` : undefined };
}
if (/^(seed-oss|skylark)/.test(m)) return { family: m.startsWith('seed') ? 'doubao' : 'skylark' };
// ---- tencent ----
if (m.startsWith('hunyuan')) {
const g = m.match(/^hunyuan-(\d\.\d)/);
return { family: 'hunyuan', generation: g ? `hunyuan-${g[1]}` : undefined };
}
if (m.startsWith('hy3')) return { family: 'hunyuan', generation: 'hunyuan-3' };
// ---- others (family only / simple version) ----
if (m.startsWith('yi-')) return { family: 'yi' };
if (/^(command|c4ai-command)/.test(m)) return { family: 'command' };
if (/^(aya|c4ai-aya)/.test(m)) return { family: 'aya' };
if (/^phi-?(\d)?/.test(m) && m.startsWith('phi')) {
const g = m.match(/^phi-?(\d(?:\.\d)?)/);
return { family: 'phi', generation: g ? `phi-${g[1]}` : undefined };
}
if (m.startsWith('wizardlm')) return { family: 'wizardlm' };
if (m.startsWith('step-')) {
const g = m.match(/^step-(?:r1|(\d(?:\.\d)?))/);
return { family: 'step', generation: g?.[1] ? `step-${g[1]}` : undefined };
}
if (/^(internlm|intern-)/.test(m)) return { family: 'intern' };
if (m.startsWith('internvl')) return { family: 'internvl' };
if (m.startsWith('baichuan')) {
const g = m.match(/^baichuan-?(m?\d)/);
return { family: 'baichuan', generation: g ? `baichuan-${g[1]}` : undefined };
}
if (/^(sensechat|sensenova)/.test(m)) return { family: 'sensenova' };
if (/^(spark|generalv|4\.0ultra)/.test(m)) return { family: 'spark' };
if (/^(360gpt|360zhinao)/.test(m)) return { family: '360zhinao' };
if (/^(jamba|ai21-jamba)/.test(m)) return { family: 'jamba' };
if (m.startsWith('sonar')) return { family: 'sonar' };
if (/^(nova-lite|nova-micro|nova-pro)/.test(m)) return { family: 'nova' };
if (/^(ling|ring)-/.test(m)) return { family: m.match(/^[a-z]+/)![0] };
if (m.startsWith('longcat')) return { family: 'longcat' };
if (m.startsWith('mimo')) return { family: 'mimo' };
if (m.startsWith('taichu')) return { family: 'taichu' };
if (/^(hermes|nous-hermes)/.test(m)) return { family: 'hermes' };
if (m.startsWith('solar')) return { family: 'solar' };
if (m.startsWith('kat-coder')) return { family: 'kat-coder' };
if (m.startsWith('dbrx')) return { family: 'dbrx' };
if (m.startsWith('morph')) return { family: 'morph' };
return undefined;
};
const map: Record<string, R> = {};
const pairs = new Map<string, number>();
let derived = 0;
for (const id of ids) {
const r = derive(id);
if (!r) continue;
derived++;
map[id] = r;
const key = `${r.family} :: ${r.generation ?? '—'}`;
pairs.set(key, (pairs.get(key) || 0) + 1);
}
console.log(`derived ${derived}/${ids.length}`);
for (const [k, n] of [...pairs.entries()].sort()) console.log(String(n).padStart(4), k);
if (process.argv.includes('--emit')) {
writeFileSync('/tmp/family-map.json', JSON.stringify(map, null, 1));
console.log('\nwritten /tmp/family-map.json');
}
@@ -0,0 +1,23 @@
/**
* Extract unique normalized chat-model ids from packages/model-bank/src/aiModels/*.ts.
* Normalization: last path segment, lowercased (matches the apply codemods).
*
* Usage (repo root): bun .agents/skills/model-bank-metadata/scripts/extract-model-ids.ts [out.json]
* Default output: /tmp/model-ids.json
*/
import { readdirSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
const dir = resolve('packages/model-bank/src/aiModels');
const out = process.argv[2] || '/tmp/model-ids.json';
const ids = new Set<string>();
for (const f of readdirSync(dir).filter((f) => f.endsWith('.ts'))) {
const mod = await import(join(dir, f));
for (const m of mod.default || []) {
if (!m?.id || m.type !== 'chat') continue;
ids.add(m.id.split('/').pop()!.toLowerCase());
}
}
writeFileSync(out, JSON.stringify([...ids].sort(), null, 1));
console.log(`${ids.size} unique normalized chat ids -> ${out}`);
+80 -1
View File
@@ -1,6 +1,6 @@
---
name: pr
description: "Create a PR for the current branch (targets `canary` by default). Use when the user asks to create a pull request, submit a PR, or says 'pr'. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', '提 PR', '提个 PR', '新建 PR'."
description: "Create a PR for the current branch (targets `canary` by default), including splitting one cross-layer branch into ordered stacked PRs so a lower layer (db / shared package / server TRPC) merges before its callers (desktop / CLI / UI). Use when the user asks to create / submit a PR, or to split a branch because clients call a server contract that isn't on the trunk yet. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', 'split this PR', 'stacked PR', 'backend should merge first', '提 PR', '提个 PR', '新建 PR', '拆 PR', '后端先合', '分层合并'."
user-invocable: true
---
@@ -71,3 +71,82 @@ Use `.github/PULL_REQUEST_TEMPLATE.md` as the body structure. Key sections:
- **Language**: All PR content must be in English
- If a PR already exists for the branch, inform the user instead of creating a duplicate
---
# Stacked PRs (cross-layer feature)
The steps above create **one** PR for the current branch. When a single branch lands across layers — `packages/database` schema/model → a shared `packages/*` lib → `src/server` TRPC → `apps/desktop` + `apps/cli` callers → `src/features` UI — shipping it as one PR can't merge safely: the clients call an endpoint that doesn't exist on the trunk until the same PR merges, so any partial/rollback or independent review breaks. Split it into **ordered PRs**, lower layer first.
## The ordering rule
A PR may only merge **after** every layer it calls is already on the trunk.
- The **server contract** (new TRPC procedure, changed return shape, new table/model) merges first.
- The **callers** (desktop, CLI, UI) merge after — they invoke that contract.
- Tie-break with one question: _"if this merged alone to `canary` right now, would it build and behave?"_ If no, it belongs in a later PR.
## Which file goes in which PR
The non-obvious calls:
- **Frontend that adapts to a contract change goes WITH the server PR.** If you widen a TRPC return shape (e.g. `listDevices` now returns `platform: string | null`), the component consuming it must change in the _same_ PR — otherwise the server PR breaks the build on its own. Contract + its in-repo consumers ship together.
- **A new shared package goes with its consumer**, not the server, unless the server imports it too. A `@lobechat/*` package imported only by desktop/CLI ships in the client PR. Don't carry an unused package in the lower PR.
- **Workspace dep declarations** (`package.json` `workspace:*`, `pnpm-workspace.yaml`) travel with the code that imports the package.
## The git recipe — split an existing full branch
Starting point: one branch (`feat/x`) with a single commit `<FULL>` containing everything, already pushed (so it's also safe on the remote).
```bash
# 1. Safety nets — make the full work unloseable before rewriting anything
git branch backup/x-full <FULL> # local ref to the full commit
git branch feat/x-clients <FULL> # the higher-layer branch starts here
# 2. Rewrite the lower-layer branch to lower-layer files only
git checkout feat/x # this becomes the SERVER PR
git reset --hard origin/canary
git checkout <FULL> -- <server/db files…> # stages just those paths
git commit -m "✨ feat(...): <server half>"
git push --force-with-lease origin feat/x # never --force; never push to canary
# 3. Build the higher-layer branch STACKED on the lower branch
git checkout feat/x-clients
git reset --hard feat/x # base = the just-rewritten server HEAD
git checkout backup/x-full -- <client/ui files…> # only the remaining paths
git commit -m "✨ feat(...): <client half>"
git push -u origin feat/x-clients
```
Then open the higher PR **based on the lower branch**, not the trunk:
```bash
gh pr create --base feat/x --head feat/x-clients --title "…" --body "…"
```
`--base feat/x` keeps the diff client-only (no server files leak in) and makes it physically impossible to merge the clients before the server. **After the server PR merges to `canary`, retarget the client PR's base to `canary`** (GitHub usually auto-retargets when the base branch merges; note it in the PR body so a human confirms).
## Verify the dependency actually holds
The whole point is the higher layer needs the lower one. Prove it: on the stacked higher branch, type-check the caller and confirm the symbol the lower layer introduced resolves.
```bash
cd apps/cli && bun run type-check 2>&1 | grep -iE "connect\.ts|device\.register"
# empty (re: your change) = the stacked base supplies device.register ✓
```
Filter to your touched files — this repo's standalone type-check emits pre-existing env noise (`__ELECTRON__`, `@/types/llm`, unbuilt `@lobechat/types`) that isn't yours.
## PR + Linear bookkeeping
- **Each PR closes only its own layer's issues.** Server PR: `Closes LOBE-<server>`. Client PR: `Closes LOBE-<pkg> / <desktop> / <cli>`. Don't let one PR's body claim another layer's issue.
- Both PRs are `Part of LOBE-<parent>`.
- On PR creation, move each closed sub-issue to **In Review** (not Done) and add a completion comment — see the `linear` skill.
## Gotchas
- **Never push to `canary`.** A split branch cut with `git checkout -b feat/x origin/canary` _tracks_ `origin/canary`, so a bare `git push` targets canary. Always `git push origin feat/x` with the explicit branch name.
- **`--force-with-lease`, not `--force`** when rewriting the lower branch — it aborts if the remote moved under you.
- **Back up before `reset --hard`.** Step 1's `backup/x-full` + the pushed remote branch mean the full commit is referenced by ≥3 refs before you rewrite anything. Verify with `git branch --contains <FULL>`.
- **Lockfiles:** this monorepo commits no root `pnpm-lock.yaml`, so a new `workspace:*` dep needs no lockfile churn. In a repo that _does_ commit one, regenerate it on each branch after the split.
- **Don't over-split.** Two PRs (contract / callers) is usually enough. A UI page that only reads an existing endpoint can be its own later PR, but don't fragment a single layer across PRs for its own sake.
+24 -23
View File
@@ -1,6 +1,6 @@
---
name: project-overview
description: "LobeHub open-source monorepo architecture map — flat `apps/` + `packages/@lobechat/*` + `src/` layout, per-layer location table, and `src/business/` stubs that the cloud repo overrides. Use when exploring an unfamiliar part of the codebase, locating where a layer lives (store / service / router / schema / etc.), or onboarding to the monorepo. Triggers on 'where does X live', 'project structure', 'monorepo layout', `src/business/` stub, 'architecture overview', '项目结构', '架构总览'."
description: 'LobeHub open-source monorepo architecture map. Use when locating code layers, understanding apps/packages/src layout, business stubs, project structure, or onboarding to the repository.'
user-invocable: false
---
@@ -56,7 +56,8 @@ git submodules.
├── apps/
│ ├── cli/ # LobeHub CLI
│ ├── desktop/ # Electron desktop app
── device-gateway/ # Device gateway service
── device-gateway/ # Device gateway service
│ └── server/ # Next.js-backed server: featureFlags, globalConfig, modules, routers, services, utils, workflows (`@/server/*` alias)
├── docs/ # changelog, development, self-hosting, usage
├── locales/ # en-US, zh-CN, ...
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
@@ -85,32 +86,32 @@ git submodules.
├── business/ # Open-source stubs (client/server) — cloud repo provides real impls
├── features/ # Domain business components
├── store/ # ~30 zustand stores — `ls` for the full set
├── server/ # featureFlags, globalConfig, modules, routers, services, workflows, agent-hono
├── server/ # standalone-Hono server pieces only: agent-hono, workflows-hono (main backend lives in `apps/server`)
└── ... # components, hooks, layout, libs, locales, services, types, utils
```
## Architecture Map
| Layer | Location |
| ---------------- | --------------------------------------------------- |
| UI Components | `src/components`, `src/features` |
| SPA Pages | `src/routes/` |
| React Router | `src/spa/router/` |
| Global Providers | `src/layout` |
| Zustand Stores | `src/store` |
| Client Services | `src/services/` |
| REST API | `src/app/(backend)/webapi` |
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
| Server Services | `src/server/services` (can access DB) |
| Server Modules | `src/server/modules` (no DB access) |
| Feature Flags | `src/server/featureFlags` |
| Global Config | `src/server/globalConfig` |
| DB Schema | `packages/database/src/schemas` |
| DB Model | `packages/database/src/models` |
| DB Repository | `packages/database/src/repositories` |
| Third-party | `src/libs` (analytics, oidc, etc.) |
| Builtin Tools | `packages/builtin-tool-*`, `packages/builtin-tools` |
| Open-source stub | `src/business/*`, `packages/business/*` (this repo) |
| Layer | Location |
| ---------------- | -------------------------------------------------------- |
| UI Components | `src/components`, `src/features` |
| SPA Pages | `src/routes/` |
| React Router | `src/spa/router/` |
| Global Providers | `src/layout` |
| Zustand Stores | `src/store` |
| Client Services | `src/services/` |
| REST API | `src/app/(backend)/webapi` |
| tRPC Routers | `apps/server/src/routers/{async\|lambda\|mobile\|tools}` |
| Server Services | `apps/server/src/services` (can access DB) |
| Server Modules | `apps/server/src/modules` (no DB access) |
| Feature Flags | `apps/server/src/featureFlags` |
| Global Config | `apps/server/src/globalConfig` |
| DB Schema | `packages/database/src/schemas` |
| DB Model | `packages/database/src/models` |
| DB Repository | `packages/database/src/repositories` |
| Third-party | `src/libs` (analytics, oidc, etc.) |
| Builtin Tools | `packages/builtin-tool-*`, `packages/builtin-tools` |
| Open-source stub | `src/business/*`, `packages/business/*` (this repo) |
## Data Flow
+5 -1
View File
@@ -1,6 +1,6 @@
---
name: react
description: "LobeHub React component conventions — base-ui (`@lobehub/ui/base-ui`) first for headless primitives (Select, Modal, DropdownMenu, ContextMenu, Popover, ScrollArea, Switch, Toast, FloatingSheet), then `@lobehub/ui` root, antd as last resort; styling via `antd-style` `createStaticStyles` + `cssVar.*` (zero-runtime preferred over `createStyles` + `token`); routing via `react-router-dom` (not `next/link`). Use when writing or editing any `.tsx` under `src/**`. Triggers on `createStaticStyles`, `createStyles`, `cssVar`, `antd-style`, `Flexbox`, `Center`, `Select`, `Modal`, `Drawer`, `Button`, `Tooltip`, `DropdownMenu`, `ContextMenu`, `Popover`, `Switch`, `ScrollArea`, `Toast`, `FloatingSheet`, `Link`, `useNavigate`, `react-router-dom`, `next/link`, `desktopRouter`, `componentMap.desktop`, `.desktop.tsx`, `base-ui`, `@lobehub/ui/base-ui`, 'new component', 'new page', 'edit layout', 'add styles', 'zustand selector', '@lobehub/ui', 'antd import'."
description: 'LobeHub React component conventions. Use when editing TSX UI, choosing base-ui vs @lobehub/ui vs antd, styling with antd-style, routing, desktop variants, layouts, or component state.'
user-invocable: false
---
@@ -53,6 +53,10 @@ For Modal specifically, see the dedicated **modal** skill — use the imperative
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
| Navigation | Burger, Menu, SideNav, Tabs |
## State
When a feature component manages more than 3 pieces of state (`useState`/`useReducer`/derived state), extract the logic into a custom hook (e.g. `useXxx`). Keep the component focused on rendering — the hook holds state and handlers, so logic can be unit-tested without rendering the component.
## Layout
Use `Flexbox` and `Center` from `@lobehub/ui`. See `references/layout-kit.md` for full props and examples.
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: response-compliance
description: OpenResponses API compliance testing. Use when testing the Response API endpoint, running compliance tests, or debugging Response API schema issues. Triggers on 'compliance', 'response api test', 'openresponses test'.
description: 'OpenResponses API compliance testing. Use for Response API endpoint tests, compliance runs, schema debugging, response api test, or openresponses test tasks.'
---
# OpenResponses Compliance Test
+2 -1
View File
@@ -1,6 +1,6 @@
---
name: review-checklist
description: "Common recurring mistakes in LobeHub code review — `console.*` leftovers, missing `return await`, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs `@lobehub/ui`, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing a PR, diff, or branch change. Triggers on 'code review', 'review the diff', 'review this PR', 'review changes', 'PR review checklist', '审一下', '审 PR'."
description: 'LobeHub code review checklist. Use when reviewing a PR, diff, or branch for console leftovers, return await, secrets, i18n, desktop router drift, UI imports, migrations, or cloud impact.'
user-invocable: false
---
@@ -22,6 +22,7 @@ user-invocable: false
- Bug fixes must include tests covering the fixed scenario
- New logic (services, store actions, utilities) should have test coverage
- **New database Model/Repository** (`packages/database/src/models/**`, `src/repositories/**`) must ship a sibling `__tests__/<name>.test.ts` — incl. user-isolation tests; BM25 search guarded by `describe.skipIf(!isServerDB)` (see `/testing``db-model-test.md`)
- Existing tests still cover the changed behavior?
- Prefer `vi.spyOn` over `vi.mock` (see `/testing` skill)
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: skills-audit
description: Weekly audit of `.agents/skills/*/SKILL.md` — surfaces duplicate / overlapping / stale skills, inconsistent descriptions, broken cross-references, and merge/delete candidates. Run as a recurring health-check, not during normal feature work.
description: 'Audit .agents/skills SKILL.md files. Use for recurring checks of duplicate, overlapping, stale, inconsistent, or broken skills and merge/delete candidates.'
disable-model-invocation: true
argument-hint: '[--verbose | --apply]'
---
@@ -50,7 +50,7 @@ Common false positives (do NOT merge):
- `db-migrations` vs `drizzle` — distinct workflows (migration files vs schema authoring).
- `microcopy` vs `i18n` — content vs mechanics.
- `agent-runtime-hooks` vs `agent-tracing` vs `agent-signal` — different surfaces of the agent system.
- `testing` vs `local-testing` vs `cli-backend-testing` — different test types.
- `testing` vs `agent-testing` — different test types.
### 4 — Description format consistency
+22 -1
View File
@@ -1,6 +1,6 @@
---
name: spa-routes
description: "SPA roots-vs-features split for LobeHub — thin route segments under `src/routes/` delegate to domain components under `src/features/`. Use when editing `src/routes/` segments, `src/spa/router/desktopRouter.config.tsx` or `desktopRouter.config.desktop.tsx` (MUST update both together — `desktopRouter.sync.test.tsx` enforces this), `mobileRouter.config.tsx`, `popupRouter.config.tsx`, or moving UI/logic between `routes/` and `features/`. Triggers on `desktopRouter.config`, `mobileRouter.config`, `popupRouter.config`, `src/routes/**`, `src/features/**`, 'add a route', 'new page', 'route segment', '路由'."
description: 'LobeHub SPA route architecture. Use when editing src/routes, src/features delegation, desktop/mobile/popup router configs, .desktop variants, route segments, redirects, or new pages.'
user-invocable: false
---
@@ -94,6 +94,27 @@ Anything that changes the tree (new segment, renamed `path`, moved layout, new c
---
## 3b. Other `.desktop.{ts,tsx}` variants inside `src/routes/`
The router pair is **not** the only `.desktop` variant pattern in this repo. Some route trees colocate a `<name>.desktop.{ts,tsx}` next to its base `<name>.{ts,tsx}` — Vite's resolver swaps in the `.desktop` file for Electron builds. Same drift risk as the router pair: editing only one side can break Electron silently.
Known variants today:
| Base file (web) | Desktop file (Electron) | Purpose |
| ----------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` | Settings tab → component map. Web uses dynamic `import()`; desktop uses sync imports. `componentMap.sync.test.ts` enforces identical keys. |
| `src/routes/(main)/agent/index.tsx` | `src/routes/(main)/agent/index.desktop.tsx` | Page entry. Desktop variant overrides the web page wholesale (e.g. extra popup guards). |
| `src/routes/(main)/group/index.tsx` | `src/routes/(main)/group/index.desktop.tsx` | Same pattern as agent. |
**Rules:**
1. After editing **any** `.ts`/`.tsx` under `src/routes/`, glob the same directory for a `<filename>.desktop.{ts,tsx}` sibling. If one exists, apply the equivalent change there in the same commit.
2. When adding a new SettingsTab, register it in **both** `componentMap.ts` (with `dynamic(...)`) and `componentMap.desktop.ts` (with a sync `import`). `componentMap.sync.test.ts` will fail the build otherwise.
3. When adding a new desktop-only page wholesale-override, prefer a single base file with platform-aware code over introducing a new `.desktop.tsx` variant — only add a new variant when the two trees genuinely diverge (different store wiring, different popup guards, etc.).
4. When deleting, remove **both** files together.
---
## 4. How to Divide Files (route vs feature)
| Question | Put in `src/routes/` | Put in `src/features/` |
@@ -1,6 +1,6 @@
---
name: store-data-structures
description: "Zustand store data-shape patterns for LobeHub — List vs Detail split, Map + Reducer, type definitions sourced from `@lobechat/types` (not `@lobechat/database`). Use when designing store state, choosing between Array (list) and `Record<string, Detail>` (detail map), or implementing a list/detail page pair. Triggers on `messagesMap`, `topicsMap`, `Record<string, Detail>`, 'list vs detail', 'store data shape', 'normalize state', 'state structure'."
description: 'LobeHub Zustand store data-shape patterns. Use when designing store state, list/detail splits, normalized maps, reducers, messagesMap, topicsMap, or choosing shared type sources.'
user-invocable: false
---
+9 -3
View File
@@ -1,6 +1,6 @@
---
name: testing
description: Testing guide using Vitest. Use when writing tests (.test.ts, .test.tsx), fixing failing tests, improving test coverage, or debugging test issues. Triggers on test creation, test debugging, mock setup, or test-related questions.
description: 'Vitest testing guide. Use when writing or updating tests, fixing failing tests, improving coverage, debugging test issues, or setting up mocks.'
user-invocable: false
---
@@ -14,15 +14,21 @@ user-invocable: false
# Run specific test file
bunx vitest run --silent='passed-only' '[file-path]'
# Database package (client)
# Database package (client-db, PGlite — default, skips BM25/pg_search)
cd packages/database && bunx vitest run --silent='passed-only' '[file]'
# Database package (server)
# Database package (server-db, Postgres — BM25/pgvector parity, what CI measures coverage in)
cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' '[file]'
```
**Never run** `bun run test` - it runs all 3000+ tests (\~10 minutes).
> **Database models/repositories:** every new file under `packages/database/src/models/**`
> or `src/repositories/**` ships with a sibling `__tests__/<name>.test.ts` in the same PR.
> Use the real DB via `getTestDB()` (integration style), guard BM25/full-text-search blocks
> with `describe.skipIf(!isServerDB)`, and always test user-isolation. See
> `references/db-model-test.md` for setup, schema gotchas, and the client-vs-server-db split.
## Test Categories
| Category | Location | Config |

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