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
+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'],