Uh oh!
There was an error while loading. Please reload this page.
fix: unify i18n service discovery, handler resolution, and plugin auto-loading - #903
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- Make getDiscoveryInfo() async using resolveService() fallback chain - Populate locale from actual i18n service instead of hardcoded values - Update all framework adapters and plugins to await getDiscoveryInfo() - Add discovery-handler consistency tests - Update CHANGELOG.md Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
hotlong
commented
Mar 11, 2026
@copilot 仔细评估是否已经完成了任务中要求的所有开发 |
- AppPlugin.start() auto-loads translation bundles into i18n service - Added 10 new tests: AppPlugin i18n loading + environment consistency - Added i18n service registration guide to kernel-services.mdx - Updated CHANGELOG.md with Added section Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
经过全面评估,原始 issue 中的 4 项要求现已全部完成: 1. 统一 Discovery 与路由查找链 ✅ 2. AppPlugin/ConsolePlugin 自动协调 i18n 数据加载 ✅ 3. 增强 i18n 测试覆盖 ✅ 新增 17 个测试:discovery-handler 一致性(6)、dev stub 场景(1)、MSW catch-all 路由(1)、context 解析(1)、501 一致性(1)、AppPlugin i18n 加载(6)、错误处理(1)。总测试 138 通过。 4. 补充文档 ✅ 在 |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Pull request overview
This PR makes i18n service discovery consistent with runtime handler resolution by switching discovery to the same async service-resolution chain, derives discovery locale metadata from the actual i18n service, and adds AppPlugin auto-loading for translation bundles so server/dev/mock environments converge on the same i18n state.
Changes:
- Made
HttpDispatcher.getDiscoveryInfo()async and aligned it withresolveService()resolution semantics; discovery now populateslocalefrom the i18n service when available. - Updated all discovery callers (dispatcher plugin + framework adapters + MSW plugin) to
await getDiscoveryInfo(). - Added AppPlugin translation auto-loading and expanded docs/tests/changelog for i18n behavior.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/runtime/src/http-dispatcher.ts | Makes discovery async, resolves services via resolveService(), and derives locale from i18n service. |
| packages/runtime/src/http-dispatcher.test.ts | Adds tests for discovery↔handler consistency and locale population across environments. |
| packages/runtime/src/dispatcher-plugin.ts | Awaits async discovery info in discovery routes. |
| packages/runtime/src/app-plugin.ts | Adds translation bundle auto-loading into the kernel i18n service during start(). |
| packages/runtime/src/app-plugin.test.ts | Adds tests for AppPlugin i18n auto-loading behavior. |
| packages/plugins/plugin-msw/src/msw-plugin.ts | Awaits discovery info in MSW discovery handler. |
| packages/adapters/hono/src/index.ts | Awaits dispatcher discovery in Hono adapter. |
| packages/adapters/express/src/index.ts | Awaits dispatcher discovery in Express adapter. |
| packages/adapters/fastify/src/index.ts | Awaits dispatcher discovery in Fastify adapter. |
| packages/adapters/nextjs/src/index.ts | Awaits dispatcher discovery in Next.js adapter. |
| packages/adapters/nestjs/src/index.ts | Makes NestJS controller discovery handler async and awaits discovery info. |
| packages/adapters/nestjs/src/nestjs.test.ts | Updates NestJS discovery test to async. |
| packages/adapters/nestjs/src/mocks/runtime.ts | Updates mock dispatcher discovery to return a promise. |
| packages/adapters/nuxt/src/index.ts | Awaits dispatcher discovery in Nuxt adapter. |
| packages/adapters/sveltekit/src/index.ts | Awaits dispatcher discovery in SvelteKit adapter. |
| content/docs/guides/kernel-services.mdx | Documents i18n registration, discovery consistency, and AppPlugin auto-loading. |
| CHANGELOG.md | Records the fix/additions under Unreleased. |
| private loadTranslations(ctx: PluginContext, appId: string): void { | ||
| const i18nService = ctx.getService('i18n') as II18nService | undefined; | ||
| if (!i18nService) { | ||
| ctx.logger.debug('[i18n] No i18n service registered; skipping translation loading', { appId }); | ||
| return; | ||
| } |
There was a problem hiding this comment.
loadTranslations() calls ctx.getService('i18n'), but PluginContext.getService() is documented/implemented to throw when a service is missing (and can also throw when the service is async). This means AppPlugin can crash during start() in environments where i18n is intentionally not registered, contradicting the “gracefully skips” behavior described here. Wrap service lookup in a try/catch (treat not-found as undefined) and/or use await ctx.getKernel().getServiceAsync('i18n') with error handling so missing i18n truly becomes a no-op.
| async getDiscoveryInfo(prefix: string) { | ||
| // Resolve all services through the same async fallback chain | ||
| // that request handlers (handleI18n, handleAuth, …) use. | ||
| const [ | ||
| authSvc, graphqlSvc, searchSvc, realtimeSvc, filesSvc, | ||
| analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc, | ||
| uiSvc, automationSvc, cacheSvc, queueSvc, jobSvc, | ||
| ] = await Promise.all([ | ||
| this.resolveService(CoreServiceName.enum.auth), | ||
| this.resolveService(CoreServiceName.enum.graphql), | ||
| this.resolveService(CoreServiceName.enum.search), | ||
| this.resolveService(CoreServiceName.enum.realtime), | ||
| this.resolveService(CoreServiceName.enum['file-storage']), | ||
| this.resolveService(CoreServiceName.enum.analytics), | ||
| this.resolveService(CoreServiceName.enum.workflow), | ||
| this.resolveService(CoreServiceName.enum.ai), | ||
| this.resolveService(CoreServiceName.enum.notification), | ||
| this.resolveService(CoreServiceName.enum.i18n), | ||
| this.resolveService(CoreServiceName.enum.ui), | ||
| this.resolveService(CoreServiceName.enum.automation), | ||
| this.resolveService(CoreServiceName.enum.cache), | ||
| this.resolveService(CoreServiceName.enum.queue), | ||
| this.resolveService(CoreServiceName.enum.job), | ||
| ]); |
There was a problem hiding this comment.
getDiscoveryInfo() now resolves all services via resolveService(), which prefers kernel.getServiceAsync(). In ObjectKernel, getServiceAsync() executes service factories and can instantiate services (including transient/scoped) just to build discovery output. That’s a behavior change from the prior “check the services map” approach and can introduce side effects / extra work on every discovery request. Consider adding a non-instantiating availability check (e.g., a kernel hasService() API or a resolveServiceRegistration() path) and using that in discovery, while keeping resolveService() for request handlers.
| it('should skip translation loading when i18n service is not registered', async () => { | ||
| vi.mocked(mockContext.getService).mockImplementation((name: string) => { | ||
| if (name === 'objectql') return mockQL; | ||
| return undefined; // No i18n service | ||
| }); | ||
| const bundle = { | ||
| id: 'com.test.noi18n', | ||
| translations: [{ en: { messages: { hello: 'Hello' } } }], | ||
| }; | ||
| const plugin = new AppPlugin(bundle); | ||
| await plugin.start!(mockContext); | ||
| // Should log debug but not throw | ||
| expect(mockContext.logger.debug).toHaveBeenCalledWith( | ||
| expect.stringContaining('No i18n service registered'), | ||
| expect.any(Object) | ||
| ); |
There was a problem hiding this comment.
This test simulates “no i18n service” by returning undefined from mockContext.getService, but real PluginContext.getService() throws when a service is missing. To ensure the intended graceful-skip behavior is covered, update the mock to throw (e.g., throw new Error("[Kernel] Service 'i18n' not found")) and assert AppPlugin.start() does not reject.
…outes the SDK could not reach (#3718) (#3888) * feat(client,spec): `ai.agents.*` and `ai.pendingActions.*` — the AI routes the SDK could not reach (#3718) #3718 deleted three `client.ai.*` methods whose URLs no route had ever mounted, then expressed the surface that does exist. It expressed ONE builder's worth of it. `service-ai` mounts seven; widening its ledger (objectstack-ai/cloud#903) counted ten routes the SDK cannot reach, nine of which had never been counted. This closes the six with the strongest evidence: objectui already ships product on them, over URLs it hand-builds because there is nothing to call. ai.agents — `/ai/chat` talks to the default agent; these talk to a named one. list() — agents this CALLER may chat with; the route filters by permission (ADR-0049), so empty is a legitimate answer, not an error to retry chat(name, req) — forces `stream: false`, same reason `ai.chat` does: the route streams by default chatStream(name, req) — same route, streaming mode. One route, two methods, mirroring chat/chatStream rather than inventing a third shape ai.pendingActions — the HITL approval queue an embedding app must render. list(options?) — status/conversationId/limit ONLY. The service also accepts objectName; the route never forwards it, so typing it would offer a filter that silently does nothing get(id) approve(id) — approves AND executes. `{status:'failed'}` comes back on HTTP 200: the approval succeeded, the execution did not. Reading only `res.ok` reports a failed write as a success reject(id, reason?) — executes nothing Typed from what the routes RETURN, not from what a client might like them to — the failure #3718 exists to punish. Pending actions are the persisted row, snake_case on the wire because that is what it is; agent rows require `capabilities` because that object is what tells a UI what to render. The capstone's `/api/v1/ai/` prefix exemption says the evidence lives across the repo boundary. It does, and it now reaches these: cloud's ledger drives every `ai.*` method against the tables its builders really return, and since #903 that means all seven. Comment updated there — it still described the one-builder version, under which `buildAgentRoutes()` and `buildPendingActionRoutes()` were invisible. Verification: client 184/184, spec 6836/6836 (24 in protocol.test.ts, extended with the new shapes incl. the negative cases — an agent row without capabilities, a non-enum status filter, `approve` yielding "rejected"). Mutation-checked: pointing `ai.pendingActions.get` off the AI prefix fails the capstone by name, proving the new methods are really in its sweep rather than silently absent. Generated artifacts regenerated: api-surface.json (+20 exports, 0 breaking) and json-schema.manifest.json. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJX6GnuNix7HisBc92THMN * docs(spec,client): regenerate the protocol reference and document the new ai methods `check:docs` regenerates `content/docs/references` from the schemas and fails when the committed copy drifts. The seven new AI schemas produce new reference entries, so that check went red on the first push — the artifact half of the same commit, not a separate defect. Also updates the hand-written SDK doc, which enumerates the whole `client.ai.*` surface and carried the #3718 history note. Leaving it at ten methods while shipping seventeen would be the exact drift this line of work keeps closing — and the docs-drift check flagged `content/docs/api/client-sdk.mdx` as affected, which on inspection it genuinely was (the other 107 files it lists are package-level fan-out, unrelated to this diff). The added block documents the two things a caller gets wrong by default: an access-filtered agent catalog is legitimately EMPTY for a seat-less user, and `pendingActions.approve` returns `{ status: 'failed' }` on HTTP 200 when the tool fails after approval — reading only `res.ok` calls that a success. check:docs / check:api-surface / check:spec-changes / check:upgrade-guide all pass; 250 generated files in sync. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJX6GnuNix7HisBc92THMN --------- Co-authored-by: Claude <noreply@anthropic.com>
getDiscoveryInfo()used synchronousgetServicesMap()to check service availability, whilehandleI18n()(and all other handlers) used the asyncresolveService()fallback chain (getServiceAsync → getService → context.getService → services Map). A service registered via an async factory would be reported as "unavailable" in discovery but fully functional via the handler. Thelocalefield was also hardcoded rather than sourced from the actual i18n service. Additionally,AppPlugindid not coordinate i18n translation loading from app bundles, leaving server/dev/mock environments with inconsistent i18n state.Core fix —
http-dispatcher.tsgetDiscoveryInfo()is nowasyncand resolves all 15 services viaPromise.all(resolveService(...))— same chain handlers uselocaleis populated fromi18nSvc.getDefaultLocale()/i18nSvc.getLocales()when available, with sensible defaults when notAppPlugin i18n auto-loading —
app-plugin.tsAppPlugin.start()now auto-loads translation bundles from app configs (translationsarray) into the kernel's i18n servicei18n.defaultLocaleconfig viai18nService.setDefaultLocale()Callers updated to
awaitdispatcher-plugin.ts,plugin-mswdispatch()root discovery pathDocumentation
content/docs/guides/kernel-services.mdxcovering service registration patterns across production/dev/mock environments, discovery consistency, AppPlugin auto-loading behavior, and REST endpoint referenceTests
mockReturnValue→mockResolvedValue; test madeasyncOriginal prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.