Merge upstream master into feat/produced-files-folder
This commit is contained in:
@@ -87,23 +87,6 @@ describe('list store projection', () => {
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
|
||||
|
||||
// Every connected client gets this frame; only the switching tab gets the
|
||||
// RPC echo. A client that ignored the payload would keep labelling the
|
||||
// session with the composition it replaced.
|
||||
b.svc.handleHostEnvelope({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
|
||||
})
|
||||
|
||||
it('reflects live increments (host stream via manager) into the store', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
bindSettingsScope, SettingsScopeController, type SettingsScope,
|
||||
} from '../src/client/settings-scope.ts'
|
||||
|
||||
interface UiTestSettings {
|
||||
preference: 'light' | 'dark' | 'system'
|
||||
}
|
||||
|
||||
const ENVELOPE = z.object({
|
||||
preference: z.union(['light', 'dark', 'system']).default('system'),
|
||||
}).toJSON()
|
||||
|
||||
let rpc = 0
|
||||
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function rejected<T>(): RpcResponse<T> {
|
||||
return {
|
||||
rpcId: `scope-${rpc++}` as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function view(value: unknown, revision = 0): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'ui-test',
|
||||
schema: ENVELOPE,
|
||||
value,
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function described(value: unknown, revision = 0) {
|
||||
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** Record each distinct published section, starting from the current one. */
|
||||
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
|
||||
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
|
||||
scope.subscribe(() => {
|
||||
const value = scope.getSnapshot().value
|
||||
if (value !== seen[seen.length - 1]) seen.push(value)
|
||||
})
|
||||
return seen
|
||||
}
|
||||
|
||||
describe('SettingsScopeController', () => {
|
||||
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
|
||||
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
|
||||
})
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
|
||||
.mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
|
||||
.mockResolvedValueOnce(described(null, 5))
|
||||
.mockResolvedValueOnce(described('scalar', 6))
|
||||
.mockResolvedValueOnce(described(['queue'], 7))
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const good = trackValues(scope)
|
||||
for (let i = 0; i < 7; i++) await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({
|
||||
status: 'ready', value: { preference: 'dark' }, revision: 7,
|
||||
})
|
||||
expect(good).toEqual([undefined, { preference: 'dark' }])
|
||||
})
|
||||
|
||||
it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
|
||||
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
|
||||
})
|
||||
|
||||
it('suppresses a superseded read of an unexposed namespace', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const statuses: string[] = []
|
||||
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
|
||||
const stale = scope.load()
|
||||
const fresh = scope.load()
|
||||
await Promise.all([stale, fresh])
|
||||
expect(statuses).not.toContain('unavailable')
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
|
||||
})
|
||||
|
||||
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot().status).toBe('ready')
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('applies a custom decode override in place of the wire schema', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{
|
||||
namespace: 'ui-test',
|
||||
decode: section => (section as UiTestSettings).preference === 'dark'
|
||||
? section as UiTestSettings
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
|
||||
await scope.load()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
|
||||
const mutate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await scope.load()
|
||||
const dark = scope.set('preference', 'dark')
|
||||
const light = scope.set('preference', 'light')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
first.resolve(ok(view({ preference: 'dark' }, 5)))
|
||||
await Promise.all([dark, light])
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
|
||||
expect(mutate).toHaveBeenNthCalledWith(1, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
|
||||
expectedRevision: 4,
|
||||
})
|
||||
expect(mutate).toHaveBeenNthCalledWith(2, {
|
||||
ns: 'ui-test',
|
||||
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
|
||||
expectedRevision: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers the latest rejected or thrown write from Host state', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.set('preference', 'system')
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
|
||||
})
|
||||
|
||||
it('does not recover superseded rejected or thrown writes', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(rejected())
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
await Promise.all([
|
||||
scope.set('preference', 'dark'),
|
||||
scope.set('preference', 'system'),
|
||||
scope.set('preference', 'light'),
|
||||
])
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
|
||||
})
|
||||
|
||||
it('keeps the write queue usable when a subscriber throws', async () => {
|
||||
const describeCall = vi.fn()
|
||||
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
let thrown = false
|
||||
scope.subscribe(() => {
|
||||
if (thrown) return
|
||||
thrown = true
|
||||
throw new Error('subscriber failed')
|
||||
})
|
||||
await expect(scope.load()).rejects.toThrow('subscriber failed')
|
||||
await expect(scope.load()).resolves.toBeUndefined()
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
|
||||
})
|
||||
|
||||
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
|
||||
const first = deferred<RpcResponse<SettingsNamespaceView>>()
|
||||
const mutate = vi.fn().mockReturnValue(first.promise)
|
||||
const describeCall = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
)
|
||||
const published = trackValues(scope)
|
||||
const dark = scope.set('preference', 'dark')
|
||||
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
const light = scope.set('preference', 'light')
|
||||
let stopped = false
|
||||
const stop = scope.dispose().then(() => { stopped = true })
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
first.resolve(ok(view({ preference: 'dark' }, 1)))
|
||||
await Promise.all([dark, light, stop])
|
||||
await scope.set('preference', 'system')
|
||||
await scope.load()
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(published).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('keeps a remote browser in memory mode without Host calls', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const scope = new SettingsScopeController<UiTestSettings>(
|
||||
{ settings: { describe: describeCall, mutate } } as never,
|
||||
{ namespace: 'ui-test' },
|
||||
'memory',
|
||||
)
|
||||
expect(scope.getSnapshot()).toEqual({
|
||||
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
|
||||
})
|
||||
await scope.load()
|
||||
await scope.set('preference', 'dark')
|
||||
await scope.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bindSettingsScope', () => {
|
||||
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
|
||||
const initial = deferred<ReturnType<typeof described>>()
|
||||
const describeCall = vi.fn()
|
||||
.mockReturnValueOnce(initial.promise)
|
||||
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
|
||||
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: true,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
|
||||
ctx.emit('settings/changed', 'unrelated')
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
ctx.emit('connection/reset')
|
||||
initial.resolve(described({ preference: 'dark' }, 1))
|
||||
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
|
||||
await vi.waitFor(() => {
|
||||
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
|
||||
})
|
||||
await fiber.dispose()
|
||||
ctx.emit('settings/changed', 'ui-test')
|
||||
await Promise.resolve()
|
||||
expect(describeCall).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('binds a remote browser in memory mode without starting a settings read', async () => {
|
||||
const describeCall = vi.fn()
|
||||
const ctx = new Context()
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: describeCall } },
|
||||
isLoopback: false,
|
||||
} as never)
|
||||
let scope!: SettingsScope<UiTestSettings>
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['connection'],
|
||||
apply: (plugin: Context) => {
|
||||
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
|
||||
},
|
||||
})
|
||||
await fiber.await()
|
||||
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
|
||||
await fiber.dispose()
|
||||
expect(describeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,26 +1,63 @@
|
||||
/**
|
||||
* 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 handed verbatim to
|
||||
* the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is
|
||||
* api-gateway's own coverage); each established connection generation emits
|
||||
* `connection/reset` for generation-scoped cache invalidation.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-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', () => {})
|
||||
ctx.remote.$on('llm/adapters-updated', () => {})
|
||||
ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
|
||||
void sessionId; void agentPreset
|
||||
})
|
||||
// @ts-expect-error -- client-local event outside the allowlist
|
||||
ctx.remote.$on('slots/changed', () => {})
|
||||
// @ts-expect-error -- declared host event the allowlist does not select
|
||||
ctx.remote.$on('skills/change', () => {})
|
||||
}
|
||||
void forwardedEventContracts
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
sinks: ConnectionSinks | undefined
|
||||
/** Every `$dispatch` the runtime made, as `[event, ...args]`. */
|
||||
dispatched: unknown[][]
|
||||
}
|
||||
|
||||
async function mount(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const api = new FakeApiClient()
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const bench: Bench = { ctx, sinks: undefined, dispatched: [] }
|
||||
// Stands in for api-gateway's Remote service: this spec owns the carrier's
|
||||
// handoff, not the fan-out behind it.
|
||||
ctx.reflect.provide('remote', {
|
||||
$dispatch: (event: string, args: readonly unknown[]) => { bench.dispatched.push([event, ...args]) },
|
||||
})
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
@@ -37,50 +74,52 @@ async function mount(): Promise<Bench> {
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
ctx.reflect.provide('remote', {})
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
|
||||
describe('wire event bridge', () => {
|
||||
it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => {
|
||||
it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => {
|
||||
const bench = await mount()
|
||||
let changed = 0
|
||||
bench.ctx.on('commands/changed', () => { changed++ })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } })
|
||||
expect(changed).toBe(1)
|
||||
const seen = bench.dispatched
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
|
||||
})
|
||||
expect(seen).toEqual([['commands/change']])
|
||||
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r2' as never,
|
||||
payload: { type: 'host/session-status', sessionId: 's1' as never, running: true },
|
||||
})
|
||||
expect(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' } })
|
||||
expect(seen).toEqual([
|
||||
['settings', 'llm-pi-ai'],
|
||||
['credentials', 'OPENAI_API_KEY'],
|
||||
['models'],
|
||||
])
|
||||
})
|
||||
const seen = bench.dispatched
|
||||
|
||||
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
|
||||
const bench = await mount()
|
||||
const seen: Array<[string, string]> = []
|
||||
bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
|
||||
rpcId: 'r3' as never,
|
||||
payload: { type: 'host/remote-event', event: 'settings/document-updated', args: ['llm-pi-ai', 7] },
|
||||
})
|
||||
expect(seen).toEqual([['s1', 'minimal']])
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r4' as never,
|
||||
payload: { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
|
||||
})
|
||||
// The carrier does not second-guess the name: selecting what a consumer can
|
||||
// receive is the allowlist's job, and dropping an unsubscribed name is the
|
||||
// Remote service's. This plugin republishes whatever the frame carried.
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r5' as never,
|
||||
payload: { type: 'host/remote-event', event: 'nobody/listening', args: ['ignored'] },
|
||||
})
|
||||
|
||||
expect(seen).toEqual([
|
||||
['settings/document-updated', 'llm-pi-ai', 7],
|
||||
['credentials/updated', 'OPENAI_API_KEY'],
|
||||
['nobody/listening', 'ignored'],
|
||||
])
|
||||
})
|
||||
|
||||
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user