Merge branch 'worktree/schedule-conversational-after' into worktree/schedule-explicit-at

This commit is contained in:
Tianyi Cui
2026-08-11 19:57:55 +08:00
246 changed files with 3188 additions and 1538 deletions

View File

@@ -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' }])

View File

@@ -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()
})
})

View File

@@ -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,
@@ -33,50 +70,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 () => {