From d88f771e1927a3ed674242a391aa54fc4e0349cc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:32:50 +0800 Subject: [PATCH] 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. --- packages/api/gateway/src/client/index.ts | 52 +++++++- packages/api/gateway/src/invariant.ts | 3 +- packages/api/gateway/tests/client.spec.ts | 111 +++++++++++++++++- packages/api/remotes/package.json | 17 ++- packages/api/remotes/src/client/index.ts | 16 +++ packages/api/remotes/src/index.ts | 23 ++++ packages/api/remotes/src/invariant.ts | 39 +++++- packages/api/remotes/src/types.ts | 29 +++++ packages/api/remotes/tests/invariant.spec.ts | 65 ++++++++++ packages/api/remotes/tsconfig.client.json | 15 ++- packages/api/remotes/tsconfig.host.json | 12 +- .../connection/tests/client-apply.spec.ts | 6 +- .../tests/websocket-downlink.spec.ts | 6 +- packages/client/runtime/src/client/index.ts | 34 +----- .../client/runtime/tests/wire-events.spec.ts | 94 +++++++++++---- packages/client/test-runtime/src/index.ts | 1 + packages/client/test-runtime/src/remote.ts | 55 +++++++++ packages/client/ui-agent-preset/package.json | 7 +- .../ui-agent-preset/src/client/index.ts | 17 ++- .../ui-agent-preset/tests/apply.spec.ts | 17 +-- packages/client/ui-agent-preset/tsconfig.json | 3 + packages/client/ui-command/package.json | 7 +- .../client/ui-command/src/client/index.ts | 2 +- .../client/ui-command/src/client/service.ts | 7 +- .../ui-command/tests/browser-plugin.spec.ts | 5 +- .../client/ui-command/tests/service.spec.ts | 6 +- packages/client/ui-command/tsconfig.json | 3 + packages/client/ui-models/package.json | 7 +- packages/client/ui-models/src/client/index.ts | 9 +- packages/client/ui-models/tests/apply.spec.ts | 13 +- packages/client/ui-models/tsconfig.json | 3 + packages/client/ui-permission/package.json | 8 +- .../client/ui-permission/src/client/index.ts | 15 ++- .../tests/browser-plugin.spec.ts | 8 +- packages/client/ui-permission/tsconfig.json | 3 + .../client/ui-settings-general/package.json | 7 +- .../ui-settings-general/src/client/index.ts | 15 ++- .../ui-settings-general/tests/apply.spec.ts | 11 +- .../client/ui-settings-general/tsconfig.json | 3 + packages/core/session/src/types.ts | 5 + packages/credentials/credentials/package.json | 5 + packages/credentials/credentials/src/index.ts | 21 +--- packages/credentials/credentials/src/types.ts | 31 +++++ packages/host/apiproxy/src/api-proxy.ts | 56 +++++++-- .../host/apiproxy/src/api/events.schema.ts | 8 +- packages/host/apiproxy/src/api/events.ts | 43 +++---- .../apiproxy/tests/api-proxy-commands.spec.ts | 28 ++++- .../apiproxy/tests/api-proxy-config.spec.ts | 44 ++++--- .../host/apiproxy/tests/rpc-schemas.spec.ts | 3 +- packages/interaction/commands/src/index.ts | 10 -- packages/interaction/commands/src/types.ts | 17 ++- packages/settings/settings/package.json | 5 + packages/settings/settings/src/index.ts | 41 +------ packages/settings/settings/src/types.ts | 50 ++++++++ packages/typert/type-meta/src/index.ts | 3 + packages/typert/type-meta/src/types.ts | 43 ++++++- .../typert/type-meta/tests/type-meta.spec.ts | 38 +++++- scripts/gen-cordis-catalog.ts | 5 +- tsconfig.base.json | 3 + 59 files changed, 956 insertions(+), 257 deletions(-) create mode 100644 packages/api/remotes/src/types.ts create mode 100644 packages/api/remotes/tests/invariant.spec.ts create mode 100644 packages/client/test-runtime/src/remote.ts create mode 100644 packages/credentials/credentials/src/types.ts create mode 100644 packages/settings/settings/src/types.ts diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 847998f173..00df34f9b4 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -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() + private readonly subscriptions = new Map>() 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 { @@ -91,6 +98,49 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { return async () => { await owned() } } + $on( + event: Event, + listener: Events[Event], + ): ReturnType { + // 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 { + let listeners = this.subscriptions.get(event) + if (listeners === undefined) { + listeners = new Set() + this.subscriptions.set(event, listeners) + } + return listeners + } + private enqueue(operation: () => T | Promise): Promise { const result = this.mutations.then(operation, operation) this.mutations = result.then(() => undefined, () => undefined) diff --git a/packages/api/gateway/src/invariant.ts b/packages/api/gateway/src/invariant.ts index a09d2fa776..9b40cbe64f 100644 --- a/packages/api/gateway/src/invariant.ts +++ b/packages/api/gateway/src/invariant.ts @@ -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 = () => {} diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index b253547c7d..a2c0c980cf 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -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 } @@ -43,6 +67,19 @@ type FixtureContext = Omit & { 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 { + 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()) + 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()) + 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().toHaveProperty('$on') + expectTypeOf().not.toHaveProperty('$dispatch') + }) + + it('drops a forwarded event nobody subscribes to', async () => { + const ctx = await bench(vi.fn()) + const seen: string[] = [] + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + + ctx.emit('remote/host-event', 'fixture/idle', [1]) + + expect(seen).toEqual([]) + }) }) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 241a86ff9f..490c06c13a 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -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:^" } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index ce67fb05d7..417937c2c3 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -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 { diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 4cd70f4a78..b95864fc5f 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -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 {} diff --git a/packages/api/remotes/src/invariant.ts b/packages/api/remotes/src/invariant.ts index f93b63e98b..1e0598a423 100644 --- a/packages/api/remotes/src/invariant.ts +++ b/packages/api/remotes/src/invariant.ts @@ -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 = 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 */ diff --git a/packages/api/remotes/src/types.ts b/packages/api/remotes/src/types.ts new file mode 100644 index 0000000000..ec0aa8a01b --- /dev/null +++ b/packages/api/remotes/src/types.ts @@ -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 {} +} diff --git a/packages/api/remotes/tests/invariant.spec.ts b/packages/api/remotes/tests/invariant.spec.ts new file mode 100644 index 0000000000..1037035a52 --- /dev/null +++ b/packages/api/remotes/tests/invariant.spec.ts @@ -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 { + 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 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"/) + }) +}) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index bc26c0b13f..d537747e77 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -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" } diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 1082a0d94b..0f7fd354d3 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -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" }, diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index d783245002..fa8ae8f1a9 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -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) }) diff --git a/packages/client/connection/tests/websocket-downlink.spec.ts b/packages/client/connection/tests/websocket-downlink.spec.ts index 9d53a82820..fecd7ea224 100644 --- a/packages/client/connection/tests/websocket-downlink.spec.ts +++ b/packages/client/connection/tests/websocket-downlink.spec.ts @@ -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') diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index d01d7eafb6..5bba2ff7fc 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -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: () => { diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index cc1f6c374c..4c2550384c 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -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 { }, } 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]> = [] diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index e94030a1f1..d7692aa6af 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -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' diff --git a/packages/client/test-runtime/src/remote.ts b/packages/client/test-runtime/src/remote.ts new file mode 100644 index 0000000000..143352cb1c --- /dev/null +++ b/packages/client/test-runtime/src/remote.ts @@ -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 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> { + return Promise.reject(new Error('TestRemote: $mount needs the real Client Remote service')) + } +} diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index e5d4e27790..aab0727a7f 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -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:^", diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 150867a177..05abfbd851 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -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 diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 96f47975ee..cf87c541c8 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -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') }) diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index 2b21a7e1e2..ea7292b023 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index 93c0cf3910..7a13ed5bd5 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.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:^", diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index f8de391fad..765e14f751 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -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 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 00515c1e41..02d5b1f14b 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -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 diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts index ae2afff1f5..af60a45e16 100644 --- a/packages/client/ui-command/tests/browser-plugin.spec.ts +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -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 () => { diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index b5029761fa..320b1bf600 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -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() diff --git a/packages/client/ui-command/tsconfig.json b/packages/client/ui-command/tsconfig.json index f83486aa36..d87e874d01 100644 --- a/packages/client/ui-command/tsconfig.json +++ b/packages/client/ui-command/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index af55b2a5cc..bb4dd16fb8 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.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:^", diff --git a/packages/client/ui-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts index 1de35aa342..3262c0c1d3 100644 --- a/packages/client/ui-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -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), ] diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index dc648e939b..e077441fe0 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -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) }) }) diff --git a/packages/client/ui-models/tsconfig.json b/packages/client/ui-models/tsconfig.json index 79e61ffcba..71f8e9cc98 100644 --- a/packages/client/ui-models/tsconfig.json +++ b/packages/client/ui-models/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index 41c3c7eb30..f7391e2c3f 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.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:^", diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index 66b15ed115..d055b10abe 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -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 () => { diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 4575941b4a..7b6f5e2a19 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -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() diff --git a/packages/client/ui-permission/tsconfig.json b/packages/client/ui-permission/tsconfig.json index bfc71e2e3c..987c3e429f 100644 --- a/packages/client/ui-permission/tsconfig.json +++ b/packages/client/ui-permission/tsconfig.json @@ -43,6 +43,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index e720ad810a..8dc142fd91 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.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:^", diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 9d57a0eba9..d4c1aa0ca5 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -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) diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 4d7301faa1..98db5699df 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -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) }) diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json index 5e37578f91..07f5269510 100644 --- a/packages/client/ui-settings-general/tsconfig.json +++ b/packages/client/ui-settings-general/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 9e50c18d11..73c28856cf 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -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'> diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 68c6be2f1b..8fbcf111bf 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -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", diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index b4fb1569f1..d58a4420a1 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -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 - } } /** diff --git a/packages/credentials/credentials/src/types.ts b/packages/credentials/credentials/src/types.ts new file mode 100644 index 0000000000..5922664cbd --- /dev/null +++ b/packages/credentials/credentials/src/types.ts @@ -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 + } +} diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 2474a05df0..147dc9ed7d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -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(payload: F): RpcRequest { 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>, 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' })) }), diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 02516c13bb..f5e99894b7 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -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 diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 73bb9d8bc2..7b200944d9 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -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 diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 038c1050f7..9abe116d31 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -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(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') + }) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 286a6d3f4f..2115d1491a 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -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'] }, ]) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 789639b70d..c3af246240 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -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: {} } }, ] diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index f30f246d64..56b5d4da14 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -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 - } } /** diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 2b81071d37..27309355d4 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -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 { /** diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 9899ded2cc..08424a08d8 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -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", diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index cacb1c5993..1640accc6a 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -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 { /** 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 - } } /** diff --git a/packages/settings/settings/src/types.ts b/packages/settings/settings/src/types.ts new file mode 100644 index 0000000000..0b3e3b0e10 --- /dev/null +++ b/packages/settings/settings/src/types.ts @@ -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 + } +} diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 40a8b5b2ad..3becc67c83 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -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, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 622698bf6c..a4899a7249 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -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 + ? ReturnType 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 + /** * 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 + /** + * 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: 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 + } } diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index aa77541128..42ab3d8831 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -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 } + + 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() + expectTypeOf<'meta-fixture/scoped'>().not.toExtend() + expectTypeOf<'meta-fixture/answered'>().not.toExtend() + + expectTypeOf<'meta-fixture/forwardable'>().toExtend() + expectTypeOf<'meta-fixture/scoped'>().not.toExtend() + expectTypeOf<'meta-fixture/absent'>().not.toExtend() + }) }) function methodContext( diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 08f588d320..e1c65bc8cd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -173,17 +173,14 @@ export const EVENT_SCOPE_PAGE: Record = { * `packages/x/x/src/**`, so a declared event either renders onto a subsystems * page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes * silently. Keys are full event names, not scopes: client-face events share - * scopes with rendered host events (`commands/changed` beside `commands/*`), + * scopes with rendered host events (`models/changed` beside `llm/*`), * so a scope-level exemption would mask a host-face regression. */ export const EVENT_WALK_EXEMPTIONS: Record = { - 'commands/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API', 'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the API', - 'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API', 'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API', 'session/preset-changed': 'client-face per-session catalog invalidation signal — packages/client/runtime/README.md owns the API', - 'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API', 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API', 'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API', diff --git a/tsconfig.base.json b/tsconfig.base.json index 30a0e9953b..c3614f011f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -74,6 +74,9 @@ "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-tasks/brand": ["./packages/tasks/tasks/src/brand.ts"], + "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], + "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], + "@deepseek-ai/dsh-api-remotes/types": ["./packages/api/remotes/src/types.ts"], "@deepseek-ai/dsh-compact/checkpoint": ["./packages/compact/compact/src/checkpoint.ts"], "@deepseek-ai/dsh-compact/types": ["./packages/compact/compact/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"],