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:
@@ -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: {} } }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../gateway"
|
||||
"path": "../gateway/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
|
||||
Reference in New Issue
Block a user