feat(typert): propagate Remote cancellation

This commit is contained in:
imccyu
2026-08-06 18:13:15 +08:00
parent 9b63d72c94
commit 22bec5e63f
28 changed files with 280 additions and 66 deletions

View File

@@ -17,11 +17,18 @@ declare module '@deepseek-ai/dsh-type-meta' {
}
interface TypeRTRemoteMap {
'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }>
'goals/create': (
agentId: string,
request: { readonly objective: string },
signal?: AbortSignal,
) => Promise<{ readonly ref: string }>
}
interface TypeRTRemoteContextMap {
'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }>
'fixture:goals/create': (
request: { readonly objective: string },
signal?: AbortSignal,
) => Promise<{ readonly ref: string }>
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
}
@@ -58,6 +65,7 @@ function directDescriptor(): InvocationDescriptor {
source: 'json',
codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
}],
cancellation: { parameter: 'signal' },
result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
}
}
@@ -114,6 +122,19 @@ describe('Client TypeRT API', () => {
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
expect.any(AbortSignal),
)
const callerAbort = new AbortController()
await expect(ctx.api.goals.create(
'agent-1',
{ objective: 'cancel me' },
callerAbort.signal,
)).resolves.toEqual({ ref: 'goal-1' })
const combinedSignal = call.mock.calls.at(-1)?.[3]
expect(combinedSignal).toBeInstanceOf(AbortSignal)
expect(combinedSignal).not.toBe(callerAbort.signal)
const cancellation = new Error('caller cancelled')
callerAbort.abort(cancellation)
expect(combinedSignal?.aborted).toBe(true)
expect(combinedSignal?.reason).toBe(cancellation)
await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
@@ -299,10 +320,18 @@ describe('Client TypeRT API', () => {
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
const ctx = await bench(call)
const descriptor = directDescriptor()
const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] })
const dispose = ctx.api.mount({
package: '@fixture/goals',
descriptors: [descriptor, contextDescriptor()],
})
const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
const goals = (ctx as FixtureContext).goals
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1')
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).goals.create({ objective: 'ship' }))
.rejects.toThrow('no Client Context binder')

View File

@@ -45,6 +45,7 @@ const emptyModel: TypertContribution['model'] = {
class GoalService extends Service {
readonly typertGateway = bindTypeRTGateway(this, 'goals')
readonly calls: string[] = []
lastSignal: AbortSignal | undefined
nextResult: unknown = undefined
businessError: Error | undefined
@@ -53,8 +54,9 @@ class GoalService extends Service {
}
@Remote
create(agent: FixtureAgent, request: { readonly title: string }): unknown {
create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown {
this.calls.push('create')
this.lastSignal = signal
return {
agentId: agent.id,
title: request.title,
@@ -224,6 +226,19 @@ class RestParameterService extends Service {
}
}
class NonFinalSignalService extends Service {
readonly typertGateway = bindTypeRTGateway(this, 'nonFinalSignal', { namespace: 'invalid-signal' })
constructor(ctx: Context) {
super(ctx, 'nonFinalSignal')
}
@Remote
run(signal: AbortSignal, value: string): string {
return signal.aborted ? '' : value
}
}
class WrongBindingService extends Service {
readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' })
@@ -334,13 +349,24 @@ describe('TypertGatewayService', () => {
registerAgentLookup(ctx, agent)
registerStrict(ctx, [createDescriptor()])
const caller = ctx.extend({ fixtureScope: 'direct-caller' })
const abort = new AbortController()
await expect(caller.typertGateway.invoke({
namespace: 'goals',
method: 'create',
args: { agentId: 'agent-1', request: { title: ' ship ' } },
signal: abort.signal,
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' })
expect(service.calls).toEqual(['create'])
expect(service.lastSignal).toBe(abort.signal)
await expect(caller.typertGateway.invoke({
namespace: 'goals',
method: 'create',
args: { agentId: 'agent-1', request: { title: 'again' } },
})).resolves.toEqual({ agentId: 'agent-1', title: 'again', scope: 'direct-caller' })
expect(service.lastSignal).toBeInstanceOf(AbortSignal)
expect(service.lastSignal?.aborted).toBe(false)
})
it('resolves strict Remote Context identity without adding a business argument', async () => {
@@ -358,16 +384,19 @@ describe('TypertGatewayService', () => {
})
it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => {
const { ctx } = await setup()
const { ctx, service } = await setup()
const agent = { id: 'agent-1' }
registerAgentLookup(ctx, agent)
const caller = ctx.extend({ fixtureScope: 'direct-src' })
const abort = new AbortController()
await expect(caller.typertGateway.invoke({
namespace: 'goals',
method: 'create',
args: { agentId: 'agent-1', request: { title: 'ship' } },
signal: abort.signal,
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' })
expect(service.lastSignal).toBe(abort.signal)
})
it('does not downgrade an observed SRC lookup after its provider unloads', async () => {
@@ -605,6 +634,7 @@ describe('TypertGatewayService', () => {
{ plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } },
{ plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } },
{ plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } },
{ plugin: NonFinalSignalService, namespace: 'invalid-signal', args: { value: 'x' } },
] as const
for (const testCase of cases) {
const ctx = await setupGateway()
@@ -874,7 +904,8 @@ describe('TypertGatewayService', () => {
expect(connection.matches?.('goals')).toBe(false)
expect(connection.matches?.('goals/missing')).toBe(false)
expect(connection.matches?.('legacy/list')).toBe(false)
const signal = new AbortController().signal
const abort = new AbortController()
const signal = abort.signal
const handler = connection.handler
if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor')
await expect(handler('goals/create', {
@@ -883,6 +914,10 @@ describe('TypertGatewayService', () => {
ok: true,
value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' },
})
const service = rawGoalService(ctx)
expect(service.lastSignal).toBe(signal)
abort.abort(new Error('client disconnected'))
expect(service.lastSignal?.aborted).toBe(true)
const invalid = await handler('goals/create', { invalid: true }, signal)
expect(invalid).toMatchObject({
ok: false,
@@ -904,7 +939,6 @@ describe('TypertGatewayService', () => {
expect(result.error.message).toContain('plain-object args field')
}
const service = rawGoalService(ctx)
service.businessError = 'non-error failure' as unknown as Error
await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({
ok: false,
@@ -1099,6 +1133,7 @@ function createDescriptor(): InvocationDescriptor {
})),
},
],
cancellation: { parameter: 'signal' },
result: strictCodec('@fixture/gateway#CreateResult', z.object({
agentId: z.string(),
title: z.string(),