`CommandService.list` and `execute` carry the wire contract directly through `@Remote`, and the Client assembly mounts the generated commands contribution. The legacy API Proxy route, its schemas, the map rows, the generated client methods and the fixture's command domain are removed, so the catalog and the admission call have one owner again. `Session.command()` keeps a result-shaped public face for parity with the prompt, cancel and attachment neighbours it sits beside, and reads the generated namespace through one `SessionRemotes` parameter. The Session cluster declares that face against the owning business package rather than the generated contribution: the Host compiler aggregate builds this package, and it runs before any contribution is emitted. Migrated calls lose the `title-invalid` class of protocol-only error codes and report `internal`; no production caller branched on them.
135 lines
5.3 KiB
TypeScript
135 lines
5.3 KiB
TypeScript
/**
|
|
* Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to
|
|
* the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is
|
|
* api-gateway's own coverage); each established connection generation emits
|
|
* `connection/reset` for generation-scoped cache invalidation.
|
|
*/
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client'
|
|
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
|
// Type-only: the api-remotes facade carries both the allowlist's selection seat
|
|
// and the owner packages' `./types` declarations, which together give `$on` its
|
|
// key face and per-event listener signatures.
|
|
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
|
import * as RuntimeClient from '../src/client/index.ts'
|
|
import { FakeApiClient } from './fake-api.ts'
|
|
|
|
/**
|
|
* Compile-time face of `ctx.remote.$on`, asserted by type-checking this file
|
|
* rather than by running it: the allowlist narrows the key set, and each
|
|
* listener's parameters come from the owner package's own cordis `Events`
|
|
* declaration (so a brand cannot be flattened on the way to a consumer).
|
|
* @param ctx - any client Context carrying the Remote service.
|
|
*/
|
|
function forwardedEventContracts(ctx: Context): void {
|
|
ctx.remote.$on('settings/document-updated', (namespace, source) => {
|
|
// @ts-expect-error -- the brand survives the wire: a bare string is not a SettingsNamespace
|
|
const bare: typeof namespace = 'plain-string'
|
|
void bare; void namespace; void source
|
|
})
|
|
ctx.remote.$on('credentials/updated', () => {})
|
|
ctx.remote.$on('commands/change', () => {})
|
|
ctx.remote.$on('llm/adapters-updated', () => {})
|
|
ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
|
|
void sessionId; void agentPreset
|
|
})
|
|
// @ts-expect-error -- client-local event outside the allowlist
|
|
ctx.remote.$on('slots/changed', () => {})
|
|
// @ts-expect-error -- declared host event the allowlist does not select
|
|
ctx.remote.$on('skills/change', () => {})
|
|
}
|
|
void forwardedEventContracts
|
|
|
|
interface Bench {
|
|
ctx: Context
|
|
sinks: ConnectionSinks | undefined
|
|
/** Every `$dispatch` the runtime made, as `[event, ...args]`. */
|
|
dispatched: unknown[][]
|
|
}
|
|
|
|
async function mount(): Promise<Bench> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(TypertRegistry)
|
|
const api = new FakeApiClient()
|
|
const bench: Bench = { ctx, sinks: undefined, dispatched: [] }
|
|
// Stands in for api-gateway's Remote service: this spec owns the carrier's
|
|
// handoff, not the fan-out behind it.
|
|
ctx.reflect.provide('remote', {
|
|
$dispatch: (event: string, args: readonly unknown[]) => { bench.dispatched.push([event, ...args]) },
|
|
})
|
|
const handle: ConnectionHandle = {
|
|
api,
|
|
isLoopback: true,
|
|
hostDescription: {
|
|
getSnapshot: () => undefined,
|
|
subscribe: () => () => {},
|
|
},
|
|
rpc: {
|
|
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
|
},
|
|
start: (sinks) => {
|
|
bench.sinks = sinks
|
|
return { stop: () => {} }
|
|
},
|
|
}
|
|
ctx.reflect.provide('connection', handle)
|
|
await ctx.plugin(RuntimeClient).await()
|
|
return bench
|
|
}
|
|
|
|
describe('wire event bridge', () => {
|
|
it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => {
|
|
const bench = await mount()
|
|
const seen = bench.dispatched
|
|
bench.sinks?.onHostEnvelope?.({
|
|
rpcId: 'r1' as never,
|
|
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
|
|
})
|
|
expect(seen).toEqual([['commands/change']])
|
|
|
|
bench.sinks?.onHostEnvelope?.({
|
|
rpcId: 'r2' as never,
|
|
payload: { type: 'host/session-status', sessionId: 's1' as never, running: true },
|
|
})
|
|
expect(seen).toEqual([['commands/change']])
|
|
})
|
|
|
|
it('carries each forwarded event name with its own argument list, unfiltered', async () => {
|
|
const bench = await mount()
|
|
const seen = bench.dispatched
|
|
|
|
bench.sinks?.onHostEnvelope?.({
|
|
rpcId: 'r3' as never,
|
|
payload: { type: 'host/remote-event', event: 'settings/document-updated', args: ['llm-pi-ai', 7] },
|
|
})
|
|
bench.sinks?.onHostEnvelope?.({
|
|
rpcId: 'r4' as never,
|
|
payload: { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
|
|
})
|
|
// The carrier does not second-guess the name: selecting what a consumer can
|
|
// receive is the allowlist's job, and dropping an unsubscribed name is the
|
|
// Remote service's. This plugin republishes whatever the frame carried.
|
|
bench.sinks?.onHostEnvelope?.({
|
|
rpcId: 'r5' as never,
|
|
payload: { type: 'host/remote-event', event: 'nobody/listening', args: ['ignored'] },
|
|
})
|
|
|
|
expect(seen).toEqual([
|
|
['settings/document-updated', 'llm-pi-ai', 7],
|
|
['credentials/updated', 'OPENAI_API_KEY'],
|
|
['nobody/listening', 'ignored'],
|
|
])
|
|
})
|
|
|
|
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
|
|
const bench = await mount()
|
|
let resets = 0
|
|
bench.ctx.on('connection/reset', () => { resets++ })
|
|
const description = { version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }
|
|
bench.sinks?.onConnected?.(description)
|
|
bench.sinks?.onConnected?.(description) // second generation after a reconnect
|
|
expect(resets).toBe(2)
|
|
})
|
|
})
|