Merge upstream master into feat/produced-files-folder
This commit is contained in:
@@ -18,6 +18,7 @@ import type { HostFrame } from '../src/api/events.ts'
|
||||
import {
|
||||
InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -373,7 +374,7 @@ describe('agentPreset.select', () => {
|
||||
.toBe('minimal')
|
||||
})
|
||||
|
||||
it('frames the committed switch so clients can drop that session\'s catalogs', async () => {
|
||||
it('forwards the owner event so clients can drop that session\'s catalogs', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' }))
|
||||
// The host-stream opener reads the committed-workspace baseline; this
|
||||
@@ -385,22 +386,24 @@ describe('agentPreset.select', () => {
|
||||
const stream = api.events.host(request({}), abort.signal)
|
||||
const consume = (async () => {
|
||||
for await (const frame of stream) {
|
||||
if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload)
|
||||
if (frame.payload.type === 'host/remote-event'
|
||||
&& frame.payload.event === 'agent-preset/selected') frames.push(frame.payload)
|
||||
}
|
||||
})()
|
||||
|
||||
await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-frame'), agentPreset: 'minimal' }))
|
||||
// The queue push rides the synchronous append, so one turn of the loop is
|
||||
// enough to deliver it; closing the stream bounds the read either way.
|
||||
// AgentPresets owns the committed-log-to-event mapping; this spec owns the
|
||||
// forwarding of that event without recreating the owner's implementation.
|
||||
ctx.emit('agent-preset/selected', SessionId('sel-frame'), 'minimal')
|
||||
// The queue push is synchronous; one turn lets the async iterator consume
|
||||
// it before the stream closes.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
abort.abort()
|
||||
await consume
|
||||
|
||||
// Recomposing registers nothing, so this frame — not the registry-wide
|
||||
// commands one — is what tells a client its cached catalogs are stale.
|
||||
// Recomposing registers nothing, so the owner event — not the
|
||||
// registry-wide commands one — tells clients their cached catalogs are stale.
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' },
|
||||
{ type: 'host/remote-event', event: 'agent-preset/selected', args: ['sel-frame', 'minimal'] },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import type { HostFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { assertJsonArgs, createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
|
||||
|
||||
@@ -269,7 +269,7 @@ describe('skill.list', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('host/commands-changed frame', () => {
|
||||
describe('forwarded commands/change frame', () => {
|
||||
it('broadcasts on registry change', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
@@ -277,7 +277,29 @@ describe('host/commands-changed frame', () => {
|
||||
const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal)
|
||||
const collected = collect<HostFrame>(stream, 1, abort)
|
||||
ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) })
|
||||
expect(await collected).toEqual([{ type: 'host/commands-changed' }])
|
||||
// Verbatim forwarding: the wire name is the host's own event name and
|
||||
// `args` is its argument list (empty for this pure invalidation).
|
||||
expect(await collected).toEqual([{ type: 'host/remote-event', event: 'commands/change', args: [] }])
|
||||
})
|
||||
|
||||
// The guard belongs to the forwarding boundary, so it is tested there rather
|
||||
// than through a malformed `ctx.emit`: every currently allowlisted event has a
|
||||
// statically JSON-safe payload, so no type-legal emit can reach the rejection
|
||||
// branch. These cases stand in for a future allowlist entry whose payload the
|
||||
// wire cannot carry — a composition mistake that must fail loud.
|
||||
describe('assertJsonArgs', () => {
|
||||
it('passes a JSON-safe argument list through unchanged', () => {
|
||||
const args = ['llm-deepseek', 7, null, { nested: ['ok'] }]
|
||||
expect(assertJsonArgs('settings/document-updated', args)).toEqual(args)
|
||||
expect(assertJsonArgs('commands/change', [])).toEqual([])
|
||||
})
|
||||
|
||||
it('names the offending event and argument position when a payload is not lossless JSON', () => {
|
||||
expect(() => assertJsonArgs('credentials/updated', [1n]))
|
||||
.toThrow('forwarded host event "credentials/updated" argument 0 is not lossless JSON data')
|
||||
expect(() => assertJsonArgs('settings/document-updated', ['ns', () => {}]))
|
||||
.toThrow('forwarded host event "settings/document-updated" argument 1 is not lossless JSON data')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -218,6 +218,22 @@ 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',
|
||||
// The revision is the Host's own counter, so the matcher is the assertion.
|
||||
args: [ns, expect.any(Number)], // oxlint-disable-line typescript/no-unsafe-assignment
|
||||
}
|
||||
}
|
||||
|
||||
describe('settings domain', () => {
|
||||
it('reports an actionable error when no settings provider is mounted', async () => {
|
||||
const ctx = await harness({ settings: false })
|
||||
@@ -376,7 +392,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 +402,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 () => {
|
||||
@@ -416,7 +429,7 @@ describe('settings domain', () => {
|
||||
.toBe('settings-not-exposed')
|
||||
})
|
||||
|
||||
it('invalidates the model catalog when a provider namespace changes, and broadcasts a raw-only change', async () => {
|
||||
it('forwards a provider settings change for model-catalog consumers', async () => {
|
||||
// Editing `models` changes no route, so llm/adapters-updated never fires
|
||||
// and an open model picker would keep serving the stale catalog. Storing
|
||||
// an override equal to the resolved value emits nothing on
|
||||
@@ -425,13 +438,10 @@ 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'], 1, async () => {
|
||||
await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } }))
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'llm-deepseek' },
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
expect(frames).toEqual([forwardedSettings('llm-deepseek')])
|
||||
// The resolved value never moved: base already said https://base.
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.value)
|
||||
.toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' })
|
||||
@@ -445,13 +455,13 @@ 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'], 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 () => {
|
||||
it('forwards an Agent-default settings change for model-catalog consumers', async () => {
|
||||
const ctx = await harness()
|
||||
const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({
|
||||
provider: z.string().required(),
|
||||
@@ -461,13 +471,10 @@ 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'], 1, async () => {
|
||||
await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'agent-default-model' },
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
expect(frames).toEqual([forwardedSettings('agent-default-model')])
|
||||
})
|
||||
|
||||
it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => {
|
||||
@@ -488,14 +495,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 +567,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 +575,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'] },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -626,15 +633,18 @@ describe('llm domain', () => {
|
||||
expect(value.failures).toEqual([{ id: 'broken', name: 'Broken', message: 'catalog backend down' }])
|
||||
})
|
||||
|
||||
it('broadcasts host/models-changed at every topology commit point', async () => {
|
||||
it('forwards llm/adapters-updated at every topology commit point', async () => {
|
||||
const ctx = await harness()
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const frames = await collectHost(api, ['host/models-changed'], 2, async () => {
|
||||
const frames = await collectHost(api, ['host/remote-event'], 2, async () => {
|
||||
const dispose = ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', []))
|
||||
dispose()
|
||||
return Promise.resolve()
|
||||
})
|
||||
expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }])
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/remote-event', event: 'llm/adapters-updated', args: [] },
|
||||
{ type: 'host/remote-event', event: 'llm/adapters-updated', args: [] },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -510,8 +510,10 @@ describe('events frame schemas', () => {
|
||||
createdAt: '0', updatedAt: '0',
|
||||
} },
|
||||
{ type: 'host/workspace-removed', workspaceId: 'w' },
|
||||
{ type: 'host/commands-changed' },
|
||||
{ type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' },
|
||||
{ type: 'host/remote-event', event: 'commands/change', args: [] },
|
||||
{ type: 'host/remote-event', event: 'settings/document-updated', args: ['ns', 3] },
|
||||
{ type: 'host/remote-event', event: 'agent-preset/selected', args: ['s', 'minimal'] },
|
||||
{ type: 'host/remote-event', event: 'llm/adapters-updated', args: [] },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
|
||||
Reference in New Issue
Block a user