* 🐛 fix(openapi): exempt heterogeneous relay routes from spec parity
`POST /api/v1/anthropic/v1/messages` and `POST /api/v1/openai/v1/responses`
are registered without `describeRoute`, so the generator's parity check has
been failing since #18558 and `openapi.yml` could no longer be regenerated.
Both relays speak the upstream Anthropic/OpenAI wire formats and are gated by
`requireHeteroModelInvocation` (operation JWT), so no API key holder can reach
them and their schemas are not LobeHub's to publish. Exempt them the same way
the documentation-serving routes already are, instead of describing endpoints
that are not part of the public API surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmyR6qUiKJX9VteSJR1hiu
* ✨ feat(openapi): add eval read routes for runs, datasets and topics
`/api/v1/eval` could create a run and read that one run back, but offered no
way to discover what already exists, so API consumers had to know a run id out
of band. Add the four missing read endpoints:
- `GET /eval/runs` (paginated, filterable by dataset and status)
- `GET /eval/datasets`
- `GET /eval/datasets/:id`
- `GET /eval/runs/:id/topics`
All four reuse the domain's existing posture: `AGENT_READ` RBAC, mandatory
pagination, and public-field allowlists so internal columns never reach the
response. Cross-tenant reads stay 404 rather than 403 so the endpoints do not
confirm the existence of other tenants' rows.
The models gain only the read primitives the routes need (count + paginated
list); no schema or migration changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmyR6qUiKJX9VteSJR1hiu
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 👷 build: upgrade vitest to 5.0.0
Bump vitest and @vitest/coverage-v8 to 5.0.0 and migrate the configs for Vitest 4/5: drop the removed coverage.all option and replace poolOptions.forks.singleFork with maxWorkers/isolate.
* 👷 build: shim jest-dom matcher types for vitest 5
Vitest 5 exposes Assertion<R, T> and no longer reads the jest.Matchers augmentation that @testing-library/jest-dom ships, so its matchers vanish from expect(). Re-declare them on the Vitest interface, and replace the removed describe.sequential with { concurrent: false }.
* 👷 build: migrate the test suite to vitest 5
Vitest 5 (and @vitest/coverage-v8) replaces the esbuild dep optimizer with Rolldown, removed environmentMatchGlobs/coverage.all/poolOptions, forwards `new` to mock implementations, requires top-level vi.mock, and no longer resets automocks in restoreAllMocks. Migrate the root config to app/server projects with Rolldown optimizer options, shim the jest-dom matchers on Assertion<R, T>, and update the affected tests (constructor mocks, hoisted mocks, awaited assertions, explicit mock types, per-test mock defaults).
* 👷 build: make the openapi knowledge-base FileService mock constructable
tests/ was outside the per-area sweep; vitest 5 forwards `new` to the mock implementation, so the arrow implementation threw "is not a constructor".
* 👷 build: point the CI blob-report artifacts at the vitest 5 output dir
Vitest 5 writes blob reports to .vitest/blob instead of .vitest-reports, so the sharded jobs uploaded nothing and the merge jobs failed with ENOENT. Track the new directory in .gitignore too.
* 👷 build: refresh the desktop lockfile for vitest 5
apps/desktop/pnpm-lock.yaml is committed and the desktop build action installs with --frozen-lockfile, so the vitest/override change has to be reflected in it.
* 👷 build: keep the pre-vitest-4 coverage exclusions explicit
Vitest 4+ ships an empty coverageConfigDefaults.exclude, so configs that spread it silently lost the default exclusions (node_modules, dist, *.config.*) and counted more files. Inline the previous list.
* 🎨 style: format the migrated vitest configs
* 👷 build: keep canary's new class mocks constructable under vitest 5
exploreGraph/dispatchAuthorization/scopedDevices (canary #19375, #19352) mock classes with arrow implementations; vitest 5 constructs the implementation when the mock is newed, so the arrows throw 'is not a constructor'.
* ⚡️ perf(spa): cut the editor runtime, ajv and database models from the boot path
Four package-level import edges put ~1.5 MB raw on the first screen:
- GlobalProvider/Editor.tsx imported @lobehub/editor/react, and rolldown does
not shake that entry down to EditorProvider, so lexical, yjs and fuse landed
in the entry chunk. The provider is reached by path until the package
exposes a provider-only entry.
- @lobechat/types re-exported the ajv-backed AgentGraphSchema through the
barrel that every store touches; it now lives on the
@lobechat/types/agent/graph subpath and the agent barrel re-exports types
only.
- @lobechat/context-engine's root barrel dragged the database models barrel
(drizzle) in through AgentDocumentInjector; the app reaches the
./agent-document subpath and the injector reads loadPolicy directly.
- packages/locales bundled locales/en-US/*.json on top of the default
sources it mirrors; the overlay now applies only in development.
Web production build: first-screen gzip 2299 KB -> 1968 KB.
Claude-Session: https://claude.ai/code/session_01QKr8CsFLYmrgqbRTSipTNH
* 🐛 fix(vite): keep one EditorContext in dev for the path-imported provider
Claude-Session: https://claude.ai/code/session_01QKr8CsFLYmrgqbRTSipTNH
* 🐛 fix(locales): refresh consumers after the dev English overlay lands
Claude-Session: https://claude.ai/code/session_01QKr8CsFLYmrgqbRTSipTNH
* 🐛 fix(file): reserve storage before upload
* 🐛 fix(file): gate storage quota on upload paths that skip reservations
The reservation flow only covers pre-signed tRPC uploads. Three surfaces write
`files` rows without ever consulting the quota: the OpenAPI upload endpoint,
server-side image generation, and bot-channel attachment ingest.
- OpenAPI upload checks the quota once per path that actually adds bytes, and
maps the rejection to a 403 instead of a generic 500. Re-requesting a record
the caller already owns adds nothing, so it stays exempt.
- Image generation admits at submission rather than at persist — blocking after
the model call is already paid for is worse than not blocking.
- Attachment ingest charges the post-compression buffer, and a quota skip now
says so instead of leaving the sender to guess.
Claude-Session: https://claude.ai/code/session_014iyxmqssnrPopmKV1xKJ7k
* 🐛 fix(file): release reservations on every dataset import failure
Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm
* 🐛 fix(file): stop reservations from deleting pre-existing objects
Reject a reservation whose pathname already holds an object, so aborting one
can only ever delete a key it created. Serialize server-owned uploads with the
owner-row lock by threading the transaction through the quota check and the
file row it authorizes.
Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm
* 🐛 fix(file): keep the S3 write out of the quota transaction
Holding the owner-row lock across the upload blocked every other write to that
user for the length of the transfer, and Postgres kills the transaction outright
wherever DATABASE_STATEMENT_TIMEOUT is set, orphaning the object it had already
written. Check the quota once before spending the transfer, upload outside any
transaction, then re-check under the lock the file row is written with.
Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm
* ♻️ refactor(cli): collect every product-naming string into one module
The CLI's identity was spread across a dozen files: bin names in
`utils/completion.ts`, the config directory duplicated in five places, the
systemd unit name defined twice, the product name in a handful of command
descriptions, and — least visibly — inside `buildNotifyProtocol`, a prompt
handed to heterogeneous agents that the model then repeats back to the
user.
That is fine for a single distribution and leaves a white-label build with
no seam. Several of these are reached at runtime along paths no UI gating
can intercept, and one of them is a prompt, so there is nothing to gate.
`src/constants/identity.ts` now holds all of them. Values are upstream's,
unchanged, and the default build's observable output is identical —
verified by building with this repo's own config and diffing what the CLI
prints:
Usage: lh [options] [command]
LobeHub CLI - manage and connect to LobeHub services
complete -o nosort -F _lobehub_completion lh lobe lobehub
lh - LobeHub CLI - manage and connect to LobeHub services
Two things that were already wrong, fixed on the way:
- `skillLocator.ts` compared `pkg.name` against a hardcoded
`'@lobehub/cli'`, so the package-root walk breaks for any fork or
rename. It reads `cliPackageName` now, which is the same value from
the same package.json.
- `constants/auth.ts` exposed a single API-key env var name. It is a
list now, most-preferred first, so a distribution can introduce its
own prefix without breaking a variable a user already exported. Only
USER-set variables moved; `LOBEHUB_SERVER`, `LOBEHUB_JWT` and the
agent-run ids are injected by the desktop app and the server, so
renaming those needs all three sides to move together and is left
alone.
`man/roff.ts` also stops hardcoding `LH` and `@lobehub/cli` in the roff
header, taking them from the bin name and `pkg.ts` instead — the man page
described a command that would not exist under any other name.
* ♻️ refactor(api-key): make the key prefix a slot instead of a literal
`sk-lh-` was hardcoded in four places and asserted in four test files. It
is the one brand string a deployment's end users actually handle — they
copy it out of the UI, paste it into CI config, quote it in support
threads — so on a white-label distribution it undoes the rebranding
everywhere else.
It is also load-bearing rather than cosmetic. `validateApiKeyFormat` is
what the OpenAPI auth middleware uses to tell an API key from an OIDC
JWT, so generation and validation must read the same value or every
issued key fails auth as a malformed JWT. That is why the prefix is one
constant now, used by both.
`API_KEY_PREFIX` defaults to `sk-lh-`, so upstream's behaviour is
unchanged — verified by generating a key and round-tripping it through
the validator.
Two deliberate choices in the implementation:
- Validation compares the prefix with `startsWith` and tests only the
16-character suffix against a static pattern, rather than
interpolating the prefix into a RegExp. The prefix is data flowing
into a pattern, and escaping it correctly is a thing to get wrong;
this way there is nothing to escape and the check stays correct for
whatever prefix a distribution picks.
- The tests derive their fixtures from the constant instead of
hardcoding a spelling, so they pass for any prefix. Several of them
fed a literal straight into `validateApiKeyFormat`, so they would
have gone red the moment anyone overrode it — failing on the
override rather than on a real defect.
`spec.ts`'s `bearerFormat` follows the constant too; it is the public
API document, so it was advertising a format the deployment no longer
issues.
* ♻️ refactor(cli): collect the completion word-index env var into identity.ts
CLI_COMPLETION_CWORD_ENV joins the other product-naming strings in
identity.ts. `LOBEHUB_COMP_CWORD` was the one string the earlier
identity refactor (a65fa6a9) missed — it's set by the generated
completion script and read right back by `__complete` in the same
process, so it never surfaces in help text and slipped past every
rendered-output check.
Purely internal plumbing (unlike CLI_API_KEY_ENV_NAMES), so no
back-compat list is needed: both sides always come from the one
installed binary.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* 🛡️ fix(api-key): fall back to sk-lh- when a distribution override omits the slot
A downstream `@lobechat/business-const` override replaces the whole module
instead of extending it, so one written before this slot existed simply
omits API_KEY_PREFIX rather than inheriting upstream's default — that
either fails typecheck or, if typecheck is skipped, crashes key generation
and validation on undefined.length.
Resolve it once in packages/utils/apiKey.ts with an explicit runtime
fallback to 'sk-lh-', and have every other consumer (openapi spec, tests)
import the resolved constant from there instead of the raw slot.
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* ✨ feat(agent): let agent authors restrict who can share their topics
Add a per-agent `topicSharePolicy` on `agencyConfig`. `member` (the
default, and what legacy rows without the field resolve to) keeps today's
behaviour: any workspace member may publish a share link for any of the
agent's topics they can open. `restricted` reserves publishing to the
agent's creator and workspace owners.
Only publishing is gated — revoking a link and the private placeholder the
share popover creates on open stay open to everyone, so taking a topic out
of circulation is always possible.
Enforced server-side in the topic share procedures; the Permission page
(agent + group) writes it, and the share controls mirror it so a restricted
member sees a disabled option with a reason instead of a request that fails.
* 🌐 style(i18n): sync generated locales
* 🐛 fix(agent): stop the Permission page offering policy edits it cannot save
A workspace Admin holds `agent:update:all`, so `canManage` is true and the
Permission page enabled all three policy selects for them. The server accepts
those keys only from the agent's creator or the workspace owner, and strips
them from an otherwise successful mutation — so the choice was accepted,
reported no error, and silently discarded.
Gate the model, execution-target and topic-share rows on the same authority
the server uses, and swap their description for the existing
"only the creator or a workspace owner" line so an Admin can still see what
exists and who to ask.
* 🐛 fix(openapi): merge JSON config patches instead of replacing them
`PATCH /api/v1/agents/:id` assigned `agencyConfig` and `chatConfig` straight
onto the column, but the request schemas expose only a slice of each: two of
`agencyConfig`'s keys (graph mode) and 13 of `LobeAgentChatConfig`'s 37. Any
edit therefore deleted every field the caller had no way to send — the member
permission policies, device bindings and execution settings on one, two dozen
chat settings on the other.
For the topic-share policy that meant an unrelated graph edit silently
reopened a restricted agent's conversations to every workspace member, since
a missing value resolves to `member`.
Merge both through one `mergeJsonPatch` helper, which `params` already did
inline; `undefined` still drops a key and `null` is still kept as the
schemas' explicit "cleared" value.
* 🐛 fix(agent): apply the topic-share policy to group topics too
`createTopic` accepts a `groupId` with no agent and no session, so a group
topic can carry neither — and `resolveTopicShareAgent` read `agentId` first,
resolved nothing, and let the publish gate fall open. The group Permission
page writes the policy onto the group's supervisor, so a restricted group's
conversations could still be published by any member who could open them.
Resolve a topic's group to its supervisor first: a group conversation is a
conversation with that supervisor, and it is the row the policy lives on.
Also keep an explicit `agencyConfig: null` / `chatConfig: null` clearing the
column in the OpenAPI update, which the new merge had turned into a no-op.
A whole-column null is a caller deliberately asking to clear; a partial edit
that cannot name the other keys is not.
* 🐛 fix(openapi): keep the member permission policies across a config clear
`PATCH /api/v1/agents/:id` authorizes on `AGENT_UPDATE`, which workspace
Admins hold for every member's agent, while writing the `agencyConfig` policy
keys is reserved to the agent's creator and the workspace primary owner — the
TRPC writer strips them from anyone else. An `agencyConfig: null` through the
public API went around that gate: the policies vanished, and a missing
`topicSharePolicy` resolves to `member`, reopening a restricted agent's topics
to the whole workspace.
The request schema cannot express those keys in the first place, so clearing
them was never something a caller could ask for. A whole-column clear now
keeps them and drops everything else.
`AGENT_PERMISSION_POLICY_KEYS` moves to `@lobechat/types` next to the config
it describes, so both writers work from one list instead of two copies.
* 🐛 fix(openapi): let an authorized caller actually clear the agent config
The previous commit kept the member permission policies across every
`agencyConfig: null`, including one sent by the agent's creator or the
workspace primary owner — the two callers `updateAgentConfig` does let write
those keys. Their clear silently did nothing, so a `restricted` policy could
not be reset through the API at all.
Resolve the same authority the TRPC writer uses and branch on it: an
authorized caller clears the column outright, anyone else keeps the policies.
The decision is a named pure function so both halves are covered by tests
rather than by mocking a transaction.
* 🐛 fix(openapi): stop denying resources that nobody owns
`POST /api/v1/chat`, `/chat/translate` and `/chat/generate-reply` answer
403 for every account without a DB role — which is every personal account.
Reproduced on app.lobehub.com and on a self-hosted deployment; both return
the identical `no permission,current user has no ALL permission,and
resource not belong to current user`. The route-level RBAC middleware
passes (an invalid body still reaches the validator and 400s), so this is
the service-layer gate, not the API key's scope.
`getResourceBelongTo` answers `undefined` for two unrelated situations: a
row that does not exist, and a row that has no per-user owner at all.
`resolveOperationPermission` collapsed both into "belongs to somebody
else" and denied before ever reaching the owner check.
The three endpoints above check `AI_MODEL_INVOKE` against
`{ targetModelId }`. Built-in models have no owner, so "does this model
belong to you" has no true answer — and the absent answer denied every
caller holding `ai_model:invoke:owner`, which is exactly what
`PERSONAL_DEFAULT_PERMISSIONS` grants.
Split the two cases:
- `ALL_SCOPE` without ALL permission → still denied. An owner-scoped grant
cannot answer an unscoped question.
- a resource owned by another user → still denied.
- no owner resolvable → falls through to the owner check, with the
returned `condition` pinned to the caller. An unowned resource therefore
cannot widen a query past the caller's own rows, and a row that really
does not exist now surfaces as "not found" rather than as a permission
error.
Also drops the target-less retry in `translate()`. It was a local
workaround for this exact gate, it can no longer change any outcome, and
leaving it in place implies its two siblings in the same file were merely
missing it.
Six tests cover the split, including the three denials that must not
loosen. Verified they fail against the previous gate: the two encoding the
new behaviour go red, the rest stay green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🐛 fix(openapi): stop crashing when a provider row stores no key
With the permission gate fixed, all three `/api/v1/chat*` endpoints reach
the model call and die there instead:
聊天对话失败: Cannot read properties of null (reading 'split')
`getApiKey` asserts `providerConfig.keyVaults!` and hands it to
`decrypt`, whose first statement is `encryptedData.split(':')`. A provider
row whose `keyVaults` is null therefore throws — from inside a helper whose
name gives no hint that a credential lookup is what failed.
That row shape is not an edge case. It is the ordinary one for any
deployment whose provider credentials come from the environment: the row
exists so the provider can be enabled and ordered, and no per-user vault is
ever written. Confirmed against a live deployment where `deepseek`,
`lobehub` and `openai` all have empty vaults, and where naming a provider
that has NO row at all got further — the `'{}'` fallback for a missing row
already did the right thing, so only the half-configured case crashed.
Both cases mean the same thing to the caller: no user-supplied key for this
provider. Return the empty vault and let the model runtime fall back to the
environment, exactly as the missing-row branch already did.
Three tests: the null vault, the absent row, and a row that does store a
key (which must still decrypt). Verified non-vacuous — restoring the `!`
turns the first one red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🐛 fix(openapi): default /chat to the product's model, not to gpt-3.5-turbo
A request to `/api/v1/chat` that names no model asked for `gpt-3.5-turbo`
on `openai`, and no caller could change that: `ChatServiceConfig` is only
reachable through the constructor and all three controller call sites pass
none. So the field was unreachable configuration, and the literals were the
behaviour.
Any deployment not running OpenAI therefore answered a model-less request
with a credential failure naming a provider the caller never mentioned —
including the default one, whose `DEFAULT_PROVIDER` is `deepseek`.
`DEFAULT_MODEL` / `DEFAULT_PROVIDER` are what `DEFAULT_AGENT_CONFIG` and the
rest of the product already resolve to. Using them here makes
`/api/v1/chat` agree with the product it is part of, and gives every
distribution the default it has already declared, with no new setting.
Test asserts the payload and the runtime both receive the constants rather
than any literal of this service's own; verified non-vacuous by restoring
the hard-coded pair.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🐛 fix(openapi): import the model defaults from where they are declared
`@/const/settings` re-exports `DEFAULT_MODEL` but not `DEFAULT_PROVIDER` —
`llm.ts` imports the latter for its own use only. Pulling both from that
barrel broke the build:
Error: Export DEFAULT_PROVIDER doesn't exist in target module
Import both from `@lobechat/business-const`, which declares them, matching
what `provider.service.ts` in this same package and
`services/verify/modelConfig.ts` already do.
The tests did not catch this: they mocked `@/const/settings` with a factory
listing both names, and a factory mock invents whatever key it is asked
for, so the missing re-export was invisible until a real bundler resolved
it. Moved the mock onto the module that actually declares them, so it can
no longer paper over the same mistake.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🐛 fix(openapi): let the chat default name any provider, not five of them
CI:
chat.service.ts(63,7): error TS2322:
Type '"deepseek"' is not assignable to type 'AIProvider | undefined'.
Casting `DEFAULT_PROVIDER` would have silenced that, and left the wrong
thing in place. `ChatServiceConfig.defaultProvider` was typed `AIProvider`
— a five-name list written by hand in the same file that types the request
field beside it as `provider?: string`, validated as `z.string().max(64)`.
So the file already said a caller may name any provider while the default
may only be one of five, and nothing downstream honours that split: both
values land in the same `ai_providers` lookup, and the runtime resolves
custom providers through `sdkType` precisely because ids are open. A list
of built-ins cannot express that set — `deepseek`, the product's own
default, was never in it.
Widened to `string` to match the request field. `AIProvider` stays exported
and is marked deprecated rather than deleted, so nothing importing it
breaks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ♻️ refactor(agent): move Graph Agent config from chatConfig to agencyConfig
A graph is an agent: the ReasoningGraph defines the agent's behavior body
(node policies, routing conditions, data contracts), so it belongs on the
agency config (how the agent behaves and executes) rather than the chat
config (per-session preferences).
- types: drop enableGraphMode/graph from LobeAgentChatConfig, add to
LobeAgentAgencyConfig next to heterogeneousProvider
- runtime: GraphAgent factory reads agencyConfig.graph with a legacy
chatConfig.graph fallback so existing rows keep running
- cli: --enable-graph / --disable-graph / --graph-file now write
agencyConfig
- db: graph full-document replace moved to agencyConfig; legacy
chatConfig.graph is forwarded to agencyConfig on the next write,
migrating old rows in place
- openapi / sdk: graph fields moved into agencyConfig, regenerated
* ♻️ refactor(agent): rename ReasoningGraph to AgentGraph
The graph is the behavior body of a Graph Agent, not a 'reasoning' data
structure — the old name was misleading. Aligns the top-level type with
its children (AgentGraphNode / AgentGraphEdge / AgentGraphField) and the
AGENT_GRAPH_ROOT_NODE_ID constant.
- ReasoningGraph -> AgentGraph
- ReasoningGraphSchema -> AgentGraphSchema
- UI copy in locales updated to match
* 🐛 fix(agent): address codex review on graph agency config
- openapi: thread agencyConfig through createAgent / updateAgent and the
public projection, so API clients can actually persist the graph instead
of getting a silent no-op
- db: an explicit agency-level graph (including null) now takes ownership
of the graph and drops the legacy chatConfig fields, so the runtime
fallback cannot resurrect a cleared snapshot
- ui: AgentGraphRuntime falls back to the legacy chatConfig values so
pre-migration rows render their effective graph instead of an empty,
disabled editor
* ✨ feat(knowledge-base): support workspace resource permissions for knowledge bases
Knowledge bases join the polymorphic resource-permission system with
`use` / `edit` levels. `use` (member No-access) soft-hides a shared KB
from members in the UI — library list, search, pages sidebar, direct
reads — while agents attached to it keep retrieval untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(knowledge-base): harden the soft-hiding surface across UI paths
- keep permission rows staged on a still-private KB/document inert until
publish, and preserve a staged access level through publishing
- exclude restricted KBs and their linked files/folders/pages from unified
search, and restricted-KB pages from the Pages listing (query-level, so
pagination and totals stay correct)
- guard direct document reads and history routes against stale restricted-KB
URLs
- return a memberRestricted flag from getKnowledgeBases so the manager lock
badge needs no per-row permission request
- move the knowledgeBaseAccess helpers to @/server/services (router _helpers
re-exports)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ✅ test(daily-brief): expect the server-provided label for the unregistered action key
#18281 switched the taskless-acceptance fixture to an unregistered key so
the server-sent label renders as-is, but left the assertion on the old
localized copy — the case (and canary) has been red since.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(openapi): expand v1 REST surface with api-keys, evals, mcp-servers and usage
Add new OpenAPI v1 resources (API keys, evals, MCP servers, usage) and
harden the existing controllers with shared validators, pagination
helpers and public-field projection so responses no longer leak internal
columns. Usage reporting goes through a business-server slot so
self-hosted deployments fall back to an unavailable summary.
* 🐛 fix(openapi): enforce row-owner checks on MCP writes and drop the unused upload pathname
The connector model's ownership predicate is workspace-wide, so the REST MCP
write paths let any member with only `agent:update:owner` patch, delete or sync
a server created by another member. The connector tRPC mutations already guard
this with `assertWorkspaceRowManageable`; add the equivalent `assertRowManageable`
to the OpenAPI BaseService and apply it before every MCP mutation.
Also remove `pathname` from the public upload schema, route docs and spec: it
never reached the storage key, and its validator allowed any 1-1024 char string,
so wiring it through would have handed clients an unchecked object path. The
batch upload schema already omitted it.
* 🐛 fix(openapi): drop request fields the v1 run paths never honor
`n` never reached ChatStreamPayload and /chat returns a single content string,
so accepting it promised multiple choices and delivered one. `caseSelection` is
storage-only for internal runs — this endpoint always creates `mode: 'internal'`,
which pre-creates topics for every case in the dataset — so a caller asking for a
subset would silently run the whole thing. `maxConcurrency` is consumed nowhere.
Remove all three from the public schemas and regenerate the spec, matching the
`pathname` removal: the contract should only advertise knobs the run path honors.
`k`, `maxSteps` and `timeout` stay because the run service does read them.
* 🐛 fix(openapi): admin-gate workspace API-key routes
`workspaceAuthMiddleware` only enforces the owner/admin role for `apikey` auth,
so an OIDC/session-authenticated workspace member holding `api_key:update:owner`
could PATCH or DELETE another member's workspace key by id — `ApiKeyModel`'s
predicate is workspace-wide, not row-owner.
The tRPC side already gates this: `apiKeyProcedure` applies
`requireWorkspaceRoleWhenScoped('admin')` to the whole router, so the REST
surface was strictly more permissive. Add the equivalent Hono middleware and
apply it to every api-keys route so both transports gate identically.
* 🐛 fix(openapi): preserve an explicit temperature of 0 on /chat
`ChatStreamPayload` was built with `params.temperature || 1`, so a request
asking for deterministic output at `temperature: 0` was silently sent at 1 —
the schema has accepted `min(0)` since the endpoint was introduced.
Pre-existing since #12758 rather than a regression here, but it is the same
contract-vs-implementation mismatch this PR has been clearing up, and the fix is
`??` instead of `||`. Scoped to the request path: `generateReply` applies the
same falsy fallback to an agent's stored config, which is a separate behavioural
change for existing agents and is left alone.
* 🐛 fix(openapi): require chat:write to queue an eval run
An internal eval run pre-creates real chat topics through `TopicModel`, and the
QStash workflow keeps writing topic/message state afterwards. The route gated
only on `agent:write` + `model:invoke`, so a restricted API key deliberately
denied `chat:write` could still persist chat artifacts through `/eval/runs`.
`/responses` already enforces the full `agent:write` + `chat:write` +
`model:invoke` trio for exactly this reason; match it here.
* 🐛 fix(openapi): correct file response schemas and regenerate the SDK
The spec fell back to the bare resource schema for any one-segment path, so
`POST /files`, `GET /files/{id}`, `POST /files/batches` and `POST /files/queries`
all advertised `File` while the controllers actually return wrapped payloads
(`{ file, parsed? }`, `{ successful, failed, summary }`, `{ files, failed, ... }`).
Generated clients would have mistyped every one of them. Describe the four
explicitly ahead of the fallback.
`packages/sdk/src/generated` was also stale — its drift test was already failing
locally, since the SDK is not in the PR CI whitelist. Regenerated it from the
corrected spec, so the new api-keys / eval / mcp-servers / usage resources are
callable and the file operations carry the right types.
* 🐛 fix(openapi): keep MCP server routes to base-scope connectors
`isPublicMcpServer` ignored `agentId`, but `queryPublic()` (the list path)
filters `agent_id IS NULL` while `findPublicById` does not. So the id routes —
GET/PATCH/DELETE and POST /:id/sync — could reach agent-scoped connectors the
list route cannot even surface.
Deleting one that way skipped the connector router's agent-plugin unpin, which
exists specifically so a delete never leaves the owning agent with a dangling
pinned tool. Require `agentId === null` in the predicate so the whole group is
consistently base-scoped; agent-scoped connectors stay managed through the agent
profile.
* 🐛 fix(openapi): stop typing named sub-operations as the group resource
The success-schema fallback treated any single-segment path as a resource
operation, so `GET /messages/count` was advertised as `Message` (it returns
`{ count }`) and `GET /users/me` as `User` (a caller without `user:read`
deliberately gets only `{ id }`). Generated SDK callers were typed against the
wrong shapes.
Narrow the fallback to the two real resource operations — the collection root
and `{id}` — and describe `messages/count`, `messages/replies` and `users/me`
explicitly. An undescribed sub-operation now degrades to a permissive object
rather than a confidently wrong resource type, which is the failure mode that
produced both this and the earlier `/files` mistyping. SDK regenerated.
Members can now issue and manage their own workspace API keys instead of
owners-only. Keys inherit the issuer's current workspace permissions, scopes
became editable after creation, and admins can restrict creation to admins
only via a workspace API key policy.
* 🐛 fix(openapi): keep /users/me reachable by restricted API keys
* 🐛 fix(openapi): keep the message count on /users/me behind chat:read
* 🐛 fix(openapi): narrow /users/me to the id for keys without user:read
* 📝 docs(openapi): describe the scoped /users/me payload in the route comment
* ✨ feat: support async agent transfer
* 🔒 fix: scope transfer-migration endpoints, dedupe polling, resume jobs at boot
* ♻️ refactor: bound transfer status polls to visible topics, keep group transfers sync
* 🐛 fix: reserve the active topic in capped transfer status polls
* 🐛 fix: guard OpenAPI user deletes during pending transfers, tighten topic gating
* 🔒 fix: require bounded topic candidates on transfer status polls
* ✨ feat(openapi): generate openapi.yml from routes via hono-openapi
- add src/common/validator.ts: drop-in zValidator built on hono-openapi so
every request schema registers into the spec while keeping the exact
@hono/zod-validator 400 response shape
- describe the four validator-less routes (users/me, agent-groups list,
file upload, batch upload incl. multipart bodies) and /health
- add scripts/generate-openapi.ts: runtime spec generation with
route-vs-spec parity check, --check mode for CI, placeholder responses
- commit the generated openapi.yml (79 operations / 38 paths) and guard
it with scripts/generate-openapi.test.ts
- ignore the generated file in eslint/prettier so formatters don't fight
the generator output
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(openapi): serve API docs at /api/v1/docs via Scalar
- extract spec assembly into src/spec.ts, shared by the generator script
and the new runtime endpoint so both always emit the same document
- GET /api/v1/openapi.json: build spec from live routes on first request,
cached in memory
- GET /api/v1/docs: Scalar interactive reference reading that endpoint
- exempt the two docs routes from the generator parity check (real
endpoints, deliberately not part of the described API surface)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 💄 style(openapi): match docs site theme to the app's neutral monochrome style
- add src/docs-theme.ts: Scalar CSS variable overrides — black accent on
light, white accent on true-black dark, antd system font stack
- theme: 'none' so the runtime bundle doesn't inject its default theme
stylesheet after our customCss (which silently overrode every variable)
- reuse the app favicon
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🌐 feat(openapi): localize docs UI following the app's language preference
Resolve the Scalar UI locale per request with the same priority the main
app uses (?hl query > LOBE_LOCALE cookie > Accept-Language), mapped to
Scalar's built-in translations (zh-CN, de, fr, es, ru, ar; zh-* all map
to zh-CN). English stays the default for unsupported locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore: optimize transfer
* 🐛 fix: re-snapshot transferred message scopes before owner deletion
- resnapshotTransferredMessagesBeforeOwnerDelete: rows whose snapshot
workspace_id disagrees with their anchor are re-materialized from the
anchor before a workspace/user row is deleted, so cascade FKs cannot
destroy history that a transfer moved away (Codex P1)
- replace RQB findFirst/findMany with plain selects at child-scope call
sites: RQB table aliasing broke the correlated EXISTS predicate (42P01)
- agentGroup copy: filter message_plugins by derived parent scope instead
of stale snapshot columns
- update stats/transcript tests to derived-scope semantics; add owner
deletion regression tests
* 🐛 fix: make scope predicates db-free and route OpenAPI user delete through resnapshot
- build predicates with a standalone QueryBuilder so they no longer need a
live db handle (fixes agentSignal tests whose db stubs lack .select)
- OpenAPI user.service.deleteUser now routes through UserModel.deleteUser
so the pre-delete resnapshot runs (Codex P1 round 2)
- dataExporter: derived-scope child tables bypass RQB findMany, which
aliases the table and breaks the correlated EXISTS (Codex P2)
* 🐛 fix: derive message scope in OpenAPI message read paths
- message.service / message-translations.service: replace snapshot-column
buildWorkspaceWhere(messages/messageTranslates) with the derived scope
predicates so transferred conversations stay readable over OpenAPI
(Codex P1 round 3)
- whole-scope counts route through MessageModel.count (index-bounded
three-arm fan-out instead of an unbounded EXISTS scan)
- messageTranslates lookups switch from RQB findFirst to plain selects
(RQB aliasing breaks the correlated EXISTS)
* 🐛 fix: derive message ownership in permission checks and remaining server readers
- BaseService.getResourceBelongTo resolves targetMessageId owner from the
topic/session anchor instead of the stale user_id snapshot (Codex P1
round 4) — permission checks now agree with the derived read scope
- convert the remaining topic-bounded server readers off snapshot filters:
agentSignal feedback context, completed-turn trajectory, and user-memory
topic extraction
- audited every other messages.user_id/workspace_id filter: the rest are
author-semantics (usage attribution, foreign-author guards, onboarding,
nightly-review windows) and intentionally keep the snapshot
* 🐛 fix: rewrite anchorless agent messages on transfer
Messages with no topic AND no session (e.g. OpenAPI-created with only an
agentId) have nothing to derive scope from — their snapshot IS
authoritative. Transfer now rewrites just these rows (bounded by agent_id,
rare by construction), keeping the mass-rewrite avoidance for everything
else (Codex P1 round 5).
* 🐛 fix: preserve anchorless message recency and gate workspace scrub on ownership
- anchorless message rewrite keeps updatedAt (a scope transfer does not
make content newer)
- WorkspaceModel.delete verifies primary ownership before running the
resnapshot scrub, so unauthorized callers cannot trigger its writes
* 🐛 fix: derive scope in usage aggregation and follow-up extraction
- usage findByDateRange / getAgentUsageStats fan out over the three
derivation arms (topic/session/orphan) so transferred history stays in
the right scope while each arm remains index-bounded
- followUpAction latest-assistant lookup uses the derived message scope
- update both unit test mocks accordingly (Codex P2 round 7)
* 🐛 fix: derive nightly-review activity scope from topic/session anchors
listActiveAgentTargets fans personal-scope activity out over the three
derivation arms (topic/session/orphan, each index-bounded) via UNION ALL,
so histories transferred into a personal scope count toward nightly
review; plugin failure counts join purely by message id since the parent
row is already scope-derived (Codex P2 round 8)
* 🐛 fix: scrub owned workspaces before user deletion cascades them
Deleting a user cascades every workspace they primary-own, which in turn
cascades messages.workspace_id — rows transferred away can carry the old
workspace snapshot under a different author, so the user-keyed scrub
alone missed them. deleteUser now runs the workspace-keyed scrub for each
owned workspace first (Codex P1 round 9). Regression test added.
* 🐛 fix: derive message scope in recent previews and eval message reads
Two read paths still filtered `messages` by its creation-time
user_id/workspace_id snapshot, so they returned nothing once an agent
transfer rehomed the owning topic:
- RecentModel's last-assistant-message subquery is already correlated to
`topics.id`, which inherits the topic arm's scope check — the snapshot
predicate was redundant and broke the preview in both transfer
directions. Dropped it rather than re-deriving the same topic.
- agentEvalExternal.messagesList has no such anchor, so it now uses
buildMessageScopeWhere; the topicId equality keeps it index-bounded.
* 🐛 fix: guard topic-anchored teammate rows in transfer foreign checks
Messages and message_groups derive their scope from the owning topic at
read time, so every row anchored to a transferred topic moves with it —
but transferHasForeignRows (agent + agent-group variants) only probed
direct sessionId/agentId/groupId linkage. A teammate row carrying only a
topicId (the OpenAPI create shape: agentId optional, sessionId always
null) escaped the guard, letting a non-owner member transfer teammates'
conversations out of the workspace.
Add hasForeignTopicAnchoredMessageRows next to the other derived-scope
helpers (index-bounded innerJoin probes, LIMIT 1) and call it from both
guards, mirroring the existing hasForeignTopicComments pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ⚡️ perf: fan queryAll out over the three derivation arms
The bare derived-scope predicate was queryAll's only filter before
ordering/paging the shared messages table — an unbounded scan on the
TRPC message.listAll / CLI message list path. Fan out over the
topic/session/orphan arms (each index-bounded, same shape as count())
and let the database order/page the UNION ALL.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix: recheck topic-anchored teammate rows under the transfer lock
transferHasForeignRows runs before the transfer transaction, so it races
message creation: a teammate topic-only message inserted in that window
followed the moved topic past the owner-only guard.
Close it with the same two-sided protocol topic comments already use:
- transferAgents / group transferToWorkspace re-run the topic-anchored
foreign-row probe INSIDE the transaction, after the topics FOR UPDATE
lock (message inserts hold FK FOR KEY SHARE on their topic, which
conflicts with FOR UPDATE — every topic-anchored insert is either
committed and visible to the recheck, or blocked until the transfer
ends), gated by a new rejectForeignMessageAuthors option the routers
set for workspace non-owners.
- The OpenAPI createMessage path (source of the topic-only shape) now
locks its topic FOR SHARE in the insert transaction and revalidates
the scope, so a create that raced a committed transfer is rejected
instead of landing in the moved scope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: workspace four-role model with membership role as source of truth
* 🐛 fix: align stale role tests and admin API-key manageability with four-role model
- provider settings route test mocks usePermission/useIsWorkspaceLoading
instead of the removed useIsWorkspaceOwner gate
- workspaceMember model test uses 'admin' (owner is not an assignable role)
- ApiKey settings manage gate follows the server's admin-or-higher rule
instead of the creator-or-owner row check
- grant workspace_admin the topic_comment content permissions added on canary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix: reconcile drifted membership roles from legacy RBAC grants on convergence
Column/RBAC drift predates the reversal: a non-primary row can carry a
stale owner label while its RBAC grant (the pre-reversal truth) says
member. Blanket-demoting stray owners to admin would silently elevate
such members, so the convergence now writes the RBAC projection back
into the column first and only falls back to admin for grant-less rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🔒 feat: hide workspace service-model settings from members
Service-model preferences steer the shared workspace model policy —
Admin-or-higher like Provider: nav entry hidden and the route wrapped
in WorkspaceAdminOnly. Server writes were already admin-gated via
workspace settings update.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🔒 fix: clamp legacy non-primary owner labels to admin in permission expansion
Owner is bound to workspaces.primaryOwnerId. Before the convergence
script runs, a stray non-primary role='owner' row must not expand to
the Owner permission matrix — it now counts as Admin at every judgment
point (RbacModel matrix expansion and the workspace owner/admin access
helpers).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix: harden convergence predicates and ownership-transfer CAS
- convergence projection ignores inactive roles and expired grants,
mirroring the RBAC checks the data replaces
- transferPrimaryOwnership compare-and-swaps on the still-current
primaryOwnerId so concurrent transfers cannot produce two owner rows
before the unique index ships
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 💄 fix: let workspace admins manage OAuth apps from the UI
The server gate is admin-or-higher; the reused OAuth Apps page still
computed canEdit from useIsWorkspaceOwner, hiding create/edit/delete
from Admins. Switch to the manage_settings permission.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix: unblock transfer for unconverged co-owners and gate API-key creation
- transfer target check accepts a legacy non-primary owner label (counts
as Admin everywhere else)
- workspace API-key create button follows the server's admin gate so
Members don't get an enabled flow that always 403s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat: workspace-scoped API keys with RBAC & workspace auth data deletion
Squashed from 6 commits:
- fix: workspace apikey rbac and workspace scope
- feat: support workspace auth delete data
- fix: apikey creator display and messenger back to lobe
- fix: api keys
- chore: optimize dropdown menus
- chore: update i18n files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix: project topic row owner userId in TopicModel.query for client ownership filters
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix: gate owner select-all batch delete behind workspace acknowledge modal & drop stale provider footer test
- BatchActionsDropdown: workspace owners deleting a select-all query hit the
server's workspace-wide delete (restrictToCreator=false); show the
workspace-wide label and the elevated WorkspaceDeleteAllModal, mirroring the
header trash button (Codex P1).
- The provider roadmap footer was removed in #17217 but the workspace provider
test still asserted it renders; update the stale test (canary CI is red on
the same test).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix: bind workspace API keys on the lambda TRPC surface
Mirror the OpenAPI resolveWorkspaceId contract in createLambdaContext:
a personal key is rejected when X-Workspace-Id is present, and a
workspace key is pinned to its own workspace (mismatched header
rejected, workspace applied even without the header).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix: enforce workspace API key entitlement on lambda auth & seed RBAC roles at workspace creation
- createLambdaContext: apply the same canUseWorkspaceApiKeys availability
gate as the OpenAPI workspace middleware before accepting a
workspace-scoped key (Codex P1).
- WorkspaceModel.create: seed the built-in RBAC roles and grant the creator
workspace_owner inside the creation transaction, matching the documented
assignWorkspaceRoleToUser contract; widen seed helpers to accept
transactions.
- OpenAPI workspace middleware: fall back to the loaded membership role for
the API-key owner check so pre-seeding workspaces are not rejected
(Codex P2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix: re-check workspace API-key issuer owner status on lambda auth
Workspace API keys are owner-only: createLambdaContext now verifies the
issuer still holds owner status (RBAC role with membership fallback via the
new shared hasWorkspaceOwnerAccess helper) before accepting a
workspace-scoped key, matching the OpenAPI workspace middleware. A demoted
or removed owner's key stops authenticating.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🌐 chore: refill locales after rebase onto canary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
`.nullish()` is defined as `.nullable().optional()` in Zod — same
validation behavior and inferred type (`T | null | undefined`). Pure
mechanical de-duplication, no behavior change.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(model-bank): rename model type `stt` to the standard `asr`
`asr` (Automatic Speech Recognition) is the conventional term for the
speech-to-text model category, matching the modern model-runtime `transcribe`
API and the `asr.transcribe` tRPC procedure. Standardize the model-type
taxonomy on `asr` across the type schema, model data, UI, API contract and
i18n — without a bulk DB data migration.
Application layer (full rename, no behavioral impact — `type` is only a
category label; nothing branches on it at runtime):
- model-bank: `AiModelTypeSchema` enum `stt` → `asr`; `AISTTModelCard` →
`AIASRModelCard`; `openaiSTTModels` → `openaiASRModels`
- UI: provider ModelList tab/count/pricing-case/create-form options + community
ModelTypeIcon map
- aihubmix: map the platform `stt` identifier to LobeHub `asr`
- i18n: rename `providerModels.tabs.stt` / `type.options.stt` keys (default +
en-US + zh-CN); other locales left to the auto-i18n CI
Persistence — runtime migration instead of a SQL migration ("don't fetch, don't
touch"):
- read-time mapping: the aiInfra repository normalizes any residual `stt` row to
`asr` on read (builtin models already get their type from model-bank)
- write-time heal: `AiModelModel` create/update/toggle/batch persist `asr`, so
data lazily migrates whenever a row is naturally written; new writes are `asr`
- shared `normalizeAiModelType` helper in model-bank
OpenAPI — non-breaking:
- input still accepts `stt` as a deprecated alias (no 4xx for old clients) and
normalizes it to `asr`; responses always emit `asr`; type filter matches both
`asr` and legacy `stt` rows
No `ai_models` data migration is shipped; old rows stay valid and convert lazily.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ♻️ refactor(cli): sync `lh model` type inputs with the `stt` → `asr` rename
`stt` was removed from `AiModelTypeSchema`, so the CLI paths that still
advertised and forwarded `--type stt` broke for users following the help text:
`lh model create/edit --type stt` was rejected by the router schema and
`lh model list --type stt` returned no ASR models.
Advertise `asr` in the `--type` help for list/create/edit, and normalize the
legacy `stt` alias to `asr` before forwarding to the API / filtering results,
so existing scripts keep working.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* 🐛 fix(agent): centralize inbox agent meta fallback across read paths
Extract inbox title/avatar fallback into a shared `inboxAgent` util and
apply it consistently wherever agents are read (models, repositories,
openapi service, topic router) so the inbox agent always resolves to its
default title and avatar when the DB row is blank.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(agent): push agent reads into the model layer instead of router-level fallback
Routers/services were querying the `agents` table directly and re-applying the
inbox meta fallback on top — a layering leak. Move those reads down so the
normalization happens once, where the data is owned:
- topic.recentTopics: use `AgentModel.getAgentAvatarsByIds` +
`ChatGroupModel.getMemberAvatarsByGroupIds` instead of inline `.from(agents)`
- add `ChatGroupModel.getMemberAvatarsByGroupIds`, dedupe the duplicated
member-avatar query that also lived in `HomeRepository`
- add `AgentModel.listMessengerBindableAgents` (virtual-or-inbox filter,
`updatedAt DESC`, inbox pinned + normalized) and use it from both
`messenger.listAgentsForBinding` and `MessengerRouter.fetchUserAgents`,
unifying the inbox title fallback on `DEFAULT_INBOX_TITLE` (was a stray
hardcoded `'LobeAI'`)
The `inboxAgent` util is now confined to `packages/database`; no router/service
re-patches inbox meta anymore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(agent): construct agent models via ctx instead of in-handler
Follow the router convention of building models in the procedure ctx
middleware rather than ad-hoc inside handlers:
- topic: move `agentModel` / `chatGroupModel` into `topicProcedure` ctx
(ambient `wsId` scope)
- messenger: the bindable-agents scope is input-driven (the scope picker
passes the workspace), and the procedure is identity-scoped, so expose a
workspace-parameterized `getAgentModel` factory on ctx and call it in
`listAgentsForBinding`
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(agent): drop dead inbox normalization from openapi agent service
The base commit's blanket sweep added `normalizeInboxAgentMeta` to the
openapi `AgentService`, but it doesn't belong there:
- `queryAgents` filters `virtual=false OR NULL` with no `OR slug=inbox`
escape hatch, and the inbox agent is `virtual=true`, so it's excluded by
design — the normalize never sees an inbox row (dead code).
- `updateAgent` only normalizes when updating the inbox via the public REST
CRUD, which shouldn't manage that virtual/system agent.
Remove both calls (restoring pre-branch behavior) and the util import; this
surface lists/edits real agents only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ♻️ refactor(agent): own messenger bindable-agent title fallback in the model
- return `isInbox` from `AgentModel.listMessengerBindableAgents` so callers no
longer recompute it from `slug`
- move the blank-title fallback into the model via an optional `fallbackTitle`
option; `MessengerRouter.fetchUserAgents` passes "Custom Agent" (text-only
channel) while `messenger.listAgentsForBinding` omits it and keeps null so the
web picker applies its own i18n default
- `messenger.listAgentsForBinding` now returns the model rows directly
- revert openapi `agent.service.ts` to canary: the inbox is virtual and excluded
from that surface, so its earlier normalization was dead code
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>