feat(remote): deliver allowlisted Host events through ctx.remote.$on
api/remotes owns the allowlist and its type projection; type-meta owns the shape predicate, the selection seat, and the internal remote/host-event carrier signal; api/gateway's Client half turns that signal into $on callbacks through a private dispatch. apiproxy forwards each allowlisted emission verbatim in one host/remote-event frame, registered ahead of the derived invalidation frames so frame order is unchanged, and drops the three per-event variants it replaces. Owner packages move their Events declarations into client-safe ./types exports, so a consumer's listener signature is the Host's own declaration.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } 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 {
|
||||
InvocationDescriptor,
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
TypeRTCodec,
|
||||
TypeRTDisposer,
|
||||
TypeRTRemoteContribution,
|
||||
TypeRTRemoteEvent,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
interface MountToken {
|
||||
@@ -71,14 +72,20 @@ export function apply(ctx: Context): void {
|
||||
new ClientRemoteService(ctx)
|
||||
}
|
||||
|
||||
/** One subscribed listener after `$on` erased its per-event argument list. */
|
||||
type RemoteEventListener = (...args: never[]) => void
|
||||
|
||||
class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
private readonly ownerCtx: Context
|
||||
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
|
||||
private readonly subscriptions = new Map<string, Set<RemoteEventListener>>()
|
||||
private mutations = Promise.resolve()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'remote')
|
||||
this.ownerCtx = ctx
|
||||
ctx.on('remote/host-event', (event, args) => { this.dispatch(event, args) })
|
||||
ctx.effect(() => () => { this.subscriptions.clear() }, 'api-gateway.client.subscriptions')
|
||||
}
|
||||
|
||||
async $mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientRemote['$mount']> {
|
||||
@@ -91,6 +98,49 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
return async () => { await owned() }
|
||||
}
|
||||
|
||||
$on<Event extends TypeRTRemoteEvent>(
|
||||
event: Event,
|
||||
listener: Events[Event],
|
||||
): ReturnType<TypeRTClientRemote['$on']> {
|
||||
// The table is keyed by the runtime event name, so the argument list this
|
||||
// signature pins per event cannot survive in it; `$deliver` restores it
|
||||
// from the frame the Host emitted for that same name.
|
||||
const erased: RemoteEventListener = listener
|
||||
const owned = this.ctx.effect(() => {
|
||||
const listeners = this.listeners(event)
|
||||
listeners.add(erased)
|
||||
return () => { listeners.delete(erased) }
|
||||
}, `api-gateway.client.$on(${JSON.stringify(event)})`)
|
||||
return () => { void owned() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one forwarded event in registration order, isolating a throwing
|
||||
* listener; an event name nobody subscribes to is dropped, since the wire
|
||||
* carries whatever the Host forwarding allowlist selected.
|
||||
*/
|
||||
private dispatch(event: string, args: readonly unknown[]): void {
|
||||
const listeners = this.subscriptions.get(event)
|
||||
if (listeners === undefined) return
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(...args as never[])
|
||||
} catch (error) {
|
||||
console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscription set for one event name; empty sets are retained, bounded by the Host's selection. */
|
||||
private listeners(event: string): Set<RemoteEventListener> {
|
||||
let listeners = this.subscriptions.get(event)
|
||||
if (listeners === undefined) {
|
||||
listeners = new Set()
|
||||
this.subscriptions.set(event, listeners)
|
||||
}
|
||||
return listeners
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
const result = this.mutations.then(operation, operation)
|
||||
this.mutations = result.then(() => undefined, () => undefined)
|
||||
|
||||
@@ -16,7 +16,8 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: Host calls re-read authoritative Cordis and TypeRT
|
||||
* state, while Client methods and descriptors mutate in one owned effect.
|
||||
* state, while Client methods, descriptors, and `$on` subscriptions mutate in
|
||||
* one owned effect.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
@@ -10,9 +11,32 @@ import type {
|
||||
TypeRTRemoteNamespace,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import type { ClientRemote } from '../src/client/index.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Test-only forwarded Host event.
|
||||
* @param namespace - marker payload recorded by listeners.
|
||||
*/
|
||||
'fixture/changed'(namespace: string): void
|
||||
/**
|
||||
* Test-only forwarded Host event nobody subscribes to.
|
||||
* @param count - marker payload never observed.
|
||||
*/
|
||||
'fixture/idle'(count: number): void
|
||||
/**
|
||||
* Test-only event the Host assembly does not forward.
|
||||
* @param flag - marker payload never delivered.
|
||||
*/
|
||||
'fixture/unselected'(flag: boolean): void
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTRemoteEventSelection extends Record<'fixture/changed' | 'fixture/idle', true> {}
|
||||
|
||||
interface TypeRTContextMap {
|
||||
fixture: TypeRTContext<string>
|
||||
}
|
||||
@@ -43,6 +67,19 @@ type FixtureContext = Omit<Context, 'remote'> & {
|
||||
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'>
|
||||
}
|
||||
|
||||
// Compile-time contract of `$on`: the key face is the forwarding selection and
|
||||
// the listener signature is the owning package's own Cordis declaration.
|
||||
function remoteEventContracts(remote: ClientRemote): void {
|
||||
remote.$on('fixture/changed', (namespace) => { void namespace })
|
||||
// @ts-expect-error -- declared in Events but outside the forwarding selection.
|
||||
remote.$on('fixture/unselected', () => {})
|
||||
// @ts-expect-error -- not declared in Events at all.
|
||||
remote.$on('fixture/absent', () => {})
|
||||
// @ts-expect-error -- the listener signature comes from the event declaration.
|
||||
remote.$on('fixture/changed', (count: number) => { void count })
|
||||
}
|
||||
void remoteEventContracts
|
||||
|
||||
const idSchema = z.string().min(1)
|
||||
const requestSchema = z.object({ objective: z.string().min(1) })
|
||||
const createResultSchema = z.object({ ref: z.string().min(1) })
|
||||
@@ -96,11 +133,19 @@ function contextDescriptor(): InvocationDescriptor {
|
||||
}
|
||||
|
||||
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
|
||||
const { ctx } = await benchFiber(call)
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function benchFiber(
|
||||
call: ConnectionHandle['rpc']['call'],
|
||||
): Promise<{ readonly ctx: Context; readonly client: Fiber }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
|
||||
await ctx.plugin({ inject, apply })
|
||||
return ctx
|
||||
const client = ctx.plugin({ inject, apply })
|
||||
await client
|
||||
return { ctx, client }
|
||||
}
|
||||
|
||||
describe('Client TypeRT API', () => {
|
||||
@@ -570,4 +615,64 @@ describe('Client TypeRT API', () => {
|
||||
expect(failure.message).toContain('internal: host failed')
|
||||
expect(failure.cause).toBe(rpcError)
|
||||
})
|
||||
|
||||
it('owns each $on subscription in the calling fiber', async () => {
|
||||
const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const seen: string[] = []
|
||||
const subscriber = ctx.plugin(Object.assign(
|
||||
(scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) },
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await subscriber
|
||||
|
||||
ctx.emit('remote/host-event', 'fixture/changed', ['settings'])
|
||||
expect(seen).toEqual(['settings'])
|
||||
|
||||
await subscriber.dispose()
|
||||
ctx.emit('remote/host-event', 'fixture/changed', ['after fiber disposal'])
|
||||
expect(seen).toEqual(['settings'])
|
||||
|
||||
await client.dispose()
|
||||
expect(ctx.get('remote')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('isolates a throwing listener from the rest of the same event', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const seen: string[] = []
|
||||
const disposeFirst = ctx.remote.$on('fixture/changed', () => {
|
||||
throw new Error('fixture listener failure')
|
||||
})
|
||||
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
||||
try {
|
||||
ctx.emit('remote/host-event', 'fixture/changed', ['credentials'])
|
||||
|
||||
expect(seen).toEqual(['credentials'])
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'client api: Remote event "fixture/changed" listener threw:',
|
||||
expect.any(Error),
|
||||
)
|
||||
disposeFirst()
|
||||
ctx.emit('remote/host-event', 'fixture/changed', ['commands'])
|
||||
expect(seen).toEqual(['credentials', 'commands'])
|
||||
expect(consoleError).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes subscription as the only forwarded-event verb', () => {
|
||||
expectTypeOf<ClientRemote>().toHaveProperty('$on')
|
||||
expectTypeOf<ClientRemote>().not.toHaveProperty('$dispatch')
|
||||
})
|
||||
|
||||
it('drops a forwarded event nobody subscribes to', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const seen: string[] = []
|
||||
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
||||
|
||||
ctx.emit('remote/host-event', 'fixture/idle', [1])
|
||||
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -47,26 +51,35 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,22 @@ 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-goal/remote'
|
||||
// The forwarded-event allowlist's selection seat: without it in the consumer's
|
||||
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
|
||||
export type { ApiRemoteForwardedEvent } from '../types.ts'
|
||||
// The owner packages' client-safe `./types` exports supply the `Events`
|
||||
// signatures `$on` hands to a listener, so a consumer reads the very
|
||||
// declaration the Host emits rather than a flattened restatement of it.
|
||||
export type {} from '@deepseek-ai/dsh-commands/types'
|
||||
export type {} from '@deepseek-ai/dsh-credentials/types'
|
||||
export type {} from '@deepseek-ai/dsh-settings/types'
|
||||
/**
|
||||
* The Gateway Client face's own declaration merges, type-only: the internal
|
||||
* `remote/host-event` delivery event a carrier owner emits and the Remote
|
||||
* service subscribes to. Erased at emit, so this facade still carries no
|
||||
* runtime edge to the Gateway implementation.
|
||||
*/
|
||||
export type {} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
|
||||
|
||||
// import type { TypeRTForwardableEvent } from '@deepseek-ai/dsh-type-meta'
|
||||
// import { API_REMOTE_FORWARDED_EVENTS } from './types.ts'
|
||||
|
||||
// // The owner packages' client-safe `./types` exports carry the cordis `Events`
|
||||
// // declarations for every allowlisted event. Pulling them into this face is what
|
||||
// // makes the shape assertion below judge real signatures rather than an empty
|
||||
// // event vocabulary.
|
||||
// import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
// import type {} from '@deepseek-ai/dsh-credentials/types'
|
||||
// import type {} from '@deepseek-ai/dsh-settings/types'
|
||||
|
||||
export {
|
||||
ApiRemoteSessionNotFound,
|
||||
ApiRemoteSubagentSessionOwnership,
|
||||
@@ -13,6 +24,18 @@ export type {
|
||||
ApiRemoteAgentResult,
|
||||
ApiRemoteLookupError,
|
||||
} from './agent-lookup.ts'
|
||||
export { API_REMOTE_FORWARDED_EVENTS } from './types.ts'
|
||||
export type { ApiRemoteForwardedEvent } from './types.ts'
|
||||
|
||||
// Shape gate over the allowlist, kept in the Host face because the Host's event
|
||||
// vocabulary is the authoritative one. It pins three things at compile time:
|
||||
// every entry NAMES a declared event (the predicate is keyed on `keyof
|
||||
// Events`), no entry BINDS a Scope (a scoped event's `ThisParameterType` is not
|
||||
// `unknown`, which is how "must not depend on AgentScope" is stated statically),
|
||||
// and every entry is ONE-WAY (a waterfall or bail shape returns something other
|
||||
// than void and is excluded). Widening the array to an event that fails any of
|
||||
// these fails here, not on the wire.
|
||||
// API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[]
|
||||
|
||||
/** Host plugin body; the selected contributions mount only in Client environments. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { API_REMOTE_FORWARDED_EVENTS } from './types.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes'
|
||||
|
||||
@@ -11,8 +11,38 @@ export const name = 'api-remotes-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
/** The allowlist as a lookup over the live dispatch stream's plain event names. */
|
||||
const FORWARDED_EVENTS: ReadonlySet<string> = new Set(API_REMOTE_FORWARDED_EVENTS)
|
||||
|
||||
/**
|
||||
* Judge one observed dispatch of an allowlisted event against what verbatim
|
||||
* forwarding can carry. The Host face's `TypeRTForwardableEvent` assertion
|
||||
* judges each name's DECLARED signature; only the dispatch stream shows how a
|
||||
* producer actually emitted it, and neither deviation below is visible to the
|
||||
* compiler. A Scope carrier would be silently dropped on the way to a consumer
|
||||
* because `ctx.remote.$on` has no scoped form, and a waterfall or bail dispatch
|
||||
* expects a return value that a one-way carrier can never deliver back.
|
||||
* @param mode - dispatch mode reported by the event bus.
|
||||
* @param event - dispatched event name.
|
||||
* @param carrier - the dispatch `this`; `null` when the event is unscoped.
|
||||
* @param fail - reporter bound to this package.
|
||||
*/
|
||||
function validateDispatch(mode: string, event: string, carrier: unknown, fail: InvariantFailure): void {
|
||||
if (!FORWARDED_EVENTS.has(event)) return
|
||||
if (carrier !== null) {
|
||||
fail(`forwarded host event "${event}" was dispatched with a Scope carrier, which consumers can never receive`)
|
||||
}
|
||||
if (mode !== 'emit') {
|
||||
fail(`forwarded host event "${event}" was dispatched as "${mode}", but forwarding to consumers is one-way`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install the forwarded-event dispatch-shape check over the live event bus. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/dispatch', (mode, event, _args, thisArg) => {
|
||||
validateDispatch(mode, event, thisArg, fail)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
@@ -21,4 +51,3 @@ const install: InvariantInstaller = () => {}
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
29
packages/api/remotes/src/types.ts
Normal file
29
packages/api/remotes/src/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The one home of this application's forwarded-Host-event allowlist, listed in
|
||||
* `tsconfig.host.json` AND `tsconfig.client.json` so the Host forwarding loop
|
||||
* and the consumer `ctx.remote.$on` key face read the same declaration instead
|
||||
* of two copies that could drift.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-api-remotes/types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host events this application forwards to consumers verbatim: no projection,
|
||||
* no redaction, no renaming. The wire name is the Host cordis event name and
|
||||
* the payload is its argument list, so this array is simultaneously the whole
|
||||
* control point over what a consumer can receive and the legal key set of
|
||||
* `ctx.remote.$on`. Forwarding one more event is an entry here and nothing
|
||||
* else.
|
||||
*/
|
||||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
'commands/change',
|
||||
'credentials/updated',
|
||||
'settings/document-updated',
|
||||
] as const
|
||||
|
||||
/** Type projection of the allowlist; the consumer and the Host read this one. */
|
||||
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
|
||||
}
|
||||
65
packages/api/remotes/tests/invariant.spec.ts
Normal file
65
packages/api/remotes/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { API_REMOTE_FORWARDED_EVENTS } from '@deepseek-ai/dsh-api-remotes'
|
||||
import type { ApiRemoteForwardedEvent } from '@deepseek-ai/dsh-api-remotes'
|
||||
import * as ApiRemotesInvariant from '@deepseek-ai/dsh-api-remotes/invariant'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(ApiRemotesInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* One legal emission per allowlisted event. `Events` types each emit by name,
|
||||
* so the three arities (0, 2, 1) cannot share a single loop body; keying the
|
||||
* table by {@link ApiRemoteForwardedEvent} makes the compiler reject it as soon
|
||||
* as the allowlist grows, which keeps "every listed event is exercised" true
|
||||
* without a single argument-list assertion.
|
||||
*/
|
||||
const legalEmission: Record<ApiRemoteForwardedEvent, (ctx: Context) => void> = {
|
||||
'commands/change': ctx => { ctx.emit('commands/change') },
|
||||
'credentials/updated': ctx => { ctx.emit('credentials/updated', credentialRef('DEMO_TOKEN')) },
|
||||
'settings/document-updated': ctx => {
|
||||
ctx.emit('settings/document-updated', settingsNamespace('demo'), 1)
|
||||
},
|
||||
}
|
||||
|
||||
describe('forwarded host event invariants', () => {
|
||||
it('accepts an unscoped one-way dispatch of every allowlisted event', async () => {
|
||||
const ctx = await setup()
|
||||
for (const event of API_REMOTE_FORWARDED_EVENTS) {
|
||||
expect(() => { legalEmission[event](ctx) }).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores an owner package event the allowlist does not select', async () => {
|
||||
const ctx = await setup()
|
||||
// `settings/updated` is the resolved-value event, deliberately left out of
|
||||
// the allowlist while its sibling `settings/document-updated` is in it, so
|
||||
// this pins that the check discriminates by name rather than by owner.
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('demo'), { a: 1 }, { a: 2 }, 'update')
|
||||
}).not.toThrow()
|
||||
// The carrier that fails an allowlisted event must pass unremarked here.
|
||||
expect(() => {
|
||||
ctx.emit({}, 'settings/updated', settingsNamespace('demo'), { a: 1 }, { a: 2 }, 'update')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an allowlisted event dispatched with a Scope carrier', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit({}, 'commands/change') })
|
||||
.toThrow(/"commands\/change" was dispatched with a Scope carrier/)
|
||||
})
|
||||
|
||||
it('rejects an allowlisted event dispatched as anything but one-way', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.bail('commands/change') })
|
||||
.toThrow(/"commands\/change" was dispatched as "bail"/)
|
||||
})
|
||||
})
|
||||
@@ -6,15 +6,28 @@
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/client/index.ts"
|
||||
"src/client/index.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../gateway"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"files": [
|
||||
"src/agent-lookup.ts",
|
||||
"src/index.ts",
|
||||
"src/invariant.ts"
|
||||
"src/invariant.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
@@ -20,9 +21,18 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
|
||||
@@ -152,14 +152,14 @@ describe('connection client apply', () => {
|
||||
sockets[1]!.receive(JSON.stringify({
|
||||
type: 'server-request',
|
||||
rpcId: 'host-browser',
|
||||
method: 'host/commands-changed',
|
||||
payload: { type: 'host/commands-changed' },
|
||||
method: 'host/remote-event',
|
||||
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
|
||||
}))
|
||||
expect(await muxFrame).toMatchObject({
|
||||
value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
|
||||
})
|
||||
expect(await hostFrame).toMatchObject({
|
||||
value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } },
|
||||
value: { rpcId: 'host-browser', payload: { type: 'host/remote-event', event: 'commands/change' } },
|
||||
})
|
||||
expect(errors).toHaveBeenCalledTimes(2)
|
||||
await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('WebSocket downlinks', () => {
|
||||
},
|
||||
async function * (signal) {
|
||||
try {
|
||||
yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } }
|
||||
yield { rpcId: RpcId('host-1'), payload: { type: 'host/remote-event', event: 'commands/change', args: [] } }
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
hostAborted = true
|
||||
@@ -116,8 +116,8 @@ describe('WebSocket downlinks', () => {
|
||||
expect(await hostFrame).toEqual({
|
||||
type: 'server-request',
|
||||
rpcId: 'host-1',
|
||||
method: 'host/commands-changed',
|
||||
payload: { type: 'host/commands-changed' },
|
||||
method: 'host/remote-event',
|
||||
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
|
||||
})
|
||||
|
||||
const muxClosed = once(mux, 'close')
|
||||
|
||||
@@ -150,29 +150,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
* @param key - the mutated SlotMap key.
|
||||
*/
|
||||
'slots/changed'(key: string): void
|
||||
/**
|
||||
* The host command registry changed (host/commands-changed passthrough).
|
||||
* Pure invalidation signal: subscribers refetch `command.list` in the
|
||||
* background rather than diffing.
|
||||
* @mode emit
|
||||
*/
|
||||
'commands/changed'(): void
|
||||
/**
|
||||
* One settings namespace's resolved value changed on the host
|
||||
* (host/settings-changed passthrough). Subscribers refetch
|
||||
* `settings.describe`; the frame carries no values.
|
||||
* @mode emit
|
||||
* @param ns - the namespace whose resolved value changed.
|
||||
*/
|
||||
'settings/changed'(ns: string): void
|
||||
/**
|
||||
* One credential reference's state changed on the host
|
||||
* (host/credentials-changed passthrough). The ref is an
|
||||
* environment-variable NAME — never a value.
|
||||
* @mode emit
|
||||
* @param ref - the reference whose configured state changed.
|
||||
*/
|
||||
'credentials/changed'(ref: string): void
|
||||
/**
|
||||
* The host provider topology changed (host/models-changed passthrough).
|
||||
* Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
|
||||
@@ -241,16 +218,15 @@ export function apply(ctx: Context): void {
|
||||
onHostEnvelope: (envelope) => {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
// Typed-event bridge: the session layer ignores registry frames (no
|
||||
// session routing); consumers (command directory caches, the settings
|
||||
// and model services) subscribe on ctx.
|
||||
// Forwarded-event bridge: the session layer ignores registry frames (no
|
||||
// session routing). This plugin only carries the frame onto the internal
|
||||
// `remote/host-event` plumbing event; the Remote service subscribes there
|
||||
// and fans out to `ctx.remote.$on`, so no consumer reads a frame.
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
if (frame.type === 'host/remote-event') ctx.emit('remote/host-event', frame.event, frame.args)
|
||||
else if (frame.type === 'host/session-preset-changed') {
|
||||
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
|
||||
}
|
||||
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
|
||||
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
|
||||
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
|
||||
},
|
||||
onConnected: () => {
|
||||
|
||||
@@ -1,16 +1,45 @@
|
||||
/**
|
||||
* Wire-to-typed-event bridge: host/commands-changed
|
||||
* → ctx 'commands/changed'; host/session-preset-changed →
|
||||
* ctx 'session/preset-changed'; each established connection generation →
|
||||
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
|
||||
* Wire-to-typed-event bridge: a `host/remote-event` frame is republished
|
||||
* verbatim on the internal `remote/host-event` plumbing event (the Remote
|
||||
* service's fan-out to `ctx.remote.$on` is api-gateway's own coverage);
|
||||
* host/session-preset-changed → ctx 'session/preset-changed';
|
||||
* `host/models-changed` still broadcasts the typed `models/changed`; each
|
||||
* established connection generation → ctx 'connection/reset' (the forced
|
||||
* cache-invalidation broadcast).
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/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', () => {})
|
||||
// @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
|
||||
@@ -33,41 +62,64 @@ async function mount(): Promise<Bench> {
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
ctx.reflect.provide('remote', {})
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
|
||||
describe('wire event bridge', () => {
|
||||
it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => {
|
||||
it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => {
|
||||
const bench = await mount()
|
||||
let changed = 0
|
||||
bench.ctx.on('commands/changed', () => { changed++ })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } })
|
||||
expect(changed).toBe(1)
|
||||
const seen: unknown[][] = []
|
||||
bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) })
|
||||
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(changed).toBe(1)
|
||||
expect(seen).toEqual([['commands/change']])
|
||||
})
|
||||
|
||||
it('broadcasts the settings/credentials/models invalidations with their frame payloads', async () => {
|
||||
it('carries each forwarded event name with its own argument list, unfiltered', async () => {
|
||||
const bench = await mount()
|
||||
const seen: unknown[][] = []
|
||||
bench.ctx.on('settings/changed', ns => seen.push(['settings', ns]))
|
||||
bench.ctx.on('credentials/changed', ref => seen.push(['credentials', ref]))
|
||||
bench.ctx.on('models/changed', () => seen.push(['models']))
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, payload: { type: 'host/settings-changed', ns: 'llm-pi-ai' } })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, payload: { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' } })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r5' as never, payload: { type: 'host/models-changed' } })
|
||||
bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) })
|
||||
|
||||
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', 'llm-pi-ai'],
|
||||
['credentials', 'OPENAI_API_KEY'],
|
||||
['models'],
|
||||
['settings/document-updated', 'llm-pi-ai', 7],
|
||||
['credentials/updated', 'OPENAI_API_KEY'],
|
||||
['nobody/listening', 'ignored'],
|
||||
])
|
||||
})
|
||||
|
||||
it('still broadcasts the typed models/changed invalidation (its host frame is unchanged)', async () => {
|
||||
const bench = await mount()
|
||||
let models = 0
|
||||
bench.ctx.on('models/changed', () => { models++ })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r6' as never, payload: { type: 'host/models-changed' } })
|
||||
expect(models).toBe(1)
|
||||
})
|
||||
|
||||
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
|
||||
const bench = await mount()
|
||||
const seen: Array<[string, string]> = []
|
||||
|
||||
@@ -39,6 +39,7 @@ export { FixtureSession, TestSessions } from './sessions.ts'
|
||||
export { stubSettingsScope } from './settings-scope.ts'
|
||||
export type { StubSettingsScope } from './settings-scope.ts'
|
||||
export { TestWorkspaces } from './workspaces.ts'
|
||||
export { TestRemote } from './remote.ts'
|
||||
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
|
||||
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
|
||||
export { makeTranslate } from './translate.ts'
|
||||
|
||||
55
packages/client/test-runtime/src/remote.ts
Normal file
55
packages/client/test-runtime/src/remote.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** Test-owned Remote face: `$on` subscriptions driven by the internal forwarded-event plumbing. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
/**
|
||||
* Remote service test double for the forwarded-event path. Feature specs need
|
||||
* `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
|
||||
* host events to reach those subscribers, but not the generated namespaces or
|
||||
* the wire — so this double implements subscription and dispatch only.
|
||||
*
|
||||
* Dispatch is driven the same way production drives it: by the internal
|
||||
* `remote/host-event` event the connection sink emits. A spec therefore
|
||||
* exercises its refresh chains with `ctx.emit('remote/host-event', name, args)`,
|
||||
* the exact signal `client/runtime` republishes from a `host/remote-event`
|
||||
* frame, rather than reaching into this double.
|
||||
*
|
||||
* `$mount` rejects: a spec that reaches a generated namespace through this
|
||||
* double has outgrown it and needs the real Client Remote service.
|
||||
*/
|
||||
export class TestRemote {
|
||||
private readonly subscriptions = new Map<string, Set<(...args: never[]) => void>>()
|
||||
|
||||
/**
|
||||
* Register the double as `ctx.remote` and bind its dispatch to the plumbing event.
|
||||
* @param ctx - the spec's root Context.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
ctx.provide('remote', this)
|
||||
ctx.on('remote/host-event', (event, args) => {
|
||||
const listeners = this.subscriptions.get(event)
|
||||
if (listeners === undefined) return
|
||||
for (const listener of [...listeners]) listener(...args as never[])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to one forwarded host event.
|
||||
* @param event - forwarded host event name.
|
||||
* @param listener - receives the Host argument list verbatim.
|
||||
* @returns disposer removing this subscription.
|
||||
*/
|
||||
$on(event: string, listener: (...args: never[]) => void): () => void {
|
||||
const listeners = this.subscriptions.get(event) ?? new Set()
|
||||
this.subscriptions.set(event, listeners)
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated-namespace mount, unsupported by this double.
|
||||
* @returns never; always rejects.
|
||||
*/
|
||||
$mount(): Promise<() => Promise<void>> {
|
||||
return Promise.reject(new Error('TestRemote: $mount needs the real Client Remote service'))
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,8 @@
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -47,6 +48,8 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
@@ -56,10 +59,10 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (the settings invalidation rides the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -43,7 +46,7 @@ export type { AgentPresetOption, AgentPresetSettingsState } from './settings-sto
|
||||
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote']
|
||||
|
||||
/**
|
||||
* Mount the General-settings row.
|
||||
@@ -71,15 +74,17 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
// The roster is a live directory and the default is a settings field, so
|
||||
// both an external settings edit and a reconnect can move this row.
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
const refresh = (): void => {
|
||||
void controller.load()
|
||||
// The section reads the same roster and marks the same default, so a
|
||||
// change made from either surface converges both.
|
||||
if (section.store.getSnapshot().status !== 'idle') void section.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
if (ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
refresh()
|
||||
}),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
@@ -132,8 +137,8 @@ export function apply(ctx: ClientContext): void {
|
||||
// the next session keeps offering the previous default until a reload,
|
||||
// which is exactly the session the setting claims to govern. A staged
|
||||
// pick survives: `load()` prefers it over the refreshed fallback.
|
||||
const settingsMoved = scope.on('settings/changed', (ns?: string) => {
|
||||
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
const settingsMoved = scope.remote.$on('settings/document-updated', (ns) => {
|
||||
if (ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
void seat.load()
|
||||
})
|
||||
// Authoring writes a FILE, not a setting, so nothing on the wire
|
||||
|
||||
@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
|
||||
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx'
|
||||
@@ -78,6 +78,9 @@ async function bench() {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
// The plugins inject `remote`; forwarded events reach them through the
|
||||
// same `remote/host-event` signal the connection sink republishes.
|
||||
new TestRemote(ctx)
|
||||
const calls: string[] = []
|
||||
ctx.provide('connection', {
|
||||
api: {
|
||||
@@ -169,7 +172,7 @@ function sessionsDouble(state: {
|
||||
|
||||
describe('ui-agent-preset apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
||||
})
|
||||
|
||||
it('registers the General row and the settings section', async () => {
|
||||
@@ -250,11 +253,11 @@ describe('ui-agent-preset apply', () => {
|
||||
await section.load()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1])
|
||||
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
|
||||
const afterRelevant = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'llm-deepseek')
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1])
|
||||
await Promise.resolve()
|
||||
|
||||
// Both surfaces re-read on their own namespace; an unrelated one moves
|
||||
@@ -282,7 +285,7 @@ describe('ui-agent-preset apply', () => {
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1])
|
||||
await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
|
||||
|
||||
// Only the General row reloads: a section nobody opened has nothing to
|
||||
@@ -333,11 +336,11 @@ describe('ui-agent-preset apply', () => {
|
||||
// An unrelated namespace moves nothing: the chip re-reads on its own
|
||||
// setting, not on every settings write in the process.
|
||||
moveDefault()
|
||||
ctx.emit('settings/changed', 'llm-deepseek')
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1])
|
||||
await Promise.resolve()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1])
|
||||
await vi.waitFor(() => {
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slash",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -49,6 +50,8 @@
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
@@ -57,10 +60,10 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
|
||||
@@ -45,7 +45,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
const NS = 'command'
|
||||
|
||||
/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */
|
||||
export const inject = ['slash', 'sessions', 'connection', 'locale']
|
||||
export const inject = ['slash', 'sessions', 'connection', 'locale', 'remote']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount the service, then register the popupSelect shell
|
||||
|
||||
@@ -11,6 +11,9 @@ import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (`commands/change` rides the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {
|
||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
|
||||
SubmitOutcome,
|
||||
@@ -93,7 +96,7 @@ function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string
|
||||
|
||||
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
|
||||
export class CommandService extends Service implements CommandServiceContract {
|
||||
static inject = ['slash', 'sessions', 'connection']
|
||||
static inject = ['slash', 'sessions', 'connection', 'remote']
|
||||
|
||||
private readonly directory: CommandDirectory
|
||||
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
|
||||
@@ -123,7 +126,7 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
matchEnter: (session, line, signal) => this.matchEnter(session, line, signal),
|
||||
warm: (session) => { this.directory.warm(session.sessionId) },
|
||||
}), 'command: slash source')
|
||||
ctx.on('commands/changed', () => { this.directory.invalidateAll() })
|
||||
ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() })
|
||||
// A preset switch changes which commands one session's agent resolves and
|
||||
// registers nothing globally, so the registry-wide signal above never
|
||||
// fires for it: repull that key alone, soft, so the old snapshot serves
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandServiceContract } from '../src/client/contract.ts'
|
||||
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, CommandService, inject } from '../src/client/index.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
@@ -38,6 +39,8 @@ async function bench() {
|
||||
name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
|
||||
} as never, (() => null) as never)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
// CommandService injects `remote` for the forwarded directory invalidation.
|
||||
new TestRemote(ctx)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const mint = (key: string) => {
|
||||
@@ -50,7 +53,7 @@ async function bench() {
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale'])
|
||||
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale', 'remote'])
|
||||
})
|
||||
|
||||
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
||||
@@ -78,6 +79,9 @@ async function bench(opts: BenchOptions = {}) {
|
||||
: undefined,
|
||||
})
|
||||
ctx.provide('connection', { api })
|
||||
// CommandService injects `remote`; the directory invalidation arrives on the
|
||||
// same `remote/host-event` signal the connection sink republishes.
|
||||
new TestRemote(ctx)
|
||||
/** Notices the fake conversation face collected (runDetached routing). */
|
||||
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
|
||||
ctx.provide('conversation', {
|
||||
@@ -611,7 +615,7 @@ describe('directory invalidation events', () => {
|
||||
},
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
ctx.emit('commands/changed')
|
||||
ctx.emit('remote/host-event', 'commands/change', [])
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
|
||||
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -45,6 +46,8 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
@@ -52,10 +55,10 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
|
||||
@@ -12,6 +12,9 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (settings/credentials invalidations ride the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { ModelsSection } from './ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from './ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
|
||||
@@ -48,7 +51,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void {
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registration depends on each slot through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote']
|
||||
|
||||
/**
|
||||
* Register the Models section once the `settings.section` declaration is on
|
||||
@@ -82,8 +85,8 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
const refresh = (): void => { refreshIfLoaded(controller) }
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('credentials/changed', refresh),
|
||||
ctx.remote.$on('settings/document-updated', refresh),
|
||||
ctx.remote.$on('credentials/updated', refresh),
|
||||
ctx.on('models/changed', refresh),
|
||||
ctx.on('connection/reset', refresh),
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
|
||||
@@ -18,6 +18,9 @@ async function bench() {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
// The plugins inject `remote`; forwarded events reach them through the
|
||||
// same `remote/host-event` signal the connection sink republishes.
|
||||
new TestRemote(ctx)
|
||||
// The apply path only captures the wire face; no call leaves this fake
|
||||
// until a section actually loads.
|
||||
ctx.provide('connection', { api: {} } as never)
|
||||
@@ -39,7 +42,7 @@ function declare(slots: SlotsService): () => void {
|
||||
|
||||
describe('ui-models apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
||||
})
|
||||
|
||||
it('registers the models nav entry for declarations before or after apply', async () => {
|
||||
@@ -135,8 +138,8 @@ describe('pushed invalidations', () => {
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
// The fake wire face has no methods: a fetch attempt would throw.
|
||||
b.ctx.emit('settings/changed', 'llm-pi-ai')
|
||||
b.ctx.emit('credentials/changed', 'OPENAI_API_KEY')
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', ['llm-pi-ai', 1])
|
||||
b.ctx.emit('remote/host-event', 'credentials/updated', ['OPENAI_API_KEY'])
|
||||
b.ctx.emit('models/changed')
|
||||
b.ctx.emit('connection/reset')
|
||||
})
|
||||
@@ -167,7 +170,7 @@ describe('pushed invalidations', () => {
|
||||
)()
|
||||
injected.controller.store.update((state) => { state.status = 'ready' })
|
||||
const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
|
||||
b.ctx.emit('credentials/changed', 'DEEPSEEK_API_KEY')
|
||||
b.ctx.emit('remote/host-event', 'credentials/updated', ['DEEPSEEK_API_KEY'])
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
"@deepseek-ai/dsh-client-ui-command",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -46,6 +47,8 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
@@ -56,14 +59,15 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (the settings invalidation rides the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
@@ -38,7 +41,7 @@ export type {
|
||||
} from './settings-store.ts'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection']
|
||||
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection', 'remote']
|
||||
|
||||
const ACCESS_NS = 'permission.access'
|
||||
|
||||
@@ -118,12 +121,12 @@ export function apply(ctx: ClientContext): void {
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== PERMISSION_SETTINGS_NS) return
|
||||
refreshPermissionIfLoaded(controller)
|
||||
}
|
||||
const refresh = (): void => { refreshPermissionIfLoaded(controller) }
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
if (ns !== PERMISSION_SETTINGS_NS) return
|
||||
refresh()
|
||||
}),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
|
||||
import {
|
||||
@@ -37,6 +38,9 @@ async function bench() {
|
||||
const locale = new LocaleService(ctx)
|
||||
locale.setLocale('en')
|
||||
ctx.provide('locale', locale)
|
||||
// The plugin injects `remote`; forwarded events reach it through the same
|
||||
// `remote/host-event` signal the connection sink republishes.
|
||||
new TestRemote(ctx)
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
@@ -158,8 +162,8 @@ describe('ui-permission browser plugin', () => {
|
||||
it('disposal removes the decoration (HMR safety)', async () => {
|
||||
const b = await bench()
|
||||
expect(b.decoration()).toBeDefined()
|
||||
b.ctx.emit('settings/changed', 'another')
|
||||
b.ctx.emit('settings/changed', 'permission')
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', ['another', 1])
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', ['permission', 1])
|
||||
b.ctx.emit('connection/reset')
|
||||
await b.fiber.dispose()
|
||||
expect(b.decoration()).toBeUndefined()
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -50,6 +51,8 @@
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
@@ -58,10 +61,10 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
|
||||
@@ -12,6 +12,9 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (the settings invalidation rides the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
|
||||
@@ -51,7 +54,7 @@ const NS = 'settings'
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registrations depend on their slots through `slots.inject()`.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
export const inject = ['slots', 'locale', 'connection', 'remote']
|
||||
|
||||
/**
|
||||
* Register the `settings` dictionaries, the chrome content, and the General
|
||||
@@ -83,12 +86,12 @@ export function apply(ctx: ClientContext): void {
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
|
||||
refreshWelcomeIfLoaded(welcomeController)
|
||||
}
|
||||
const refresh = (): void => { refreshWelcomeIfLoaded(welcomeController) }
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
if (ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
|
||||
refresh()
|
||||
}),
|
||||
ctx.on('connection/reset', () => {
|
||||
refresh()
|
||||
refreshDocumentIfLoaded(documentController)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
@@ -33,6 +33,9 @@ async function bench(isLoopback = true) {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
// The plugins inject `remote`; forwarded events reach them through the
|
||||
// same `remote/host-event` signal the connection sink republishes.
|
||||
new TestRemote(ctx)
|
||||
const settingsDescribe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'settings-general' as never,
|
||||
result: {
|
||||
@@ -86,7 +89,7 @@ function generalEntry(slots: SlotsService) {
|
||||
|
||||
describe('ui-settings-general apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
||||
})
|
||||
|
||||
it('fills all six seats for declarations before or after apply', async () => {
|
||||
@@ -167,9 +170,9 @@ describe('ui-settings-general apply', () => {
|
||||
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
|
||||
await controller.load()
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('settings/changed', 'unrelated')
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', ['unrelated', 1])
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
|
||||
b.ctx.emit('remote/host-event', 'settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1])
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
|
||||
b.ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import type {
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from './json.ts'
|
||||
|
||||
// The lossless-JSON payload type belongs to this client-safe face too: a wire
|
||||
// contract carrying JSON data must not import the root entry, which merges
|
||||
// `ctx.sessions` (a Host-only SessionStore) into every consumer's program.
|
||||
export type { JsonValue } from './json.ts'
|
||||
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
export type SessionId = Branded<'SessionId'>
|
||||
|
||||
|
||||
@@ -22,12 +22,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { CredentialRef } from './types.ts'
|
||||
|
||||
/** Nominal reference to one credential: a POSIX-style environment-variable name. */
|
||||
export type CredentialRef = Branded<'CredentialRef'>
|
||||
export type { CredentialRef } from './types.ts'
|
||||
|
||||
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
@@ -50,22 +49,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
credentials: Credentials
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Committed change to a provider-managed credential source: a `set`, an
|
||||
* `unset`, or an external edit observed in storage. Ambient
|
||||
* process-environment changes are not observable and never emit. Listener
|
||||
* failures are contained and logged — a sync throw and an async rejection
|
||||
* alike — without changing the committed operation's outcome, except
|
||||
* `INVARIANT`-coded failures, which rethrow after every listener ran;
|
||||
* that rethrow reaches the emitter only from synchronous listeners, so
|
||||
* invariant checks on this event must not be async functions.
|
||||
* @param ref - the reference whose stored value changed.
|
||||
* @mode emit
|
||||
*/
|
||||
'credentials/updated'(ref: CredentialRef): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
31
packages/credentials/credentials/src/types.ts
Normal file
31
packages/credentials/credentials/src/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Client-safe type surface of the credential-reference seam: the reference
|
||||
* brand and the seam's Cordis event declaration. Types only — no runtime code,
|
||||
* and nothing here reaches a Host-only symbol, so a Client compilation face
|
||||
* reads exactly the signature the Host emits.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-credentials/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Nominal reference to one credential: a POSIX-style environment-variable name. */
|
||||
export type CredentialRef = Branded<'CredentialRef'>
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Committed change to a provider-managed credential source: a `set`, an
|
||||
* `unset`, or an external edit observed in storage. Ambient
|
||||
* process-environment changes are not observable and never emit. Listener
|
||||
* failures are contained and logged — a sync throw and an async rejection
|
||||
* alike — without changing the committed operation's outcome, except
|
||||
* `INVARIANT`-coded failures, which rethrow after every listener ran;
|
||||
* that rethrow reaches the emitter only from synchronous listeners, so
|
||||
* invariant checks on this event must not be async functions.
|
||||
* @param ref - the reference whose stored value changed.
|
||||
* @mode emit
|
||||
*/
|
||||
'credentials/updated'(ref: CredentialRef): void
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Context, Events } from '@deepseek-ai/cordis'
|
||||
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
|
||||
@@ -15,8 +15,8 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { isAppendSurfaceEvent, isJsonValue, lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
@@ -96,6 +96,7 @@ import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import {
|
||||
ApiRemoteSessionNotFound as SessionNotFound,
|
||||
ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership,
|
||||
API_REMOTE_FORWARDED_EVENTS,
|
||||
apiRemoteSubagentOwnershipError,
|
||||
createApiRemoteAgentResolver,
|
||||
hasApiRemoteSubagentOwner,
|
||||
@@ -414,6 +415,27 @@ function frame<F>(payload: F): RpcRequest<F> {
|
||||
return { rpcId: RpcId(randomUUID()), payload }
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow one allowlisted host event's argument list to the JSON values the
|
||||
* wrapper frame carries. A rejected argument is an allowlist mistake (the
|
||||
* forwarded path applies no projection), not hostile input, so it fails loud
|
||||
* here rather than degrading to a dropped or lossy frame. Exported for the
|
||||
* test that owns this decision: every currently allowlisted event has a
|
||||
* statically JSON-safe payload, so a type-legal `ctx.emit` cannot reach the
|
||||
* rejection branch.
|
||||
* @param event - forwarded host event name, named in the failure.
|
||||
* @param args - the emitter's argument list.
|
||||
* @returns the same arguments typed as JSON values.
|
||||
*/
|
||||
export function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] {
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (!isJsonValue(arg)) {
|
||||
throw new Error(`forwarded host event "${event}" argument ${index} is not lossless JSON data`)
|
||||
}
|
||||
}
|
||||
return args as JsonValue[]
|
||||
}
|
||||
|
||||
/** Queue the subscription baseline frame. */
|
||||
function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
|
||||
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
||||
@@ -3441,9 +3463,27 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
workspace: changedWorkspaceView(change.key, change.value),
|
||||
}))
|
||||
}),
|
||||
ctx.on('commands/change', () => {
|
||||
queue.push(frame({ type: 'host/commands-changed' }))
|
||||
}),
|
||||
// Allowlisted host events ride one verbatim wrapper frame each. The
|
||||
// allowlist is api-remotes', and `ctx.remote.$on` is the consumer
|
||||
// face; nothing here projects, redacts, or renames. Registered ahead
|
||||
// of the derived frames below so a forwarded event still precedes the
|
||||
// invalidation derived from it (`settings/document-updated` before
|
||||
// its `host/models-changed`), which is the order a client sees.
|
||||
...API_REMOTE_FORWARDED_EVENTS.map(name => ctx.on(
|
||||
name,
|
||||
// cordis keys `on` by literal event name, so subscribing from a
|
||||
// runtime list erases the handler type once. The erasure is safe
|
||||
// because the allowlist's shape assertion already proves each name
|
||||
// is a real, non-scoped, void-returning event, and assertJsonArgs
|
||||
// proves the payload is JSON-safe before it reaches the queue.
|
||||
((...args: unknown[]) => {
|
||||
queue.push(frame({
|
||||
type: 'host/remote-event',
|
||||
event: name,
|
||||
args: assertJsonArgs(name, args),
|
||||
}))
|
||||
}) as Events[typeof name],
|
||||
)),
|
||||
// The recompose itself registers nothing (it re-parents the agent's
|
||||
// scope onto a standing mount that may already exist), so the
|
||||
// logged selection is the only commit point a client can follow.
|
||||
@@ -3461,7 +3501,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// configuration client still has to re-read (its held revision is
|
||||
// stale, and the field's meaning changed).
|
||||
const name = String(ns)
|
||||
queue.push(frame({ type: 'host/settings-changed', ns: name }))
|
||||
// A provider's own settings carry its model catalog and endpoint,
|
||||
// so a change there invalidates the model list even when the route
|
||||
// set is untouched — `llm/adapters-updated` alone misses it. The
|
||||
@@ -3473,9 +3512,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
queue.push(frame({ type: 'host/models-changed' }))
|
||||
}
|
||||
}),
|
||||
ctx.on('credentials/updated', (ref) => {
|
||||
queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) }))
|
||||
}),
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
queue.push(frame({ type: 'host/models-changed' }))
|
||||
}),
|
||||
|
||||
@@ -83,10 +83,12 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
|
||||
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
|
||||
z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
// args stays wide, the same posture as session/projection's value: the frame
|
||||
// arrives from JSON.parse, so every element is already a JSON value, and the
|
||||
// structural contract belongs to the owner package's cordis `Events`
|
||||
// declaration — the host validated JSON-safety before forwarding.
|
||||
z.object({ type: z.literal('host/remote-event'), event: z.string().min(1), args: z.array(z.unknown()) }),
|
||||
z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }),
|
||||
z.object({ type: z.literal('host/settings-changed'), ns: z.string() }),
|
||||
z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }),
|
||||
z.object({ type: z.literal('host/models-changed') }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-a
|
||||
import type { Message } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { JsonValue, SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
|
||||
import type { TaskView } from './tasks.ts'
|
||||
@@ -140,36 +140,29 @@ export type HostFrame =
|
||||
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
|
||||
| { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] }
|
||||
/**
|
||||
* The command registry changed (`commands/change` passthrough). Pure
|
||||
* invalidation signal, no payload: clients refetch `command.list` in the
|
||||
* background rather than diffing.
|
||||
* One allowlisted host cordis event forwarded verbatim. The allowlist is
|
||||
* owned by `@deepseek-ai/dsh-api-remotes` (`API_REMOTE_FORWARDED_EVENTS`),
|
||||
* which is also the only control point over what a consumer can receive.
|
||||
* `event` is the host's own event name and `args` its argument list: this
|
||||
* path applies no projection, no redaction, and no renaming, so the payload
|
||||
* contract is the owner package's cordis `Events` declaration rather than
|
||||
* anything stated here. Delivery lands on `ctx.remote.$on`, not on a
|
||||
* per-event frame variant.
|
||||
*/
|
||||
| { type: 'host/commands-changed' }
|
||||
| { type: 'host/remote-event'; event: string; args: JsonValue[] }
|
||||
/**
|
||||
* One blank session was recomposed onto another agent preset (the logged
|
||||
* `agent-preset/selected` commit point, read off the session stream). The
|
||||
* registry-wide `host/commands-changed` cannot stand in for it: recomposing
|
||||
* re-parents that agent's scope without registering anything, so a
|
||||
* preset already mounted for another session produces no registry change
|
||||
* at all. Clients refetch the catalogs this session's composition decides
|
||||
* (`command.list`, `skill.list`) for this sessionId alone, and fold the
|
||||
* preset id into their session row — the RPC echo reaches only the client
|
||||
* that issued the switch, so the row is where every other one learns it.
|
||||
* registry-wide `commands/change` forwarded above cannot stand in for it:
|
||||
* recomposing re-parents that agent's scope without registering anything,
|
||||
* so a preset already mounted for another session produces no registry
|
||||
* change at all. Clients refetch the catalogs this session's composition
|
||||
* decides (`command.list`, `skill.list`) for this sessionId alone, and fold
|
||||
* the preset id into their session row — the RPC echo reaches only the
|
||||
* client that issued the switch, so the row is where every other one learns
|
||||
* it.
|
||||
*/
|
||||
| { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string }
|
||||
/**
|
||||
* One settings namespace's resolved value changed (`settings/updated`
|
||||
* passthrough) — an RPC write, an external `settings.yaml` edit, or a
|
||||
* provider reload all converge here. Clients refetch `settings.describe`;
|
||||
* values never ride the frame (they would need redaction and can go stale).
|
||||
*/
|
||||
| { type: 'host/settings-changed'; ns: string }
|
||||
/**
|
||||
* One credential reference's state changed (`credentials/updated`
|
||||
* passthrough): a set/unset over this wire or an external `.env` edit.
|
||||
* The ref is an environment-variable NAME — never a value.
|
||||
*/
|
||||
| { type: 'host/credentials-changed'; ref: string }
|
||||
/**
|
||||
* The provider topology changed (`llm/adapters-updated` passthrough):
|
||||
* routes registered or dropped, or the configurable directory moved. Pure
|
||||
|
||||
@@ -23,7 +23,7 @@ import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import type { HostFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { assertJsonArgs, createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
|
||||
|
||||
@@ -269,7 +269,7 @@ describe('skill.list', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('host/commands-changed frame', () => {
|
||||
describe('forwarded commands/change frame', () => {
|
||||
it('broadcasts on registry change', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
@@ -277,7 +277,29 @@ describe('host/commands-changed frame', () => {
|
||||
const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal)
|
||||
const collected = collect<HostFrame>(stream, 1, abort)
|
||||
ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) })
|
||||
expect(await collected).toEqual([{ type: 'host/commands-changed' }])
|
||||
// Verbatim forwarding: the wire name is the host's own event name and
|
||||
// `args` is its argument list (empty for this pure invalidation).
|
||||
expect(await collected).toEqual([{ type: 'host/remote-event', event: 'commands/change', args: [] }])
|
||||
})
|
||||
|
||||
// The guard belongs to the forwarding boundary, so it is tested there rather
|
||||
// than through a malformed `ctx.emit`: every currently allowlisted event has a
|
||||
// statically JSON-safe payload, so no type-legal emit can reach the rejection
|
||||
// branch. These cases stand in for a future allowlist entry whose payload the
|
||||
// wire cannot carry — a composition mistake that must fail loud.
|
||||
describe('assertJsonArgs', () => {
|
||||
it('passes a JSON-safe argument list through unchanged', () => {
|
||||
const args = ['llm-deepseek', 7, null, { nested: ['ok'] }]
|
||||
expect(assertJsonArgs('settings/document-updated', args)).toEqual(args)
|
||||
expect(assertJsonArgs('commands/change', [])).toEqual([])
|
||||
})
|
||||
|
||||
it('names the offending event and argument position when a payload is not lossless JSON', () => {
|
||||
expect(() => assertJsonArgs('credentials/updated', [1n]))
|
||||
.toThrow('forwarded host event "credentials/updated" argument 0 is not lossless JSON data')
|
||||
expect(() => assertJsonArgs('settings/document-updated', ['ns', () => {}]))
|
||||
.toThrow('forwarded host event "settings/document-updated" argument 1 is not lossless JSON data')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -218,6 +218,21 @@ async function collectHost(
|
||||
return frames
|
||||
}
|
||||
|
||||
/**
|
||||
* One forwarded `settings/document-updated` frame for `ns`. The revision rides
|
||||
* the host's own argument list, so it is matched by shape rather than pinned to
|
||||
* a per-test count.
|
||||
* @param ns - the namespace whose stored section changed.
|
||||
* @returns the expected wrapper frame.
|
||||
*/
|
||||
function forwardedSettings(ns: string): HostFrame {
|
||||
return {
|
||||
type: 'host/remote-event',
|
||||
event: 'settings/document-updated',
|
||||
args: [ns, expect.any(Number) as unknown as number],
|
||||
}
|
||||
}
|
||||
|
||||
describe('settings domain', () => {
|
||||
it('reports an actionable error when no settings provider is mounted', async () => {
|
||||
const ctx = await harness({ settings: false })
|
||||
@@ -376,7 +391,7 @@ describe('settings domain', () => {
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
|
||||
.toEqual(['ui-onboarding', 'ui-theme'])
|
||||
const frames = await collectHost(api, ['host/settings-changed'], 2, async () => {
|
||||
const frames = await collectHost(api, ['host/remote-event'], 2, async () => {
|
||||
expectOk(await api.settings.mutate(request({
|
||||
ns: 'ui-onboarding',
|
||||
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
|
||||
@@ -386,10 +401,7 @@ describe('settings domain', () => {
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
})))
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'ui-onboarding' },
|
||||
{ type: 'host/settings-changed', ns: 'ui-theme' },
|
||||
])
|
||||
expect(frames).toEqual([forwardedSettings('ui-onboarding'), forwardedSettings('ui-theme')])
|
||||
})
|
||||
|
||||
it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => {
|
||||
@@ -425,11 +437,11 @@ describe('settings domain', () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
|
||||
const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 2, async () => {
|
||||
await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } }))
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'llm-deepseek' },
|
||||
forwardedSettings('llm-deepseek'),
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
// The resolved value never moved: base already said https://base.
|
||||
@@ -445,10 +457,10 @@ describe('settings domain', () => {
|
||||
base: { defaultPreset: 'read-only' },
|
||||
})
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 1, async () => {
|
||||
const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 1, async () => {
|
||||
await permission.update({ defaultPreset: 'workspace-write' })
|
||||
})
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
|
||||
expect(frames).toEqual([forwardedSettings('permission')])
|
||||
})
|
||||
|
||||
it('invalidates the model catalog when the Agent default selection changes', async () => {
|
||||
@@ -461,11 +473,11 @@ describe('settings domain', () => {
|
||||
// The shared section names the selection every blank session resolves to,
|
||||
// so an externally edited default — another tab, a
|
||||
// hand-edited settings.yaml — has to reach an open selector as well.
|
||||
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
|
||||
const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 2, async () => {
|
||||
await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'agent-default-model' },
|
||||
forwardedSettings('agent-default-model'),
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
})
|
||||
@@ -488,14 +500,14 @@ describe('settings domain', () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
|
||||
const frames = await collectHost(api, ['host/remote-event'], 1, async () => {
|
||||
const view = expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { apiKey: 'sk-new', baseURL: 'https://next' } })))
|
||||
expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://next' })
|
||||
expect(view.user).toEqual({ baseURL: 'https://next' })
|
||||
expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }])
|
||||
expect(JSON.stringify(view)).not.toContain('sk-new')
|
||||
})
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'llm-deepseek' }])
|
||||
expect(frames).toEqual([forwardedSettings('llm-deepseek')])
|
||||
})
|
||||
|
||||
it('replace resets the user layer wholesale', async () => {
|
||||
@@ -560,7 +572,7 @@ describe('credentials domain', () => {
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const before = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
|
||||
expect(before.credentials).toEqual({ OPENAI_API_KEY: { configured: false, writable: true } })
|
||||
const frames = await collectHost(api, ['host/credentials-changed'], 2, async () => {
|
||||
const frames = await collectHost(api, ['host/remote-event'], 2, async () => {
|
||||
expectOk(await api.credentials.set(request({ ref: 'OPENAI_API_KEY', value: 'sk-secret' })))
|
||||
const after = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] })))
|
||||
expect(after.credentials).toEqual({ OPENAI_API_KEY: { configured: true, source: 'file', writable: true } })
|
||||
@@ -568,8 +580,8 @@ describe('credentials domain', () => {
|
||||
expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' })))
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' },
|
||||
{ type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' },
|
||||
{ type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
|
||||
{ type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -505,7 +505,8 @@ describe('events frame schemas', () => {
|
||||
createdAt: '0', updatedAt: '0',
|
||||
} },
|
||||
{ type: 'host/workspace-removed', workspaceId: 'w' },
|
||||
{ type: 'host/commands-changed' },
|
||||
{ type: 'host/remote-event', event: 'commands/change', args: [] },
|
||||
{ type: 'host/remote-event', event: 'settings/document-updated', args: ['ns', 3] },
|
||||
{ type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
|
||||
@@ -123,16 +123,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
commands: CommandService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A command was registered or unregistered. This is an unfiltered registry
|
||||
* notification because a global or scoped change may affect any UI view.
|
||||
* Observer failures are contained and cannot veto the registry mutation.
|
||||
* @mode emit
|
||||
*/
|
||||
'commands/change'(): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Durable command event vocabulary shared with type-only consumers.
|
||||
* Durable command event vocabulary and the registry's Cordis event
|
||||
* declaration, shared with type-only consumers. Client-safe: nothing here
|
||||
* reaches a Host-only symbol, so a Client compilation face reads the same
|
||||
* `commands/change` signature the Host emits.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-commands/types
|
||||
*/
|
||||
@@ -19,6 +22,18 @@ export interface CommandSourceMap {
|
||||
/** The union over {@link CommandSourceMap} — who issued a command line. */
|
||||
export type CommandSource = CommandSourceMap[keyof CommandSourceMap]
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* A command was registered or unregistered. This is an unfiltered registry
|
||||
* notification because a global or scoped change may affect any UI view.
|
||||
* Observer failures are contained and cannot veto the registry mutation.
|
||||
* @mode emit
|
||||
*/
|
||||
'commands/change'(): void
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
|
||||
@@ -22,12 +22,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
@@ -8,15 +8,13 @@
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type z from '@deepseek-ai/schemastery'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import { redactSecrets } from './redact.ts'
|
||||
import type { RedactedSecret } from './redact.ts'
|
||||
import type { SettingsNamespace, SettingsUpdateSource } from './types.ts'
|
||||
|
||||
export { redactSecrets } from './redact.ts'
|
||||
export type { RedactedSecret, RedactedValue } from './redact.ts'
|
||||
|
||||
/** Nominal id of one registered settings namespace. */
|
||||
export type SettingsNamespace = Branded<'SettingsNamespace'>
|
||||
export type { SettingsNamespace, SettingsUpdateSource } from './types.ts'
|
||||
|
||||
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/
|
||||
|
||||
@@ -35,9 +33,6 @@ export function settingsNamespace(value: string): SettingsNamespace {
|
||||
/** When a namespace's changes take effect for its owner. */
|
||||
export type SettingsApplies = 'live' | 'restart'
|
||||
|
||||
/** Origin of one committed settings change. */
|
||||
export type SettingsUpdateSource = 'update' | 'provider'
|
||||
|
||||
/** Registration options beyond the namespace schema. */
|
||||
export interface SettingsRegisterOptions<T> {
|
||||
/** Composition-layer values resolved below the user layer (entry-config subset). */
|
||||
@@ -137,38 +132,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Committed change to one registered namespace's resolved value. Emitted
|
||||
* after the provider persisted (for `update`) or published (`provider`)
|
||||
* the change; never emitted when the resolved value is deep-equal.
|
||||
* Listener failures are contained and logged — a sync throw and an async
|
||||
* rejection alike — except `INVARIANT`-coded failures, which rethrow
|
||||
* after every listener ran; that rethrow reaches the emitter only from
|
||||
* synchronous listeners, so invariant checks on this event must not be
|
||||
* async functions.
|
||||
* @param ns - the namespace whose resolved value changed.
|
||||
* @param next - the new resolved value.
|
||||
* @param prev - the previous resolved value.
|
||||
* @param source - whether the change entered through `update()` or the provider.
|
||||
* @mode emit
|
||||
*/
|
||||
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
|
||||
|
||||
/**
|
||||
* One registered namespace's RAW user section changed, whether or not the
|
||||
* resolved value did. `settings/updated` is the consumer-facing event and
|
||||
* stays deep-equal-gated; this one exists for configuration surfaces,
|
||||
* which must learn that a field went from inherited to overridden (same
|
||||
* resolved value, different meaning) and that their held revision is
|
||||
* stale. Listener containment matches `settings/updated`.
|
||||
* @param ns - the namespace whose stored section changed.
|
||||
* @param revision - the namespace's new revision.
|
||||
* @mode emit
|
||||
*/
|
||||
'settings/document-updated'(ns: SettingsNamespace, revision: number): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
50
packages/settings/settings/src/types.ts
Normal file
50
packages/settings/settings/src/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Client-safe type surface of the user-settings seam: the namespace brand, the
|
||||
* commit-origin union, and the seam's Cordis event declarations. Types only —
|
||||
* no runtime code, and nothing here reaches a Host-only symbol, so a Client
|
||||
* compilation face reads exactly the signatures the Host emits.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-settings/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Nominal id of one registered settings namespace. */
|
||||
export type SettingsNamespace = Branded<'SettingsNamespace'>
|
||||
|
||||
/** Origin of one committed settings change. */
|
||||
export type SettingsUpdateSource = 'update' | 'provider'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Committed change to one registered namespace's resolved value. Emitted
|
||||
* after the provider persisted (for `update`) or published (`provider`)
|
||||
* the change; never emitted when the resolved value is deep-equal.
|
||||
* Listener failures are contained and logged — a sync throw and an async
|
||||
* rejection alike — except `INVARIANT`-coded failures, which rethrow
|
||||
* after every listener ran; that rethrow reaches the emitter only from
|
||||
* synchronous listeners, so invariant checks on this event must not be
|
||||
* async functions.
|
||||
* @param ns - the namespace whose resolved value changed.
|
||||
* @param next - the new resolved value.
|
||||
* @param prev - the previous resolved value.
|
||||
* @param source - whether the change entered through `update()` or the provider.
|
||||
* @mode emit
|
||||
*/
|
||||
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
|
||||
|
||||
/**
|
||||
* One registered namespace's RAW user section changed, whether or not the
|
||||
* resolved value did. `settings/updated` is the consumer-facing event and
|
||||
* stays deep-equal-gated; this one exists for configuration surfaces,
|
||||
* which must learn that a field went from inherited to overridden (same
|
||||
* resolved value, different meaning) and that their held revision is
|
||||
* stale. Listener containment matches `settings/updated`.
|
||||
* @param ns - the namespace whose stored section changed.
|
||||
* @param revision - the namespace's new revision.
|
||||
* @mode emit
|
||||
*/
|
||||
'settings/document-updated'(ns: SettingsNamespace, revision: number): void
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export type {
|
||||
TypeRTContextRegistry,
|
||||
TypeRTContextWire,
|
||||
TypeRTDisposer,
|
||||
TypeRTForwardableEvent,
|
||||
TypeRTHostContextProvider,
|
||||
TypeRTHostContextResolver,
|
||||
TypeRTLocalRegistry,
|
||||
@@ -64,6 +65,8 @@ export type {
|
||||
TypeRTRemoteScopeMap,
|
||||
TypeRTRemoteScopeNamespace,
|
||||
TypeRTRemoteContribution,
|
||||
TypeRTRemoteEvent,
|
||||
TypeRTRemoteEventSelection,
|
||||
TypeRTRemoteMap,
|
||||
TypeRTRemoteNamespace,
|
||||
TypeRTRemoteNamespaceMap,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @module @deepseek-ai/dsh-type-meta/types
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Context, Events } from '@deepseek-ai/cordis'
|
||||
|
||||
declare const LOOKUP_HOST: unique symbol
|
||||
declare const LOOKUP_WIRE: unique symbol
|
||||
@@ -42,6 +42,24 @@ export interface TypeRTRemoteMap {}
|
||||
/** Merge-extensible scoped Remote method signatures generated for consumers. */
|
||||
export interface TypeRTRemoteScopeMap {}
|
||||
|
||||
/**
|
||||
* Cordis event names whose shape a one-way Remote delivery can carry: unbound
|
||||
* from any Scope and returning `void`. Which ones are actually forwarded is the
|
||||
* Host assembly's selection; this predicate only excludes shapes the carrier
|
||||
* cannot represent.
|
||||
*/
|
||||
export type TypeRTForwardableEvent = {
|
||||
[Event in keyof Events]: unknown extends ThisParameterType<Events[Event]>
|
||||
? ReturnType<Events[Event]> extends void ? Event : never
|
||||
: never
|
||||
}[keyof Events]
|
||||
|
||||
/** Merge-extensible forwarding selection declared once by the Host assembly. */
|
||||
export interface TypeRTRemoteEventSelection {}
|
||||
|
||||
/** Legal `$on` keys: selected events that exist in the current compilation face. */
|
||||
export type TypeRTRemoteEvent = Extract<keyof Events, keyof TypeRTRemoteEventSelection>
|
||||
|
||||
/**
|
||||
* Resolve one direct Remote namespace from the generated flat endpoint map.
|
||||
* @template Namespace - wire namespace before the endpoint slash.
|
||||
@@ -184,6 +202,15 @@ export interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap {
|
||||
* @returns disposer after namespace services and concrete methods are ready.
|
||||
*/
|
||||
$mount(contribution: TypeRTRemoteContribution): Promise<TypeRTDisposer>
|
||||
/**
|
||||
* Subscribe to one forwarded Host event; delivery is one-way, in registration
|
||||
* order, and isolates a throwing listener from the rest.
|
||||
* @template Event - forwarded event name selected by the Host assembly.
|
||||
* @param event - forwarded Host event name, unchanged on the wire.
|
||||
* @param listener - receives the Host's argument list as declared by Cordis `Events`.
|
||||
* @returns disposer owned by the calling fiber.
|
||||
*/
|
||||
$on<Event extends TypeRTRemoteEvent>(event: Event, listener: Events[Event]): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,4 +452,18 @@ declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
typert: TypeRTService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* The carrier received one allowlisted host event forwarded over the wire.
|
||||
* Declared here because both compilation faces share this package; only the
|
||||
* consumer side participates, where the Client half owning the host frame
|
||||
* sink emits it and the Remote service is its only subscriber, turning it
|
||||
* into `$on` callbacks. The Host neither emits nor observes it.
|
||||
* @mode emit
|
||||
* @param event - forwarded host event name, exactly as the Host emitted it.
|
||||
* @param args - the Host argument list, already JSON-decoded.
|
||||
*/
|
||||
'remote/host-event'(event: string, args: readonly unknown[]): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import {
|
||||
bindTypeRTGateway,
|
||||
GatewayService,
|
||||
@@ -9,12 +9,38 @@ import {
|
||||
RemoteScope,
|
||||
remoteMethods,
|
||||
type TypeRTContext,
|
||||
type TypeRTForwardableEvent,
|
||||
type TypeRTRemoteEvent,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Test-only one-way event: bound to no Scope and returning nothing.
|
||||
* @param value - marker payload.
|
||||
*/
|
||||
'meta-fixture/forwardable'(value: string): void
|
||||
/**
|
||||
* Test-only Scope-bound event, which no carrier can deliver one-way.
|
||||
* @param value - marker payload.
|
||||
*/
|
||||
'meta-fixture/scoped'(this: Context, value: string): void
|
||||
/**
|
||||
* Test-only answered event, whose result no one-way delivery can return.
|
||||
* @param value - marker payload.
|
||||
* @returns the replacement value.
|
||||
*/
|
||||
'meta-fixture/answered'(value: string): string
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
metaFixture: TypeRTContext<string>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteEventSelection extends
|
||||
Record<'meta-fixture/forwardable' | 'meta-fixture/absent', true> {}
|
||||
}
|
||||
|
||||
describe('type-meta Remote declarations', () => {
|
||||
@@ -209,6 +235,16 @@ describe('type-meta Remote declarations', () => {
|
||||
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace')
|
||||
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api goals' })).toThrow('namespace')
|
||||
})
|
||||
|
||||
it('admits only one-way event shapes and only selected events that exist', () => {
|
||||
expectTypeOf<'meta-fixture/forwardable'>().toExtend<TypeRTForwardableEvent>()
|
||||
expectTypeOf<'meta-fixture/scoped'>().not.toExtend<TypeRTForwardableEvent>()
|
||||
expectTypeOf<'meta-fixture/answered'>().not.toExtend<TypeRTForwardableEvent>()
|
||||
|
||||
expectTypeOf<'meta-fixture/forwardable'>().toExtend<TypeRTRemoteEvent>()
|
||||
expectTypeOf<'meta-fixture/scoped'>().not.toExtend<TypeRTRemoteEvent>()
|
||||
expectTypeOf<'meta-fixture/absent'>().not.toExtend<TypeRTRemoteEvent>()
|
||||
})
|
||||
})
|
||||
|
||||
function methodContext<This extends object>(
|
||||
|
||||
Reference in New Issue
Block a user