feat(remote): deliver allowlisted Host events through ctx.remote.$on
api/remotes owns the allowlist and its type projection; type-meta owns the shape predicate, the selection seat, and the internal remote/host-event carrier signal; api/gateway's Client half turns that signal into $on callbacks through a private dispatch. apiproxy forwards each allowlisted emission verbatim in one host/remote-event frame, registered ahead of the derived invalidation frames so frame order is unchanged, and drops the three per-event variants it replaces. Owner packages move their Events declarations into client-safe ./types exports, so a consumer's listener signature is the Host's own declaration.
This commit is contained in:
@@ -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'] },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user