diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 9ba0cf8dc1..1e4aeaabd7 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: c4f3a5b94bf25b4581b9430cfcb4f02f707e0749 -2026-08-02-typert-remote-method-calls.zh.md: e11d8ebe42d44cc9805e942a31f13f7ae847815a +2026-08-02-typert-remote-method-calls.md: 3d5a79fd4a26f7d232dcc7635625899e2eb9df6b +2026-08-02-typert-remote-method-calls.zh.md: 3d6ec680ba97a532f18219670e8dba799a94ed7b diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index c4f3a5b94b..3d5a79fd4a 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -164,7 +164,7 @@ Every registration returns a disposer owned by the caller's Cordis fiber. Client The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that TypeRT Service. -Business-object packages own stable declarations and default resolvers through `register()`; Host composition supplies an effect-scoped asynchronous policy for the same key through `configure()`. Configuration may precede provider registration, but does not by itself make a lookup available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session`: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` resolver returns the resolved Agent's Session, so the two parameter kinds do not create separate resume lifecycles. +Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle. The registry's Host root entry has the complete `TypeRTService` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index e11d8ebe42..3d6ec680ba 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -164,7 +164,7 @@ ctx.typert.contexts Host Context resolver 与 Client Context binder lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 TypeRT Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 -业务对象包通过 `register()` 拥有稳定声明和默认 resolver;Host 组合通过 `configure()` 为同一个 key 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用 lookup;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent` 和 `session` 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` resolver 返回解析所得 Agent 的 Session,因而两种参数不会产生两套恢复生命周期。 +业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent`、`session` lookup 和 `agent` Host Context 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` lookup 返回解析所得 Agent 的 Session,`agent` Host Context 返回其 Context,因此三种投影共用一个恢复生命周期。 Registry 的 Host 根入口拥有完整 `TypeRTService` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index bafffa80f7..ddef36e288 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -134,7 +134,8 @@ class ClientApiService extends Service implements TypeRTClientApi { for (const method of methods) record.service.assertMethodAvailable(method) } else { for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method) - if (this.ownerCtx.reflect.props[namespace] !== undefined) { + const property = this.ownerCtx.reflect.props[namespace] + if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) { throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`) } } @@ -224,6 +225,7 @@ class ClientApiService extends Service implements TypeRTClientApi { if (namespace.tokens.get(descriptor.method) !== token) return namespace.service.remove(descriptor.method) namespace.tokens.delete(descriptor.method) + if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace) } } @@ -289,7 +291,7 @@ class ScopedRemoteNamespace { private readonly ctx: Context private readonly ownerCtx: Context private readonly methods = new Set() - private provided = false + private disposeService: (() => void) | undefined readonly name: string static assertMethodAvailable(namespace: string, method: string): void { @@ -331,12 +333,7 @@ class ScopedRemoteNamespace { }, }) if (activate) { - if (this.provided) { - this.ownerCtx.set(this.name, this) - } else { - this.ownerCtx.reflect.provide(this.name, this) - this.provided = true - } + this.disposeService = this.ownerCtx.reflect.provide(this.name, this) } } catch (error) { Reflect.deleteProperty(this, method) @@ -348,11 +345,14 @@ class ScopedRemoteNamespace { remove(method: string): void { Reflect.deleteProperty(this, method) this.methods.delete(method) - if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined) + if (this.methods.size !== 0) return + const disposeService = this.disposeService + this.disposeService = undefined + disposeService?.() } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided']) +const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx']) function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 13cf460f4d..5899f5d560 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -134,7 +134,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const endpoint = endpointOf(request.namespace, request.method) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) - const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint) + const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint) const receiver = receiverContext.get(descriptor.service) as unknown if (!isObject(receiver)) { throw new TypertGatewayError( @@ -331,11 +331,11 @@ export class TypertGatewayService extends Service implements TypertGateway { } } - private resolveReceiverContext( + private async resolveReceiverContext( descriptor: InvocationDescriptor, args: Readonly>, endpoint: string, - ): Context { + ): Promise { if (descriptor.invocation.kind === 'direct') return this.ctx const invocation = descriptor.invocation const provider = this.ctx.typert.contexts.getHost(invocation.context) @@ -358,8 +358,9 @@ export class TypertGatewayService extends Service implements TypertGateway { const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire) let context: Context | undefined try { - context = provider.resolve(identity) + context = await provider.resolve(identity) } catch (cause) { + if (cause instanceof TypeRTLookupFailure) throw cause throw new TypertGatewayError( 'context-failed', endpoint, diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 2fbcbb9280..feae3056c9 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -526,6 +526,20 @@ describe('Client TypeRT API', () => { await retry() }) + it('unregisters an empty scoped namespace so another provider can claim its name', async () => { + const ctx = await bench(vi.fn()) + const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) + expect(ctx.get('goals')).toBeDefined() + + await dispose() + + expect(ctx.get('goals')).toBeUndefined() + const replacement = { owner: 'replacement' } + const disposeReplacement = ctx.reflect.provide('goals', replacement) + expect(ctx.get('goals')).toBe(replacement) + await disposeReplacement() + }) + it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index d784a1ac2f..d298116b82 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -538,6 +538,22 @@ describe('TypertGatewayService', () => { expect(error.cause).toEqual(new Error('provider failed')) }) + it('preserves a Host Context policy rejection for the active RPC adapter', async () => { + const { ctx } = await setup() + const rejection = new TypeRTLookupFailure({ code: 'agent-busy', message: 'owned', details: { reason: 'subagent' } }) + ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(ctx.extend()), + resolve: async () => { throw rejection }, + }) + registerStrict(ctx, [renameDescriptor()]) + + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + })).rejects.toBe(rejection) + }) + it('reports Context provider metadata mismatch and unresolved identities', async () => { const { ctx } = await setup() registerStrict(ctx, [renameDescriptor()]) diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts index e3a5b27df8..eb54ea9b0b 100644 --- a/packages/api/remotes/src/agent-lookup.ts +++ b/packages/api/remotes/src/agent-lookup.ts @@ -187,6 +187,7 @@ export function createApiRemoteAgentResolver( } typeCtx.typert.lookups.configure('agent', resolveAgent) typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) + typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx) }) return agentFor diff --git a/packages/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts index c9110b8f3f..7179f5b2b2 100644 --- a/packages/api/remotes/tests/agent-lookup.spec.ts +++ b/packages/api/remotes/tests/agent-lookup.spec.ts @@ -5,6 +5,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes' +import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' const sid = (value: string): SessionId => value as SessionId @@ -14,6 +16,7 @@ function header(id: SessionId): SessionHeader { async function createContext(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) return ctx @@ -107,4 +110,45 @@ describe('API Remote Agent resolver races', () => { await ctx.fiber.dispose() } }) + + it('uses the shared cold-resume policy for the Agent Host Context', async () => { + const ctx = await createContext() + const sessionId = sid('context-cold-resume') + const meta = header(sessionId) + let published: Session | undefined + provideSession(ctx, meta, () => { + published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } }) + return Promise.resolve({ meta, events: [] }) + }) + const agentCtx = ctx.extend() + vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + if (published === undefined) throw new Error('Session was not published') + return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() } + }) + const defaultProvider = ctx.typert.contexts.getHost('agent') + createApiRemoteAgentResolver(ctx, {}) + await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) + const provider = ctx.typert.contexts.getHost('agent') + if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') + + await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx) + await ctx.fiber.dispose() + }) + + it('applies the subagent ownership fence to the Agent Host Context', async () => { + const ctx = await createContext() + const sessionId = sid('context-owned-subagent') + const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) + ctx.agents.register(stubAgent(ctx.extend(), session)) + const defaultProvider = ctx.typert.contexts.getHost('agent') + createApiRemoteAgentResolver(ctx, {}) + await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) + const provider = ctx.typert.contexts.getHost('agent') + if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') + + const resolution = provider.resolve(sessionId) + await expect(resolution).rejects.toBeInstanceOf(TypeRTLookupFailure) + await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) + await ctx.fiber.dispose() + }) }) diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 2e49d5b6b8..bea4f67df2 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -38,10 +38,10 @@ const NS = 'goal' /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ export const inject = ['slots', 'sessions', 'api', 'locale'] -/** Map one generated Remote call onto the strip's inline-render shape. */ -async function settle(result: Promise): Promise { +/** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ +async function settle(invoke: () => Promise): Promise { try { - await result + await invoke() return { ok: true } } catch (error) { const cause = error instanceof Error ? error.cause : undefined @@ -94,22 +94,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.edit(sessionId, ref, { objective })) + return settle(() => ctx.api.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.pause(sessionId, ref)) + return settle(() => ctx.api.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.resume(sessionId, ref)) + return settle(() => ctx.api.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(ctx.api.goals.clear(sessionId, ref)) + return settle(() => ctx.api.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 11c95e27d9..f900682712 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -70,7 +70,7 @@ async function bench(options: { resume: answer(`${prefix}/resume`, { ref }), clear: answer(`${prefix}/clear`, ref), }) - let activeGoals = goals('goals') + let activeGoals: ReturnType | undefined = goals('goals') ctx.provide('api', { get goals() { return activeGoals }, }) @@ -95,6 +95,7 @@ async function bench(options: { fiber, calls, remountGoals: () => { activeGoals = goals('remounted-goals') }, + unmountGoals: () => { activeGoals = undefined }, entry: () => { const entry = ctx.slots.entries('conversation.input.dock')[0] if (entry === undefined) return undefined @@ -141,6 +142,18 @@ describe('ui-goal browser plugin', () => { expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }]) }) + it('settles every verb when the Remote namespace is temporarily absent', async () => { + const b = await bench({ projection: makeProjection() }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + b.unmountGoals() + + for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) { + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + } + expect(b.calls).toHaveLength(0) + }) + it('a null or absent projection short-circuits every verb without touching the wire', async () => { for (const projection of [null, undefined]) { const b = await bench({ projection }) diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index a6180c6bfc..011834c52d 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/registry/README.md -README.md: dae8c3ed124fd6e2d61eb47964e2c07dda762b48 -README.zh.md: aea74b3753feccd88ee132363dc60ade02161498 +README.md: fa227b1c8faf1abd5a6492d4b8fe7d0c51ceeef1 +README.zh.md: 343e43aaca6ddaa0bb5e8d3130c85f37e4b4cb93 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index dae8c3ed12..fa227b1c8f 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -10,6 +10,7 @@ Package reflection is keyed by `#`. Schemas are keyed by `>() + private readonly hostResolvers = new Map>() private readonly clients = new Map>() private readonly changes: ChangeSource @@ -347,16 +349,56 @@ class ContextStore { key: K, provider: TypeRTHostContextProvider>, ) => this.registerHost(ctx, key, provider), + configureHost: >( + key: K, + resolver: TypeRTHostContextResolver>, + ) => this.configureHost(ctx, key, resolver), registerClient: >( key: K, binder: TypeRTClientContextBinder>, ) => this.registerClient(ctx, key, binder), - getHost: key => this.hosts.get(key)?.provider, + getHost: key => this.getHost(key), getClient: key => this.clients.get(key)?.provider, subscribe: listener => this.changes.subscribe(ctx, listener), } } + private getHost(key: string): TypeRTHostContextProvider | undefined { + const provider = this.hosts.get(key)?.provider + if (provider === undefined) return undefined + const resolver = this.hostResolvers.get(key)?.provider + if (resolver === undefined) return provider + return { + wire: provider.wire, + wireTypeSymbol: provider.wireTypeSymbol, + resolve: id => resolver.resolve(id), + } + } + + private configureHost( + ctx: Context, + key: string, + resolver: TypeRTHostContextResolver, + ): TypeRTDisposer { + validateSegment('Context key', key) + if (this.hostResolvers.has(key)) throw new Error(`typert: host-context "${key}" resolver is already configured`) + const entry: ProviderEntry = { + provider: { resolve: async id => resolver(id as Wire) }, + owner: {}, + } + const { hostResolvers, changes } = this + return ctx.effect(function* () { + hostResolvers.set(key, entry) + changes.emit({ kind: 'host-context', key }) + yield () => { + /* v8 ignore next -- duplicate configuration is rejected, so this effect remains the key's unique owner. */ + if (hostResolvers.get(key) !== entry) return + hostResolvers.delete(key) + changes.emit({ kind: 'host-context', key }) + } + }, `typert.contexts.configureHost(${JSON.stringify(key)})`) + } + private registerHost(ctx: Context, key: string, provider: TypeRTHostContextProvider): TypeRTDisposer { validateSegment('Context key', key) validateWireName('Context wire field', provider.wire) @@ -392,6 +434,10 @@ class ContextStore { } } +interface HostContextResolverEntry { + resolve(id: unknown): Promise +} + /** * Registry of generated schemas, package reflection, invocations, and Remote * dependency providers. diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 087cf00fc4..92b81803c7 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -389,6 +389,36 @@ describe('TypertRegistry', () => { await disposeReloadedProvider() }) + it('configures an asynchronous Host Context resolver independently of provider load order', async () => { + const ctx = await makeCtx() + const fallback = ctx.extend() + const configured = ctx.extend() + const disposeResolver = ctx.typert.contexts.configureHost('registryFixture', async id => + id === 'configured' ? configured : undefined) + + expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() + const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === 'fallback' ? fallback : undefined, + }) + await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) + expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured') + + await disposeProvider() + expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined() + const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture/session#SessionId', + resolve: id => id === 'fallback' ? fallback : undefined, + }) + await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) + + await disposeResolver() + expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('fallback')).toBe(fallback) + await disposeReloadedProvider() + }) + it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { const ctx = await makeCtx() const changes: string[] = [] diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 510b8d3854..6c21127e54 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md -README.md: b394c843409e840b75bbb08b128614379e528001 -README.zh.md: 5bd9bb18289a0320e0603d8b373e60d7f1e3c7e5 +README.md: a76169742cb78d0d19814bcd0f978c71036a5a1c +README.zh.md: 6f2d2fd6e241441fae8102c0639608e9b27b9bec diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index b394c84340..a76169742c 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -20,7 +20,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API. -Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. ## Model Experience diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 5bd9bb1828..6f2d2fd6e2 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -20,7 +20,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。 -查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 +查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 ## 模型体验 diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 1418c9d7f2..2f687f985f 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -50,6 +50,7 @@ export type { TypeRTContextWire, TypeRTDisposer, TypeRTHostContextProvider, + TypeRTHostContextResolver, TypeRTLocalRegistry, TypeRTLookup, TypeRTLookupDefinition, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index b65690115f..ed309b7857 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -238,9 +238,14 @@ export interface TypeRTHostContextProvider { * @param id - validated wire identity. * @returns the scoped Context, or `undefined` when unavailable. */ - resolve(id: Wire): Context | undefined + resolve(id: Wire): Context | undefined | Promise } +/** Composition-owned resolver replacing one Host Context provider's default lookup policy. */ +export type TypeRTHostContextResolver = ( + id: Wire, +) => Context | undefined | Promise + /** Client resolver for the identity carried by the calling scoped Context. */ export interface TypeRTClientContextBinder { /** @@ -367,6 +372,17 @@ export interface TypeRTContextRegistry { key: K, provider: TypeRTHostContextProvider>, ): TypeRTDisposer + /** + * Override one Host Context key's identity policy for the calling fiber. + * Configuration may precede provider registration and restores the provider's default resolver on disposal. + * @param key - merge-declared Context key. + * @param resolver - composition-owned resolver used by every Host Context lookup of this key. + * @returns disposer restoring the provider's default resolver. + */ + configureHost>( + key: K, + resolver: TypeRTHostContextResolver>, + ): TypeRTDisposer /** * Register a Client Context identity binder. * @param key - merge-declared Context key.