Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui

Adapt to two contract changes master introduced:

- The generated Remote face now wraps every business result in
  RemoteResult, folding carrier failures into an ok:false branch instead
  of rejecting. The controller reads that envelope at its three call
  sites and maps a carrier failure onto the same settled shape the
  controls already render; three specs cover the new branch.
- Client packages split their tsconfig into host and client halves, and
  the host aggregate now compiles any test not named *.client.spec.*.
  Rename this package's specs to the client convention and drop the
  ../connection project reference, which pointed at a solution file that
  no longer carries the client sources.

Keep master's mount loop with its rollback-on-failure in api-remotes and
add messageFeedbackRemote to it.
This commit is contained in:
Chinesezjc
2026-08-12 10:43:23 +08:00
parent 47f254a252
commit b462d5fd69
507 changed files with 3130 additions and 2238 deletions
+30 -13
View File
@@ -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<unknown> {
): Promise<RemoteResult<unknown>> {
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<unknown> {
): Promise<RemoteResult<unknown>> {
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<unknown>
) => Promise<RemoteResult<unknown>>
class RemoteNamespaceService extends Service {
private readonly methods = new Map<string, RemoteMethodRecord>()
@@ -467,7 +475,7 @@ class RemoteNamespaceService extends Service {
Object.defineProperty(this, method, {
configurable: true,
enumerable: true,
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<RemoteResult<unknown>> {
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<never> {
return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`)
}
function carrierFailure(endpoint: string, error: unknown): RemoteResult<never> {
return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`)
}
function internalFailure(message: string): RemoteResult<never> {
return { ok: false, error: { code: 'internal', message, details: {} } }
}
+50 -3
View File
@@ -70,6 +70,18 @@ export class TypertGatewayError extends Error {
}
}
/** Business invocation lost its carrier cancellation race. */
class RemoteInvocationCancelled extends Error {
/**
* @param endpoint - canonical Remote endpoint.
* @param cause - business rejection observed after carrier cancellation.
*/
constructor(endpoint: string, cause: unknown) {
super(`Remote invocation "${endpoint}" was aborted`, { cause })
this.name = 'RemoteInvocationCancelled'
}
}
/**
* Resolve strict generated definitions or conservative SRC markers against
* current Cordis Services and TypeRT providers.
@@ -157,7 +169,17 @@ export class TypertGatewayService extends Service implements TypertGateway {
)
}
const result = await Reflect.apply(method, receiver, args) as unknown
let result: unknown
try {
result = await Reflect.apply(method, receiver, args) as unknown
} catch (error) {
if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error)
throw error
}
// A weak descriptor declares no return type, so nothing returned is a void
// result and rides the wire as an absent value field. A strict descriptor
// keeps its schema: there, undefined has to be a declared result.
if (result === undefined && descriptor.result.mode !== 'strict') return result
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
}
@@ -190,6 +212,9 @@ export class TypertGatewayService extends Service implements TypertGateway {
args: payload.args,
signal,
})
// A void or explicitly absent business result carries no `value` field;
// JSON has no `undefined`, and the envelope's optional slot is the one
// representation of absence that both args and results already use.
return { ok: true, value }
} catch (error) {
return rpcFailure(error)
@@ -384,6 +409,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
args: Readonly<Record<string, unknown>>,
endpoint: string,
): Promise<unknown> {
// An absent field reached assertExactArguments' allowance, so this parameter
// takes undefined; a present-but-undefined field is not JSON-safe input and
// still fails decode. Lookup ids are never omissible, so absence here only
// ever belongs to a json parameter.
if (!Object.hasOwn(args, parameter.wire)) return undefined
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
if (parameter.source === 'json') return value
const key = parameter.lookup
@@ -439,6 +469,12 @@ export class TypertGatewayService extends Service implements TypertGateway {
}
function rpcFailure(error: unknown): ConnectionRpcResult {
if (error instanceof RemoteInvocationCancelled) {
return {
ok: false,
error: { code: 'cancelled', message: error.message, details: {} },
}
}
if (error instanceof TypeRTLookupFailure) {
return { ok: false, error: error.failure as ConnectionRpcError }
}
@@ -559,7 +595,15 @@ function assertExactArguments(
if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
const actual = Reflect.ownKeys(args)
const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
const missing = [...expected].filter(key => !Object.hasOwn(args, key))
// A JSON field may be omitted when the strict descriptor declares absence,
// and always under SRC: a weak descriptor reads parameter names from the
// JavaScript signature and cannot see which are optional, so LIB is where an
// omitted required argument is caught. Lookup ids are never omissible.
const acceptsMissing = new Set(descriptor.parameters
.filter(parameter => parameter.source === 'json'
&& (parameter.acceptsUndefined === true || parameter.codec.mode === 'src-json'))
.map(parameter => parameter.wire))
const missing = [...expected].filter(key => !Object.hasOwn(args, key) && !acceptsMissing.has(key))
if (extra.length === 0 && missing.length === 0) return
const clauses: string[] = []
if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
@@ -575,7 +619,10 @@ function decode(
field: string,
): unknown {
try {
if (codec.mode === 'strict') value = codec.schema.parse(value)
if (codec.mode === 'strict') {
value = codec.schema.parse(value)
if (value === undefined) return value
}
assertJsonValue(value, new Set())
return value
} catch (cause) {
+198 -96
View File
@@ -5,6 +5,7 @@ import { z } from 'zod'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type {
InvocationDescriptor,
RemoteResult,
TypeRTClientRemote,
TypeRTContext,
TypeRTRemoteScopeApi,
@@ -42,23 +43,26 @@ 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 }>
) => Promise<RemoteResult<{ readonly ref: string }>>
'probe/maybe': (value: string | null | undefined) => Promise<RemoteResult<string | null | undefined>>
}
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 }>
) => Promise<RemoteResult<{ readonly ref: string }>>
'fixture:probe/rename': (
request: { readonly objective: string },
) => Promise<RemoteResult<{ readonly renamed: boolean }>>
}
interface TypeRTRemoteNamespaceMap {
goals: TypeRTRemoteNamespace<'goals'>
probe: TypeRTRemoteNamespace<'probe'>
}
}
@@ -87,9 +91,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 +116,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 +136,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<Context> {
const { ctx } = await benchFiber(call)
return ctx
@@ -153,28 +176,29 @@ describe('Client TypeRT API', () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.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({ ok: true, value: { 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,
)).resolves.toEqual({ ref: 'goal-1' })
)).resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
const combinedSignal = call.mock.calls.at(-1)?.[3]
expect(combinedSignal).toBeInstanceOf(AbortSignal)
expect(combinedSignal).not.toBe(callerAbort.signal)
@@ -182,18 +206,62 @@ 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' })).resolves.toEqual({
ok: false,
error: {
code: 'internal',
message: 'client api: probe/create failed: client api: probe/create rejected "result"',
details: {},
},
})
await assembly.dispose()
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined()
expect(ctx.get('goals')).toBe(businessGoals)
expect((ctx.remote as unknown as Record<string, unknown>).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()
await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toEqual({
ok: false,
error: {
code: 'internal',
message: 'client api: Remote method probe/create is no longer mounted',
details: {},
},
})
disposeBusinessProbe()
})
it('encodes declared undefined as an omitted argument and distinguishes it from null results', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.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()],
})
await expect(ctx.remote.probe.maybe(undefined)).resolves.toStrictEqual({ ok: true, value: undefined })
expect(call).toHaveBeenNthCalledWith(
1,
'/api',
'probe/maybe',
{ args: {} },
expect.any(AbortSignal),
)
await expect(ctx.remote.probe.maybe(null)).resolves.toStrictEqual({ ok: true, value: null })
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 +273,25 @@ 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({ ok: true, value: { 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<string, unknown>).goals).toBeUndefined()
expect(ctx.get('remote.goals')).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
expect(ctx.get('remote.probe')).toBeUndefined()
})
it('uses the caller Context identity for scoped namespace methods', async () => {
@@ -234,23 +303,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: [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({ ok: true, value: { 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 +352,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 +393,11 @@ 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({ ok: true, value: { renamed: true } })
expect(call).toHaveBeenLastCalledWith(
'/api',
'goals/rename',
'probe/rename',
{ args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
expect.any(AbortSignal),
)
@@ -338,7 +409,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 +424,11 @@ describe('Client TypeRT API', () => {
spy.mockRestore()
}
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).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<string, unknown>).archive).toBeTypeOf('function')
expect(ctx.remote.probe.create).toBeTypeOf('function')
expect((ctx.remote.probe as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
await retry()
})
@@ -367,7 +438,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 +500,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<unknown>
const goals = (ctx as FixtureContext).remote.goals
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
const create = ctx.remote.probe.create as unknown as (...args: unknown[]) => Promise<unknown>
const probe = (ctx as FixtureContext).remote.probe
const rename = probe.rename as unknown as (...args: unknown[]) => Promise<unknown>
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 +535,30 @@ 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<string, unknown>).goals).toBeUndefined()
await expect(invocation).resolves.toEqual({
ok: false,
error: {
code: 'internal',
message: 'client api: Remote method probe/create is no longer mounted',
details: {},
},
})
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
})
it('fails a method obtained from a withdrawn namespace getter', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
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 +576,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,8 +587,8 @@ describe('Client TypeRT API', () => {
}
const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })
const method = (ctx.remote.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
const method = (ctx.remote.probe as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
await expect(method?.('wire-value')).resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
expect(Object.getPrototypeOf(payload.args)).toBeNull()
expect(Object.hasOwn(payload.args, '__proto__')).toBe(true)
@@ -526,15 +604,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 +632,13 @@ describe('Client TypeRT API', () => {
spy.mockRestore()
}
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).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,42 +656,66 @@ 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<string, unknown>).rename).toBeTypeOf('function')
expect((ctx.get('remote.probe') as unknown as Record<string, unknown>).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<ConnectionHandle['rpc']['call']>())
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 () => {
it('delivers an RPC failure in the error branch with the Host error verbatim', async () => {
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().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' })
} catch (error) {
failure = error
}
expect(failure).toBeInstanceOf(Error)
if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
expect(failure.message).toContain('internal: host failed')
expect(failure.cause).toBe(rpcError)
const outcome = await ctx.remote.probe.create('agent-1', { objective: 'ship' })
expect(outcome.ok).toBe(false)
if (outcome.ok) throw new Error('expected the Client API invocation to report a failure')
expect(outcome.error).toBe(rpcError)
})
it('folds a transport throw into the error branch', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>()
.mockRejectedValue(new Error('carrier offline')))
await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({
ok: false,
error: {
code: 'internal',
message: 'client api: probe/create failed: carrier offline',
details: {},
},
})
})
it('folds a carrier throw that is not an Error into the error branch', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>()
.mockRejectedValue('carrier exploded'))
await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({
ok: false,
error: {
code: 'internal',
message: 'client api: probe/create failed: carrier exploded',
details: {},
},
})
})
it('owns each $on subscription in the calling fiber', async () => {
+74 -2
View File
@@ -77,6 +77,12 @@ class GoalService extends Service {
return this.nextResult === undefined ? value : this.nextResult
}
@Remote
maybe(value: string | null | undefined): string | null | undefined {
this.calls.push('maybe')
return value
}
@Remote
fail(request: unknown): never {
void request
@@ -782,6 +788,19 @@ describe('TypertGatewayService', () => {
}), 'input-invalid')
})
it('admits an omitted SRC field and hands the Host method undefined', async () => {
const { ctx, service } = await setup()
// A weak descriptor reads parameter names from the JavaScript signature and
// cannot see which are optional, so an absent field is admitted; the case
// above keeps an explicitly undefined field rejected.
await expect(ctx.typertGateway.invoke({
namespace: 'goals',
method: 'passthrough',
args: {},
})).resolves.toBeUndefined()
expect(service.calls).toContain('passthrough')
})
it('rejects cyclic SRC input and non-JSON SRC results', async () => {
const { ctx, service } = await setup()
const cyclic: { self?: unknown } = {}
@@ -945,7 +964,7 @@ describe('TypertGatewayService', () => {
expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' })
registerAgentLookup(ctx, { id: 'agent-1' })
registerStrict(ctx, [createDescriptor()])
registerStrict(ctx, [createDescriptor(), maybeDescriptor()])
expect(connection.matches?.('goals/create')).toBe(true)
expect(connection.matches?.('goals/passthrough')).toBe(true)
expect(connection.matches?.('goals')).toBe(false)
@@ -973,6 +992,15 @@ describe('TypertGatewayService', () => {
if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
expect(invalid.error.message).toMatch(/exactly one plain-object args field/)
await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toEqual({
ok: true,
value: undefined,
})
await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toEqual({
ok: true,
value: null,
})
for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) {
const result = await handler(endpoint, { args: {} }, signal)
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
@@ -987,11 +1015,33 @@ describe('TypertGatewayService', () => {
}
service.businessError = 'non-error failure' as unknown as Error
await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({
await expect(handler(
'goals/fail',
{ args: { request: null } },
new AbortController().signal,
)).resolves.toEqual({
ok: false,
error: { code: 'internal', message: 'non-error failure', details: {} },
})
// A business rejection observed while the carrier signal is already aborted
// is the caller's cancellation, not an internal gateway fault.
const cancelledCall = new AbortController()
cancelledCall.abort(new Error('client disconnected'))
service.businessError = new Error('fixture business failure')
await expect(handler(
'goals/fail',
{ args: { request: null } },
cancelledCall.signal,
)).resolves.toEqual({
ok: false,
error: {
code: 'cancelled',
message: 'Remote invocation "goals/fail" was aborted',
details: {},
},
})
await gatewayFiber.dispose()
expect(connection.handler).toBeUndefined()
})
@@ -1302,6 +1352,28 @@ function strictOnlyDescriptor(): InvocationDescriptor {
}
}
function maybeDescriptor(): InvocationDescriptor {
const value = strictCodec(
'@fixture/gateway#MaybeValue',
z.union([z.string(), z.null(), z.undefined()]),
)
return {
id: '@fixture/gateway#goals/maybe',
service: 'goals',
namespace: 'goals',
method: 'maybe',
invocation: { kind: 'direct' },
parameters: [{
name: 'value',
wire: 'value',
source: 'json',
acceptsUndefined: true,
codec: value,
}],
result: value,
}
}
async function expectCode(
promise: Promise<unknown>,
code: TypertGatewayError['code'],
+22
View File
@@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"files": [
"src/client/index.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../client/connection/tsconfig.client.json"
},
{
"path": "../../typert/type-meta"
}
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
},
"files": [
"src/index.ts",
"src/invariant.ts",
"src/types.ts"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
},
{
"path": "../../client/connection/tsconfig.host.json"
},
{
"path": "../../typert/type-meta"
}
]
}
+3 -19
View File
@@ -1,27 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"files": [],
"references": [
{
"path": "../../../vendor/cosmokit"
"path": "./tsconfig.host.json"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
},
{
"path": "../../client/connection"
},
{
"path": "../../typert/type-meta"
"path": "./tsconfig.client.json"
}
]
}
+26 -9
View File
@@ -1,11 +1,13 @@
/** Platform-neutral assembly of generated Host Remote contributions. */
import type { Context } from '@deepseek-ai/cordis'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
export type {} from '@deepseek-ai/dsh-message-feedback/remote'
// The forwarded-event allowlist's selection seat: without it in the consumer's
@@ -19,12 +21,22 @@ export type {} from '@deepseek-ai/dsh-credentials/types'
export type {} from '@deepseek-ai/dsh-llm/types'
export type {} from '@deepseek-ai/dsh-agent-presets/types'
export type {} from '@deepseek-ai/dsh-settings/types'
/**
* The Gateway Client face's own declaration merges, type-only: `ctx.remote` and
* with it the `$on`/`$dispatch` surface. Erased at emit, so this facade still
* carries no runtime edge to the Gateway implementation.
* The carrier's Client-facing types, re-exported so a business package names one
* assembly package instead of both this facade and the Connection plugin. Type-only:
* the carrier's runtime values stay behind their own module edge.
*/
export type {} from '@deepseek-ai/dsh-api-gateway/client'
export type {
ClientResponse, ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock,
CredentialView, DirectoryListing, DiscoveredModelView, HistoryEntry, HostFrame, IApiClient,
MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem,
SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
SubagentAddress, SubagentCatalog, TaskView, ToolCallView, ToolEventView, ToolResultView,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -42,11 +54,16 @@ export const inject = ['remote']
* @returns disposer after every selected Remote namespace is ready.
*/
export async function apply(ctx: Context): Promise<() => Promise<void>> {
const mounted = [
await ctx.remote.$mount(goalsRemote),
await ctx.remote.$mount(messageFeedbackRemote),
]
const disposers: Array<() => Promise<void>> = []
try {
for (const contribution of [commandsRemote, goalsRemote, messageFeedbackRemote]) {
disposers.push(await ctx.remote.$mount(contribution))
}
} catch (error) {
for (const dispose of disposers.reverse()) await dispose()
throw error
}
return async () => {
for (const dispose of mounted.reverse()) await dispose()
for (const dispose of disposers.reverse()) await dispose()
}
}
+6 -4
View File
@@ -147,19 +147,21 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
} catch {
invalidRejected = true
}
// Every generated method resolves to the RemoteResult envelope; the
// business values below are what the assertions pin.
const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
const rootEdit = await client.remote.goals.edit(
rootAgent.id,
rootResult.ref,
rootResult.value.ref,
{ objective: 'edited root goal' },
)
const agentContext = client.extend({ builtAgentId: scopedAgent.id })
const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
const result = {
invalidRejected,
rootResult,
rootEdit,
scopedResult,
rootResult: rootResult.value,
rootEdit: rootEdit.value,
scopedResult: scopedResult.value,
rootGoal: host.goals.get(rootAgent)?.objective,
scopedGoal: host.goals.get(scopedAgent)?.objective,
rootEvents: rootAgent.session.events.length,
+4 -1
View File
@@ -15,7 +15,10 @@
"path": "../../../vendor/cordis"
},
{
"path": "../gateway"
"path": "../gateway/tsconfig.client.json"
},
{
"path": "../../client/connection/tsconfig.client.json"
},
{
"path": "../../credentials/credentials"