diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 745984a609..9c7e15d1fc 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -6,10 +6,11 @@ import { Service } from '@deepseek-ai/cordis' import type { Context, Events } from '@deepseek-ai/cordis' -import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, TypeRTClientRemote, + RemoteResult, TypeRTCodec, TypeRTDisposer, TypeRTRemoteContribution, @@ -328,7 +329,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { scoped: ScopedMethod | undefined, callerCtx: Context, values: readonly unknown[], - ): Promise { + ): Promise> { if (scoped !== undefined) { const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) const identity = binder?.identity(callerCtx) @@ -359,9 +360,9 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, - ): Promise { + ): Promise> { const endpoint = endpointOf(descriptor) - if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`) + if (!token.active) return withdrawn(endpoint) const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1 if (values.length !== expected && !hasCallerSignal) { @@ -391,7 +392,8 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { let valueIndex = 0 descriptor.parameters.forEach((parameter, parameterIndex) => { if (parameterIndex === projection?.parameterIndex) return - args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire) + const value = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire) + if (value !== undefined) args[parameter.wire] = value valueIndex += 1 }) const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined @@ -400,10 +402,16 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { const signal = callerSignal === undefined ? token.abort.signal : AbortSignal.any([token.abort.signal, callerSignal]) - const result = await connection.rpc.call('/api', endpoint, { args }, signal) - if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`) - if (!result.ok) throw remoteFailure(endpoint, result.error) - return parse(descriptor.result, result.value, endpoint, 'result') + try { + const result = await connection.rpc.call('/api', endpoint, { args }, signal) + if (!mountActive(token)) return withdrawn(endpoint) + if (!result.ok) return { ok: false, error: result.error } + return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') } + } catch (error) { + // Carrier throws (offline, abort, a rejected result payload) are outcomes + // of the call, not assembly faults, so they join the same error branch. + return carrierFailure(endpoint, error) + } } } @@ -412,7 +420,7 @@ type InvokeRemote = ( scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], -) => Promise +) => Promise> class RemoteNamespaceService extends Service { private readonly methods = new Map() @@ -467,7 +475,7 @@ class RemoteNamespaceService extends Service { Object.defineProperty(this, method, { configurable: true, enumerable: true, - get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise { + get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise> { const callerCtx = this.ctx const current = this.methods.get(method) const direct = current?.direct @@ -566,6 +574,15 @@ function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: stri } } -function remoteFailure(endpoint: string, error: RpcError): Error { - return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error }) +/** The namespace retired before or during the call, so no request outcome exists. */ +function withdrawn(endpoint: string): RemoteResult { + return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`) +} + +function carrierFailure(endpoint: string, error: unknown): RemoteResult { + return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`) +} + +function internalFailure(message: string): RemoteResult { + return { ok: false, error: { code: 'internal', message, details: {} } } } diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index d4a5965611..cefdd94023 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -42,23 +42,24 @@ declare module '@deepseek-ai/dsh-type-meta' { } interface TypeRTRemoteMap { - 'goals/create': ( + 'probe/create': ( agentId: string, request: { readonly objective: string }, signal?: AbortSignal, ) => Promise<{ readonly ref: string }> + 'probe/maybe': (value: string | null | undefined) => Promise } interface TypeRTRemoteScopeMap { - 'fixture:goals/create': ( + 'fixture:probe/create': ( request: { readonly objective: string }, signal?: AbortSignal, ) => Promise<{ readonly ref: string }> - 'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> + 'fixture:probe/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }> } interface TypeRTRemoteNamespaceMap { - goals: TypeRTRemoteNamespace<'goals'> + probe: TypeRTRemoteNamespace<'probe'> } } @@ -87,9 +88,9 @@ const renameResultSchema = z.object({ renamed: z.boolean() }) function directDescriptor(): InvocationDescriptor { return { - id: '@fixture/goals#goals/create', - service: 'goals', - namespace: 'goals', + id: '@fixture/probe#probe/create', + service: 'probe', + namespace: 'probe', method: 'create', invocation: { kind: 'direct' }, scope: { context: 'fixture', wire: 'agentId' }, @@ -112,9 +113,9 @@ function directDescriptor(): InvocationDescriptor { function contextDescriptor(): InvocationDescriptor { return { - id: '@fixture/goals#goals/rename', - service: 'goals', - namespace: 'goals', + id: '@fixture/probe#probe/rename', + service: 'probe', + namespace: 'probe', method: 'rename', invocation: { kind: 'context', @@ -132,6 +133,25 @@ function contextDescriptor(): InvocationDescriptor { } } +function maybeDescriptor(): InvocationDescriptor { + const schema = z.union([z.string(), z.null(), z.undefined()]) + return { + id: '@fixture/probe#probe/maybe', + service: 'probe', + namespace: 'probe', + method: 'maybe', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'value', + wire: 'value', + source: 'json', + acceptsUndefined: true, + codec: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema }, + }], + result: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema }, + } +} + async function bench(call: ConnectionHandle['rpc']['call']): Promise { const { ctx } = await benchFiber(call) return ctx @@ -153,24 +173,24 @@ describe('Client TypeRT API', () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) - const businessGoals = { owner: 'host business service' } - const disposeBusinessGoals = ctx.provide('goals', businessGoals) + const businessProbe = { owner: 'host business service' } + const disposeBusinessProbe = ctx.provide('probe', businessProbe) const assembly = ctx.plugin(Object.assign( - (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }), { inject: ['remote'] }, )) await assembly - const retained = ctx.remote.goals.create + const retained = ctx.remote.probe.create - await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' }) expect(call).toHaveBeenCalledWith( '/api', - 'goals/create', + 'probe/create', { args: { agentId: 'agent-1', request: { objective: 'ship' } } }, expect.any(AbortSignal), ) const callerAbort = new AbortController() - await expect(ctx.remote.goals.create( + await expect(ctx.remote.probe.create( 'agent-1', { objective: 'cancel me' }, callerAbort.signal, @@ -182,18 +202,52 @@ describe('Client TypeRT API', () => { callerAbort.abort(cancellation) expect(combinedSignal?.aborted).toBe(true) expect(combinedSignal?.reason).toBe(cancellation) - await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') + await expect(ctx.remote.probe.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"') call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) - await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"') await assembly.dispose() - expect((ctx.remote as unknown as Record).goals).toBeUndefined() - expect(ctx.get('remote.goals')).toBeUndefined() - expect(ctx.get('goals')).toBe(businessGoals) + expect((ctx.remote as unknown as Record).probe).toBeUndefined() + expect(ctx.get('remote.probe')).toBeUndefined() + expect(ctx.get('probe')).toBe(businessProbe) expect(ctx.typert.remotes.list()).toEqual([]) await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted') - disposeBusinessGoals() + disposeBusinessProbe() + }) + + it('encodes declared undefined as an omitted argument and distinguishes it from null results', async () => { + const call = vi.fn() + .mockResolvedValueOnce({ ok: true, value: undefined }) + .mockResolvedValueOnce({ ok: true, value: null }) + const ctx = await bench(call) + const dispose = await ctx.remote.$mount({ + package: '@fixture/maybe', + descriptors: [maybeDescriptor()], + }) + + // The analyzers disagree on the key-remapped namespace projection: tsc + // resolves this method, oxlint reads it as an error type. + // oxlint-disable-next-line typescript/no-unsafe-call + await expect(ctx.remote.probe.maybe(undefined)).resolves.toBeUndefined() + expect(call).toHaveBeenNthCalledWith( + 1, + '/api', + 'probe/maybe', + { args: {} }, + expect.any(AbortSignal), + ) + // oxlint-disable-next-line typescript/no-unsafe-call + await expect(ctx.remote.probe.maybe(null)).resolves.toBeNull() + expect(call).toHaveBeenNthCalledWith( + 2, + '/api', + 'probe/maybe', + { args: { value: null } }, + expect.any(AbortSignal), + ) + + await dispose() }) it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => { @@ -205,24 +259,24 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }), + (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }), { inject: ['remote'] }, )) await assembly - await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) + await expect(agentCtx.remote.probe.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' }) expect(call).toHaveBeenCalledWith( '/api', - 'goals/create', + 'probe/create', { args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' })) + await expect((ctx as FixtureContext).remote.probe.create({ objective: 'wrong scope' })) .rejects.toThrow('expected 2 business argument(s)') await assembly.dispose() - expect((ctx.remote as unknown as Record).goals).toBeUndefined() - expect(ctx.get('remote.goals')).toBeUndefined() + expect((ctx.remote as unknown as Record).probe).toBeUndefined() + expect(ctx.get('remote.probe')).toBeUndefined() }) it('uses the caller Context identity for scoped namespace methods', async () => { @@ -234,23 +288,23 @@ describe('Client TypeRT API', () => { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, }) const assembly = ctx.plugin(Object.assign( - (scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }), + (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [contextDescriptor()] }), { inject: ['remote'] }, )) await assembly - await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.probe.rename({ objective: 'land' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenCalledWith( '/api', - 'goals/rename', + 'probe/rename', { args: { agentId: 'agent-2', request: { objective: 'land' } } }, expect.any(AbortSignal), ) - await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' })) + await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'land' })) .rejects.toThrow('requires a "fixture" Context') await assembly.dispose() - expect(ctx.get('remote.goals')).toBeUndefined() + expect(ctx.get('remote.probe')).toBeUndefined() }) it('rejects weak descriptors and namespace collisions before registration', async () => { @@ -282,32 +336,32 @@ describe('Client TypeRT API', () => { await expect(ctx.remote.$mount({ package: '@fixture/direct-duplicates', - descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], + descriptors: [direct, { ...direct, id: '@fixture/probe#probe/create-again' }], })).rejects.toThrow('repeats direct method') await expect(ctx.remote.$mount({ package: '@fixture/scoped-duplicates', - descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], + descriptors: [context, { ...context, id: '@fixture/probe#probe/rename-again' }], })).rejects.toThrow('repeats scoped method') const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] }) await expect(ctx.remote.$mount({ - package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }], - })).rejects.toThrow('direct method goals/create is already mounted') + package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#probe/create' }], + })).rejects.toThrow('direct method probe/create is already mounted') await disposeDirect() const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] }) await expect(ctx.remote.$mount({ - package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }], - })).rejects.toThrow('scoped method goals/rename is already mounted') + package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#probe/rename' }], + })).rejects.toThrow('scoped method probe/rename is already mounted') await expect(ctx.remote.$mount({ package: '@fixture/service-method-conflict', - descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], + descriptors: [{ ...context, id: '@fixture/probe#probe/remove', method: 'remove' }], })).rejects.toThrow('conflicts with its namespace service') - const scopedService = ctx.get('remote.goals') as unknown as object + const scopedService = ctx.get('remote.probe') as unknown as object Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined }) await expect(ctx.remote.$mount({ package: '@fixture/service-own-property-conflict', - descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }], + descriptors: [{ ...direct, id: '@fixture/probe#probe/custom', method: 'custom' }], })).rejects.toThrow('conflicts with its namespace service') Reflect.deleteProperty(scopedService, 'custom') await disposeScoped() @@ -323,10 +377,10 @@ describe('Client TypeRT API', () => { package: '@fixture/multiple-scoped', descriptors: [directDescriptor(), contextDescriptor()], }) - await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) + await expect(agentCtx.remote.probe.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true }) expect(call).toHaveBeenLastCalledWith( '/api', - 'goals/rename', + 'probe/rename', { args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } }, expect.any(AbortSignal), ) @@ -338,7 +392,7 @@ describe('Client TypeRT API', () => { const { scope: _scope, ...first } = directDescriptor() const second: InvocationDescriptor = { ...first, - id: '@fixture/goals#goals/archive', + id: '@fixture/probe#probe/archive', method: 'archive', } const defineProperty = Object.defineProperty @@ -353,11 +407,11 @@ describe('Client TypeRT API', () => { spy.mockRestore() } - expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).probe).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) - expect(ctx.remote.goals.create).toBeTypeOf('function') - expect((ctx.remote.goals as unknown as Record).archive).toBeTypeOf('function') + expect(ctx.remote.probe.create).toBeTypeOf('function') + expect((ctx.remote.probe as unknown as Record).archive).toBeTypeOf('function') await retry() }) @@ -367,7 +421,7 @@ describe('Client TypeRT API', () => { package: '@fixture/context-anchor', descriptors: [contextDescriptor()], }) - const namespace = ctx.get('remote.goals') as unknown as { + const namespace = ctx.get('remote.probe') as unknown as { installScoped: (...args: unknown[]) => void readonly create?: unknown } @@ -429,28 +483,28 @@ describe('Client TypeRT API', () => { const ctx = await bench(call) const descriptor = directDescriptor() const dispose = await ctx.remote.$mount({ - package: '@fixture/goals', + package: '@fixture/probe', descriptors: [descriptor, contextDescriptor()], }) - const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise - const goals = (ctx as FixtureContext).remote.goals - const rename = goals.rename as unknown as (...args: unknown[]) => Promise + const create = ctx.remote.probe.create as unknown as (...args: unknown[]) => Promise + const probe = (ctx as FixtureContext).remote.probe + const rename = probe.rename as unknown as (...args: unknown[]) => Promise await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1') await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra')) .rejects.toThrow('got 4') - await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0') - await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' })) + await expect(rename.call(probe)).rejects.toThrow('expected 1 argument(s), got 0') + await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' })) .rejects.toThrow('expected 2 business argument(s)') - await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' })) + await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' })) .rejects.toThrow('no Client Context binder') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' - await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict' ctx.set('connection', undefined) - await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') await dispose() }) @@ -464,23 +518,23 @@ describe('Client TypeRT API', () => { const { scope: _scope, ...first } = directDescriptor() const second: InvocationDescriptor = { ...first, - id: '@fixture/goals#goals/archive', + id: '@fixture/probe#probe/archive', method: 'archive', } - const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] }) - const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' }) + const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [first, second] }) + const invocation = ctx.remote.probe.create('agent-1', { objective: 'ship' }) await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) await dispose() resolveCall({ ok: true, value: { ref: 'goal-1' } }) await expect(invocation).rejects.toThrow('withdrawn during invocation') - expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).probe).toBeUndefined() }) it('fails a method obtained from a withdrawn namespace getter', async () => { const ctx = await bench(vi.fn()) - const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) - const namespace = ctx.get('remote.goals') as unknown as object + const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) + const namespace = ctx.get('remote.probe') as unknown as object const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace) await dispose() @@ -498,7 +552,7 @@ describe('Client TypeRT API', () => { const { scope: _scope, ...base } = directDescriptor() const descriptor: InvocationDescriptor = { ...base, - id: '@fixture/goals#goals/prototype', + id: '@fixture/probe#probe/prototype', method: 'prototype', parameters: [{ name: 'value', @@ -509,7 +563,7 @@ describe('Client TypeRT API', () => { } const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] }) - const method = (ctx.remote.goals as unknown as Record Promise>).prototype + const method = (ctx.remote.probe as unknown as Record Promise>).prototype await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) const payload = call.mock.calls[0]?.[2] as { readonly args: Record } expect(Object.getPrototypeOf(payload.args)).toBeNull() @@ -526,15 +580,15 @@ describe('Client TypeRT API', () => { return defineProperty(target, key, attributes) }) try { - await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) + await expect(ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })) .rejects.toThrow('fixture namespace startup failure') await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) } finally { spy.mockRestore() } - const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) - expect(ctx.remote.goals.create).toBeTypeOf('function') + const retry = await ctx.remote.$mount({ package: '@fixture/probe-retry', descriptors: [directDescriptor()] }) + expect(ctx.remote.probe.create).toBeTypeOf('function') await retry() }) @@ -554,13 +608,13 @@ describe('Client TypeRT API', () => { spy.mockRestore() } - expect((ctx.remote as unknown as Record).goals).toBeUndefined() + expect((ctx.remote as unknown as Record).probe).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) const retry = await ctx.remote.$mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()], }) - expect(ctx.remote.goals.create).toBeTypeOf('function') + expect(ctx.remote.probe.create).toBeTypeOf('function') await retry() }) @@ -578,35 +632,35 @@ describe('Client TypeRT API', () => { spy.mockRestore() } - expect(ctx.get('remote.goals')).toBeUndefined() + expect(ctx.get('remote.probe')).toBeUndefined() await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) - expect((ctx.get('remote.goals') as unknown as Record).rename).toBeTypeOf('function') + expect((ctx.get('remote.probe') as unknown as Record).rename).toBeTypeOf('function') await retry() }) it('unregisters an empty scoped namespace so another provider can claim its name', async () => { const ctx = await bench(vi.fn()) const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] }) - expect(ctx.get('remote.goals')).toBeDefined() + expect(ctx.get('remote.probe')).toBeDefined() await dispose() - expect(ctx.get('remote.goals')).toBeUndefined() + expect(ctx.get('remote.probe')).toBeUndefined() const replacement = { owner: 'replacement' } - const disposeReplacement = ctx.reflect.provide('remote.goals', replacement) - expect(ctx.get('remote.goals')).toBe(replacement) + const disposeReplacement = ctx.reflect.provide('remote.probe', replacement) + expect(ctx.get('remote.probe')).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 })) - await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }) + await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) let failure: unknown try { - await ctx.remote.goals.create('agent-1', { objective: 'ship' }) + await ctx.remote.probe.create('agent-1', { objective: 'ship' }) } catch (error) { failure = error } diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index d66e025ffd..f2f545d3c3 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -8,6 +8,7 @@ * their CAS ref reads the session's current projected value at call time. * Goal creation stays on the /goal host command. */ +import type { RemoteResult } from '@deepseek-ai/dsh-type-meta' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. import type {} from '@deepseek-ai/dsh-api-remotes/client' @@ -40,29 +41,11 @@ const NS = 'goal' /** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */ export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents'] -/** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */ -async function settle(invoke: () => Promise): Promise { - try { - await invoke() - return { ok: true } - } catch (error) { - const cause = error instanceof Error ? error.cause : undefined - if (isRemoteError(cause)) return { ok: false, error: { code: cause.code, message: cause.message } } - return { - ok: false, - error: { - code: 'internal', - message: error instanceof Error ? error.message : 'goal mutation failed', - }, - } - } -} - -function isRemoteError(value: unknown): value is { readonly code: string; readonly message: string } { - return value !== null - && typeof value === 'object' - && typeof (value as { code?: unknown }).code === 'string' - && typeof (value as { message?: unknown }).message === 'string' +/** Narrow one Remote mutation's result to the fields the goal strip renders. */ +function settle(result: RemoteResult): GoalActionResult { + return result.ok + ? { ok: true } + : { ok: false, error: { code: result.error.code, message: result.error.message } } } /** @@ -103,22 +86,22 @@ export function apply(ctx: ClientContext): void { onEdit: async (objective) => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.remote.goals.edit(sessionId, ref, { objective })) + return settle(await ctx.remote.goals.edit(sessionId, ref, { objective })) }, onPause: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.remote.goals.pause(sessionId, ref)) + return settle(await ctx.remote.goals.pause(sessionId, ref)) }, onResume: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.remote.goals.resume(sessionId, ref)) + return settle(await ctx.remote.goals.resume(sessionId, ref)) }, onClear: async () => { const ref = refOf(sessionId) if (ref === undefined) return noCurrentGoal - return settle(() => ctx.remote.goals.clear(sessionId, ref)) + return settle(await ctx.remote.goals.clear(sessionId, ref)) }, }), }, GoalDock)) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index cbed0047c3..03dcc30d36 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -308,6 +308,7 @@ export class FaceModelEmitter { lines.push(` wire: ${quote(parameter.wire)},`) lines.push(` source: ${quote(parameter.source)},`) if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`) + if (parameter.boundary.acceptsUndefined) lines.push(' acceptsUndefined: true,') lines.push(` codec: ${indent(strictCodec( parameter.boundary, schemas.boundary(parameterBoundaryKey(invocation, index)), @@ -342,6 +343,7 @@ export class FaceModelEmitter { const lines = [ '/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */', 'import type {', + ' RemoteResult,', ' TypeRTRemoteContribution,', '} from \'@deepseek-ai/dsh-type-meta\'', ] @@ -462,10 +464,13 @@ export class FaceModelEmitter { ): string { const parameters = invocation.parameters.filter(parameter => !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => - `${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) + `${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') const result = this.renderer.renderType(invocation.result.type, referenceNames) - return `(${parameters.join(', ')}) => Promise<${result}>` + // The Client Remote face delivers the carrier's outcome, so every generated + // consumer signature resolves to a result the caller reads instead of a + // value it must guard with its own try/catch. + return `(${parameters.join(', ')}) => Promise>` } } diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 3faef9c8be..567feaae4e 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -23,6 +23,7 @@ interface RuntimeDescriptor { readonly cancellation?: { readonly parameter: 'signal' } readonly parameters: readonly { readonly wire: string + readonly acceptsUndefined?: true readonly codec: { readonly schema: RuntimeSchema } }[] readonly result: { readonly schema: RuntimeSchema } @@ -146,6 +147,57 @@ describe('Remote model generation', { timeout: 60_000 }, () => { assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap) }) + it('projects authored optionality and absence onto consumers and codecs', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + '\n}\n\nexport type {', + ` + + @Remote + maybe(value: string | undefined): string | undefined { + return value + } + + @Remote + labelled(id: string, label?: string): string { + return label ?? id + } + + @Remote + clear(): void {} +} + +export type {`, + )) + + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.remote?.dts).toContain( + "'goals/maybe': (value: string | undefined) => Promise", + ) + expect(artifact?.remote?.dts).toContain("'goals/clear': () => Promise") + // An explicit `T | undefined` stays a required argument; only authored + // optionality lets a consumer omit the field. + expect(artifact?.remote?.dts).not.toContain('value?: string') + expect(artifact?.remote?.dts).toContain("'goals/labelled': (id: string, label?: string) => Promise") + + const remoteJs = artifact?.remote?.js + if (remoteJs === undefined) throw new Error('undefined Remote fixture emitted no Host-for-Client JavaScript') + const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`) + const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule + const maybe = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/maybe')) + const clear = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/clear')) + expect(maybe?.parameters[0]?.acceptsUndefined).toBe(true) + expect(maybe?.parameters[0]?.codec.schema.safeParse(undefined).success).toBe(true) + expect(maybe?.result.schema.safeParse(undefined).success).toBe(true) + expect(clear?.result.schema.safeParse(undefined).success).toBe(true) + expect(clear?.result.schema.safeParse(null).success).toBe(false) + const labelled = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/labelled')) + expect(labelled?.parameters[0]?.acceptsUndefined).toBeUndefined() + expect(labelled?.parameters[1]?.acceptsUndefined).toBe(true) + expect(labelled?.parameters[1]?.codec.schema.safeParse(undefined).success).toBe(true) + expect(labelled?.parameters[1]?.codec.schema.safeParse(7).success).toBe(false) + }) + it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => { const root = copyFixture() editFile(root, 'packages/remote/src/types.ts', source => `${source} @@ -436,9 +488,9 @@ export interface ClientMarker { message: 'Remote parameters cannot have default values', }, { - name: 'optional parameter', - edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'), - message: 'Remote parameters cannot be optional', + name: 'optional lookup parameter', + edit: (source: string) => source.replace('agent: Agent,', 'agent?: Agent,'), + message: 'lookup parameter for agent cannot be optional', }, { name: 'wrong cancellation type', diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 3becc67c83..7bbf3f107a 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -41,6 +41,8 @@ export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, + RemoteFailure, + RemoteResult, TypeRTClientRemote, TypeRTClientContextBinder, TypeRTCodec, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index c08fb209ee..9fffb8a468 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -39,6 +39,28 @@ export interface TypeRTContextMap {} /** Merge-extensible direct Remote method signatures generated for consumers. */ export interface TypeRTRemoteMap {} +/** + * One Remote call's failure as the carrier reported it. `code` stays open here: + * the closed RPC code union belongs to the carrier package, which already + * depends on this one, so naming it would invert that edge. + */ +export interface RemoteFailure { + readonly code: string + readonly message: string + readonly details: object +} + +/** + * What every generated Remote method resolves to. The Remote face itself folds + * carrier failures into the error branch, so no consumer wraps a call to + * recover one; only assembly faults (arity, an unmounted method, a missing + * Context binder) still reject. + * @template T - the Host method's business result. + */ +export type RemoteResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: RemoteFailure } + /** Merge-extensible scoped Remote method signatures generated for consumers. */ export interface TypeRTRemoteScopeMap {} @@ -136,6 +158,8 @@ export interface InvocationParameterDescriptor { readonly lookup?: string /** Boundary codec for the wire representation. */ readonly codec: TypeRTCodec + /** Missing wire fields decode to `undefined` only for an explicitly declared `T | undefined`. */ + readonly acceptsUndefined?: true } /** Source position retained for diagnostics from generated definitions. */