mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
test(agent-presets): cover the Remote migration
This commit is contained in:
@@ -237,19 +237,8 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
read: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.read', payload, Promise.resolve(ok({
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
|
||||
}))),
|
||||
copy: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
openDocument: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
remove: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
|
||||
@@ -2273,6 +2273,82 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
return { ok: true, value: goalView(projection) }
|
||||
}
|
||||
|
||||
/** Canonical fixture implementation of the generated AgentPresets Remote contract. */
|
||||
const presetRemotes = {
|
||||
// Both trusts appear, because a surface must present a locally authored
|
||||
// preset differently from one the deployment vetted.
|
||||
list(): RpcResult<{ presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[]; authorable: boolean }> {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
presets: [...fixturePresets].map(([id, preset]) => ({
|
||||
id,
|
||||
trust: preset.trust,
|
||||
isDefault: id === fixtureDefaultPreset,
|
||||
})),
|
||||
authorable: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
select(_id: SessionId, agentPreset: string): RpcResult<string> {
|
||||
fixtureDefaultPreset = agentPreset
|
||||
return { ok: true, value: agentPreset }
|
||||
},
|
||||
read(agentPreset: string): RpcResult<{ agentPreset: string; trust: 'system' | 'user'; content: string }> {
|
||||
const preset = fixturePresets.get(agentPreset)
|
||||
if (preset === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent-preset-not-found',
|
||||
message: `unknown agent preset "${agentPreset}"`,
|
||||
details: { agentPreset, available: [...fixturePresets.keys()] },
|
||||
},
|
||||
}
|
||||
}
|
||||
return { ok: true, value: { agentPreset, trust: preset.trust, content: preset.content } }
|
||||
},
|
||||
copy(from: string, id: string): RpcResult<void> {
|
||||
const source = fixturePresets.get(from)
|
||||
if (source === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent-preset-not-found',
|
||||
message: `unknown agent preset "${from}"`,
|
||||
details: { agentPreset: from, available: [...fixturePresets.keys()] },
|
||||
},
|
||||
}
|
||||
}
|
||||
if (fixturePresets.has(id)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent-preset-invalid',
|
||||
message: `agent preset "${id}" already exists`,
|
||||
details: { agentPreset: id, reason: 'already exists' },
|
||||
},
|
||||
}
|
||||
}
|
||||
fixturePresets.set(id, { trust: 'user', content: source.content })
|
||||
return { ok: true, value: undefined }
|
||||
},
|
||||
deletePreset(id: string): RpcResult<void> {
|
||||
if (fixturePresets.get(id)?.trust === 'system') {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent-preset-read-only',
|
||||
message: `agent preset "${id}" ships with the deployment`,
|
||||
details: { agentPreset: id, reason: 'it ships with the deployment' },
|
||||
},
|
||||
}
|
||||
}
|
||||
fixturePresets.delete(id)
|
||||
return { ok: true, value: undefined }
|
||||
},
|
||||
}
|
||||
|
||||
/** At most one in-flight replay per session; cancel clears it. */
|
||||
const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
|
||||
|
||||
@@ -3234,57 +3310,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
openPath: request => ok(request, { opened: true as const }),
|
||||
},
|
||||
agentPresets: {
|
||||
// Both trusts appear, because a surface must present a locally authored
|
||||
// preset differently from one the deployment vetted.
|
||||
list: request => ok(request, {
|
||||
presets: [...fixturePresets].map(([id, preset]) => ({
|
||||
id,
|
||||
trust: preset.trust,
|
||||
isDefault: id === fixtureDefaultPreset,
|
||||
})),
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
}),
|
||||
select: (request) => {
|
||||
fixtureDefaultPreset = request.payload.agentPreset
|
||||
return ok(request, { agentPreset: request.payload.agentPreset })
|
||||
},
|
||||
read: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const preset = fixturePresets.get(agentPreset)
|
||||
if (preset === undefined) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-not-found',
|
||||
message: `unknown agent preset "${agentPreset}"`,
|
||||
details: { agentPreset, available: [...fixturePresets.keys()] },
|
||||
})
|
||||
}
|
||||
return ok(request, {
|
||||
agentPreset,
|
||||
trust: preset.trust,
|
||||
content: preset.content,
|
||||
})
|
||||
},
|
||||
copy: (request) => {
|
||||
const { from, agentPreset } = request.payload
|
||||
const source = fixturePresets.get(from)
|
||||
if (source === undefined) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-not-found',
|
||||
message: `unknown agent preset "${from}"`,
|
||||
details: { agentPreset: from, available: [...fixturePresets.keys()] },
|
||||
})
|
||||
}
|
||||
if (fixturePresets.has(agentPreset)) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-invalid',
|
||||
message: `agent preset "${agentPreset}" already exists`,
|
||||
details: { agentPreset, reason: 'already exists' },
|
||||
})
|
||||
}
|
||||
fixturePresets.set(agentPreset, { trust: 'user', content: source.content })
|
||||
return ok(request, { agentPreset })
|
||||
},
|
||||
// Native opens are deterministic no-op successes in this fixture, so the
|
||||
// open-directory affordance renders and the path-text fallback stays a
|
||||
// component-test concern.
|
||||
@@ -3300,19 +3325,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
}
|
||||
return ok(request, { opened: true as const })
|
||||
},
|
||||
remove: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const existing = fixturePresets.get(agentPreset)
|
||||
if (existing?.trust === 'system') {
|
||||
return err(request, {
|
||||
code: 'agent-preset-read-only',
|
||||
message: `agent preset "${agentPreset}" ships with the deployment`,
|
||||
details: { agentPreset, reason: 'it ships with the deployment' },
|
||||
})
|
||||
}
|
||||
fixturePresets.delete(agentPreset)
|
||||
return ok(request, {})
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
@@ -3422,6 +3434,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
query?: string
|
||||
images?: readonly unknown[]
|
||||
ref?: { id: string; revision: number }
|
||||
agentPreset?: string
|
||||
from?: string
|
||||
id?: string
|
||||
request?: unknown
|
||||
_request?: unknown
|
||||
}>
|
||||
@@ -3449,6 +3464,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef))
|
||||
case 'agentPresets/list': return Promise.resolve(presetRemotes.list())
|
||||
case 'agentPresets/select': return Promise.resolve(presetRemotes.select(sessionId, args.agentPreset as string))
|
||||
case 'agentPresets/read': return Promise.resolve(presetRemotes.read(args.agentPreset as string))
|
||||
case 'agentPresets/copy': return Promise.resolve(presetRemotes.copy(args.from as string, args.id as string))
|
||||
case 'agentPresets/deletePreset': return Promise.resolve(presetRemotes.deletePreset(args.id as string))
|
||||
case 'session/list': return sessionApi.list(
|
||||
args._request as Parameters<FixtureSessionApi['list']>[0],
|
||||
)
|
||||
@@ -3580,12 +3600,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'host.createDirectory': return this.api.host.createDirectory(request)
|
||||
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
case 'agentPreset.list': return this.api.agentPresets.list(request)
|
||||
case 'agentPreset.select': return this.api.agentPresets.select(request)
|
||||
case 'agentPreset.read': return this.api.agentPresets.read(request)
|
||||
case 'agentPreset.copy': return this.api.agentPresets.copy(request)
|
||||
case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal)
|
||||
case 'agentPreset.remove': return this.api.agentPresets.remove(request)
|
||||
case 'settings.describe': return this.api.settings.describe(request)
|
||||
case 'settings.openDocument': return this.api.settings.openDocument(request, signal)
|
||||
case 'settings.update': return this.api.settings.update(request)
|
||||
|
||||
@@ -97,19 +97,8 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
read: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.read', payload, Promise.resolve(ok({
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
|
||||
}))),
|
||||
copy: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
openDocument: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
remove: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('connection node half', () => {
|
||||
const methods = [
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set',
|
||||
'llm.discoverModels', 'llm.models', 'agentPreset.read', 'agentPreset.list',
|
||||
'llm.discoverModels', 'llm.models', 'agentPreset.openDocument',
|
||||
]
|
||||
for (const method of methods) {
|
||||
const denied = fakeResponse()
|
||||
@@ -505,8 +505,8 @@ describe('connection node half over a real HTTP server', () => {
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'llm.discoverModels',
|
||||
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
|
||||
'llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select',
|
||||
'agentPreset.openDocument',
|
||||
'llm.providers', 'llm.models',
|
||||
]
|
||||
for (const method of methods) {
|
||||
expect([method, await call(port, method, 'localhost')]).toEqual([method, 401])
|
||||
|
||||
@@ -29,46 +29,34 @@ import { AgentPresetSeatController } from '../src/client/seat-store.ts'
|
||||
// FALLBACK_LOCALE (en); each bench stages zh explicitly on the locale instead.
|
||||
|
||||
const ROSTER_ONE = {
|
||||
rpcId: 'r',
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [{ id: 'standard', trust: 'system', isDefault: true }],
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
},
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [{ id: 'standard', trust: 'system', isDefault: true }],
|
||||
authorable: true,
|
||||
},
|
||||
}
|
||||
|
||||
/** The roster after this browser copied one preset of its own. */
|
||||
const ROSTER_AUTHORED = {
|
||||
rpcId: 'r',
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
],
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
},
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
],
|
||||
authorable: true,
|
||||
},
|
||||
}
|
||||
|
||||
/** The same roster with a second preset carrying the default. */
|
||||
const ROSTER_MOVED = {
|
||||
rpcId: 'r',
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [
|
||||
{ id: 'standard', trust: 'system', isDefault: false },
|
||||
{ id: 'minimal', trust: 'system', isDefault: true },
|
||||
],
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
},
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [
|
||||
{ id: 'standard', trust: 'system', isDefault: false },
|
||||
{ id: 'minimal', trust: 'system', isDefault: true },
|
||||
],
|
||||
authorable: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -84,30 +72,45 @@ async function bench() {
|
||||
ctx.provide('locale', locale)
|
||||
const remote = new TestRemote(ctx)
|
||||
const calls: string[] = []
|
||||
// The roster and the switch are the AgentPresets Remote namespace; the
|
||||
// shared double carries no generated namespaces, so this spec stages its
|
||||
// own. Registered twice on purpose: the nested key satisfies the plugin's
|
||||
// `inject`, and the property is what `ctx.remote.agentPresets` reads,
|
||||
// because the double is a plain provided object rather than a Service.
|
||||
const agentPresets = {
|
||||
list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
|
||||
read: () => Promise.resolve({
|
||||
ok: true as const,
|
||||
value: { agentPreset: 'standard', trust: 'system', content: '' },
|
||||
}),
|
||||
copy: (_from: string, id: string) => {
|
||||
calls.push(`copy:${id}`)
|
||||
// The host's roster now contains it, which is the whole point of the
|
||||
// copy and what every surface must converge on.
|
||||
ROSTER = ROSTER_AUTHORED
|
||||
return Promise.resolve({ ok: true as const, value: undefined })
|
||||
},
|
||||
deletePreset: () => Promise.resolve({ ok: true as const, value: undefined }),
|
||||
select: (_agentId: SessionId, agentPreset: string) => {
|
||||
calls.push(`select:${agentPreset}`)
|
||||
return Promise.resolve({ ok: true as const, value: agentPreset })
|
||||
},
|
||||
}
|
||||
ctx.provide('remote.agentPresets', agentPresets as never)
|
||||
Object.assign(remote, { agentPresets })
|
||||
ctx.provide('connection', {
|
||||
api: {
|
||||
agentPresets: {
|
||||
list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
|
||||
read: () => Promise.resolve({
|
||||
host: {
|
||||
describe: () => Promise.resolve({
|
||||
rpcId: 'r',
|
||||
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '' } },
|
||||
result: { ok: true as const, value: { canOpenPath: true } },
|
||||
}),
|
||||
copy: (payload: { from: string; agentPreset: string }) => {
|
||||
calls.push(`copy:${payload.agentPreset}`)
|
||||
// The host's roster now contains it, which is the whole point of the
|
||||
// copy and what every surface must converge on.
|
||||
ROSTER = ROSTER_AUTHORED
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
|
||||
},
|
||||
},
|
||||
agentPresets: {
|
||||
openDocument: (payload: { agentPreset: string }) => {
|
||||
calls.push(`openDocument:${payload.agentPreset}`)
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } })
|
||||
},
|
||||
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
calls.push(`select:${payload.agentPreset}`)
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
// The row reads this to learn whether this browser may write at all.
|
||||
@@ -179,7 +182,7 @@ function sessionsDouble(state: {
|
||||
|
||||
describe('ui-agent-preset apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'settingsScope'])
|
||||
})
|
||||
|
||||
it('registers the General row and the settings section', async () => {
|
||||
@@ -600,8 +603,7 @@ describe('AgentPresetSeatController reconciliation', () => {
|
||||
{
|
||||
name: 'RPC rejection',
|
||||
select: () => Promise.resolve({
|
||||
rpcId: 'r',
|
||||
result: { ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} } },
|
||||
ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} },
|
||||
}),
|
||||
message: 'selection rejected',
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
|
||||
import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
|
||||
|
||||
@@ -41,6 +41,8 @@ interface FakeOptions {
|
||||
authorable?: boolean
|
||||
/** Whether the host can open a preset directory on a desktop. */
|
||||
hasDocument?: boolean
|
||||
/** Reject `host.describe`, as a dead transport does. */
|
||||
throwDescribe?: boolean
|
||||
/** Hold `remove` until this resolves, to observe the in-flight state. */
|
||||
holdRemove?: Promise<void>
|
||||
}
|
||||
@@ -49,63 +51,29 @@ const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true
|
||||
const fail = (message: string) =>
|
||||
Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } })
|
||||
|
||||
const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value })
|
||||
const remoteFail = (message: string) =>
|
||||
Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } })
|
||||
|
||||
/**
|
||||
* A wire face over an in-memory preset store: copies land, so the roster the
|
||||
* controller re-reads after a copy is the one the copy produced.
|
||||
* @param presets - the starting compositions by id.
|
||||
* The carried wire face: the desktop opener, the default write, and the opener
|
||||
* capability the page joins onto the roster.
|
||||
* @param defaultId - the preset a session with no choice gets.
|
||||
* @param options - failure injection and call recording.
|
||||
* @returns the fake client.
|
||||
*/
|
||||
function fakeApi(
|
||||
presets: Map<string, FakePreset>,
|
||||
defaultId: { id: string },
|
||||
options: FakeOptions = {},
|
||||
): Pick<IApiClient, 'agentPresets' | 'settings'> {
|
||||
): Pick<IApiClient, 'agentPresets' | 'settings' | 'host'> {
|
||||
const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
|
||||
return {
|
||||
host: {
|
||||
describe: () => (options.throwDescribe === true
|
||||
? Promise.reject(new Error('socket closed'))
|
||||
: ok({ canOpenPath: options.hasDocument ?? true })),
|
||||
},
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
record('list', {})
|
||||
if (options.throwList === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failList !== undefined) return fail(options.failList)
|
||||
return ok({
|
||||
presets: [...presets].map(([id, preset]) => ({
|
||||
id, trust: preset.trust, isDefault: id === defaultId.id,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
})),
|
||||
authorable: options.authorable ?? true,
|
||||
hasDocument: options.hasDocument ?? true,
|
||||
})
|
||||
},
|
||||
read: (payload: { agentPreset: string }) => {
|
||||
record('read', payload)
|
||||
if (options.throwRead === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failRead !== undefined) return fail(options.failRead)
|
||||
const preset = presets.get(payload.agentPreset)
|
||||
/* v8 ignore next -- every test reads an id the fake store holds */
|
||||
if (preset === undefined) return fail(`unknown preset ${payload.agentPreset}`)
|
||||
return ok({
|
||||
agentPreset: payload.agentPreset,
|
||||
trust: preset.trust,
|
||||
content: preset.content,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
})
|
||||
},
|
||||
copy: (payload: { from: string; agentPreset: string; name?: string }) => {
|
||||
record('copy', payload)
|
||||
if (options.throwCopy === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failCopy !== undefined) return fail(options.failCopy)
|
||||
const source = presets.get(payload.from)
|
||||
/* v8 ignore next -- every test copies a source the fake store holds */
|
||||
if (source === undefined) return fail(`unknown preset ${payload.from}`)
|
||||
presets.set(payload.agentPreset, {
|
||||
trust: 'user',
|
||||
content: source.content,
|
||||
...payload.name === undefined ? {} : { name: payload.name },
|
||||
})
|
||||
return ok({ agentPreset: payload.agentPreset })
|
||||
},
|
||||
openDocument: (payload: { agentPreset: string }) => {
|
||||
record('openDocument', payload)
|
||||
if (options.throwOpen === true) return Promise.reject(new Error('socket closed'))
|
||||
@@ -114,13 +82,6 @@ function fakeApi(
|
||||
? ok({ opened: true })
|
||||
: ok({ opened: false, path: `/presets/${payload.agentPreset}` })
|
||||
},
|
||||
remove: async (payload: { agentPreset: string }) => {
|
||||
record('remove', payload)
|
||||
await options.holdRemove
|
||||
if (options.failRemove !== undefined) return await fail(options.failRemove)
|
||||
presets.delete(payload.agentPreset)
|
||||
return await ok({})
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
update: (payload: { ns: string; patch: { default?: string } }) => {
|
||||
@@ -131,7 +92,81 @@ function fakeApi(
|
||||
return ok({})
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>
|
||||
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings' | 'host'>
|
||||
}
|
||||
|
||||
/**
|
||||
* The Remote namespace over an in-memory preset store: copies land, so the
|
||||
* roster the controller re-reads after a copy is the one the copy produced.
|
||||
* @param presets - the starting compositions by id.
|
||||
* @param defaultId - the preset a session with no choice gets.
|
||||
* @param options - failure injection and call recording.
|
||||
* @returns the fake Remote namespace.
|
||||
*/
|
||||
function fakeRemote(
|
||||
presets: Map<string, FakePreset>,
|
||||
defaultId: { id: string },
|
||||
options: FakeOptions = {},
|
||||
): Pick<ClientRemote, 'agentPresets'> {
|
||||
const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
|
||||
return {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
record('list', {})
|
||||
if (options.throwList === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failList !== undefined) return remoteFail(options.failList)
|
||||
return remoteOk({
|
||||
presets: [...presets].map(([id, preset]) => ({
|
||||
id, trust: preset.trust, isDefault: id === defaultId.id,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
})),
|
||||
authorable: options.authorable ?? true,
|
||||
})
|
||||
},
|
||||
read: (agentPreset: string) => {
|
||||
record('read', { agentPreset })
|
||||
if (options.throwRead === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failRead !== undefined) return remoteFail(options.failRead)
|
||||
const preset = presets.get(agentPreset)
|
||||
/* v8 ignore next -- every test reads an id the fake store holds */
|
||||
if (preset === undefined) return remoteFail(`unknown preset ${agentPreset}`)
|
||||
return remoteOk({
|
||||
agentPreset,
|
||||
trust: preset.trust,
|
||||
content: preset.content,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
})
|
||||
},
|
||||
// Arity is checked against the declaration, not against which arguments
|
||||
// carry a value, so a short call rejects instead of answering. Reject
|
||||
// one here too: the real face would, and a lenient double hid it once.
|
||||
copy: (...args: [from: string, id: string, name?: string]) => {
|
||||
if (args.length !== 3) {
|
||||
return Promise.reject(new Error(`client api: agentPresets/copy expected 3 argument(s), got ${String(args.length)}`))
|
||||
}
|
||||
const [from, id, name] = args
|
||||
record('copy', { from, id, ...name === undefined ? {} : { name } })
|
||||
if (options.throwCopy === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failCopy !== undefined) return remoteFail(options.failCopy)
|
||||
const source = presets.get(from)
|
||||
/* v8 ignore next -- every test copies a source the fake store holds */
|
||||
if (source === undefined) return remoteFail(`unknown preset ${from}`)
|
||||
presets.set(id, {
|
||||
trust: 'user',
|
||||
content: source.content,
|
||||
...name === undefined ? {} : { name },
|
||||
})
|
||||
return remoteOk(undefined)
|
||||
},
|
||||
deletePreset: async (id: string) => {
|
||||
record('deletePreset', { id })
|
||||
await options.holdRemove
|
||||
if (options.failRemove !== undefined) return await remoteFail(options.failRemove)
|
||||
presets.delete(id)
|
||||
return await remoteOk(undefined)
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<ClientRemote, 'agentPresets'>
|
||||
}
|
||||
|
||||
function seed(): Map<string, FakePreset> {
|
||||
@@ -146,8 +181,10 @@ function harness(options: FakeOptions = {}) {
|
||||
const defaultId = { id: 'standard' }
|
||||
const calls: Recorded[] = []
|
||||
let rosterChanges = 0
|
||||
const wired = { ...options, calls: options.calls ?? calls }
|
||||
const controller = new AgentPresetSectionController(
|
||||
fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
|
||||
fakeApi(defaultId, wired),
|
||||
fakeRemote(presets, defaultId, wired),
|
||||
() => { rosterChanges += 1 },
|
||||
)
|
||||
return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges }
|
||||
@@ -160,6 +197,19 @@ function copyOf(controller: AgentPresetSectionController): CopyDraft {
|
||||
}
|
||||
|
||||
describe('loading the roster', () => {
|
||||
it('still lists the roster when the opener capability cannot be read', async () => {
|
||||
const { controller } = harness({ throwDescribe: true })
|
||||
|
||||
await controller.load()
|
||||
|
||||
// The two reads are independent: a refused `host.describe` costs the
|
||||
// open-directory affordance, not the page.
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.hasDocument).toBe(false)
|
||||
expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine'])
|
||||
})
|
||||
|
||||
it('maps the roster onto rows with the capability flags', async () => {
|
||||
const { controller } = harness({ authorable: true, hasDocument: false })
|
||||
|
||||
@@ -352,7 +402,7 @@ describe('submitting a copy', () => {
|
||||
expect(state.rows.map(row => row.id)).toContain('my-copy')
|
||||
expect(rosterChanges()).toBe(1)
|
||||
expect(calls.find(call => call.method === 'copy')?.payload)
|
||||
.toEqual({ from: 'standard', agentPreset: 'my-copy', name: '我的模式' })
|
||||
.toEqual({ from: 'standard', id: 'my-copy', name: '我的模式' })
|
||||
// A preset is its files from here on, so landing in them completes the
|
||||
// copy rather than following it.
|
||||
expect(calls.find(call => call.method === 'openDocument')?.payload)
|
||||
@@ -369,7 +419,7 @@ describe('submitting a copy', () => {
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(calls.find(call => call.method === 'copy')?.payload)
|
||||
.toEqual({ from: 'standard', agentPreset: 'my-copy' })
|
||||
.toEqual({ from: 'standard', id: 'my-copy' })
|
||||
})
|
||||
|
||||
it('reveals the new directory as text where the host has no desktop', async () => {
|
||||
@@ -492,7 +542,7 @@ describe('deleting', () => {
|
||||
await controller.remove()
|
||||
|
||||
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine')
|
||||
expect(calls.some(call => call.method === 'remove')).toBe(false)
|
||||
expect(calls.some(call => call.method === 'deletePreset')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a second confirmation while one delete is in flight', async () => {
|
||||
@@ -508,7 +558,7 @@ describe('deleting', () => {
|
||||
release()
|
||||
await removal
|
||||
|
||||
expect(calls.filter(call => call.method === 'remove')).toHaveLength(1)
|
||||
expect(calls.filter(call => call.method === 'deletePreset')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces a refusal and clears the confirmation', async () => {
|
||||
@@ -528,13 +578,15 @@ describe('deleting', () => {
|
||||
const { controller, presets } = harness()
|
||||
await controller.load()
|
||||
presets.clear()
|
||||
const broken = new AgentPresetSectionController({
|
||||
agentPresets: {
|
||||
list: () => Promise.reject(new Error('gone')),
|
||||
remove: () => Promise.reject(new Error('socket closed')),
|
||||
},
|
||||
settings: {},
|
||||
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>)
|
||||
const broken = new AgentPresetSectionController(
|
||||
{ agentPresets: {}, settings: {}, host: {} } as unknown as Pick<IApiClient, 'agentPresets' | 'settings' | 'host'>,
|
||||
{
|
||||
agentPresets: {
|
||||
list: () => Promise.reject(new Error('gone')),
|
||||
deletePreset: () => Promise.reject(new Error('socket closed')),
|
||||
},
|
||||
} as unknown as Pick<ClientRemote, 'agentPresets'>,
|
||||
)
|
||||
broken.confirmDelete('mine')
|
||||
|
||||
await broken.remove()
|
||||
@@ -548,7 +600,9 @@ describe('a controller with no roster listener', () => {
|
||||
// The rosterChanged callback is optional wiring, not a requirement: a
|
||||
// page composed without sibling surfaces still deletes cleanly.
|
||||
const presets = seed()
|
||||
const alone = new AgentPresetSectionController(fakeApi(presets, { id: 'standard' }))
|
||||
const defaultId = { id: 'standard' }
|
||||
const alone = new AgentPresetSectionController(
|
||||
fakeApi(defaultId), fakeRemote(presets, defaultId))
|
||||
await alone.load()
|
||||
alone.confirmDelete('mine')
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -14,9 +14,15 @@ import {
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
|
||||
} from '../src/client/settings-store.ts'
|
||||
|
||||
/** The two faces the row reads: the roster Remote and the settings wire. */
|
||||
interface FakeWire {
|
||||
api: IApiClient
|
||||
remote: Pick<ClientRemote, 'agentPresets'>
|
||||
}
|
||||
|
||||
/** Controller over a real mirror derived from the same fake wire. */
|
||||
function derivedController(api: IApiClient) {
|
||||
return new AgentPresetSettingsController(api, new SettingsDescribeMirror(api))
|
||||
function derivedController(wire: FakeWire) {
|
||||
return new AgentPresetSettingsController(wire.api, wire.remote, new SettingsDescribeMirror(wire.api))
|
||||
}
|
||||
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
|
||||
|
||||
@@ -24,7 +30,27 @@ type SeatSession = Pick<SessionSummary, 'id' | 'blank' | 'projectionValues'>
|
||||
|
||||
interface Recorded { ns: string; patch: unknown }
|
||||
|
||||
/** A client whose roster and write outcome the test controls. */
|
||||
/** A roster Remote answering a fixed set of rows, or refusing. */
|
||||
function fakeRoster(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
options: { failList?: string; failListCode?: string; throwOnList?: boolean } = {},
|
||||
): Pick<ClientRemote, 'agentPresets'> {
|
||||
return {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
if (options.throwOnList === true) return Promise.reject(new Error('socket closed'))
|
||||
return Promise.resolve(options.failList === undefined
|
||||
? { ok: true as const, value: { presets, authorable: true } }
|
||||
: {
|
||||
ok: false as const,
|
||||
error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} },
|
||||
})
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<ClientRemote, 'agentPresets'>
|
||||
}
|
||||
|
||||
/** A wire whose roster and write outcome the test controls. */
|
||||
function fakeApi(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
options: {
|
||||
@@ -34,13 +60,8 @@ function fakeApi(
|
||||
failWriteWith?: Error
|
||||
readOnly?: boolean
|
||||
} = {},
|
||||
): IApiClient {
|
||||
return {
|
||||
agentPresets: {
|
||||
list: () => Promise.resolve(options.failList === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }),
|
||||
},
|
||||
): FakeWire {
|
||||
const api = {
|
||||
settings: {
|
||||
// Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false
|
||||
// and the row disables its control instead of offering a refused write.
|
||||
@@ -65,6 +86,10 @@ function fakeApi(
|
||||
},
|
||||
},
|
||||
} as unknown as IApiClient
|
||||
return {
|
||||
api,
|
||||
remote: fakeRoster(presets, options.failList === undefined ? {} : { failList: options.failList }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('the agent-preset settings controller', () => {
|
||||
@@ -138,6 +163,20 @@ describe('the agent-preset settings controller', () => {
|
||||
expect(controller.store.getSnapshot().error).toBeNull()
|
||||
})
|
||||
|
||||
it('treats an unavailable optional namespace as an empty roster', async () => {
|
||||
const controller = derivedController({
|
||||
api: {} as IApiClient,
|
||||
remote: fakeRoster([], {
|
||||
failList: 'no active Remote method exports this endpoint',
|
||||
failListCode: 'invocation-unavailable',
|
||||
}),
|
||||
})
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: null, options: [] })
|
||||
})
|
||||
|
||||
it('writes only the default field, into the agent-presets namespace', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = derivedController(fakeApi([
|
||||
@@ -221,8 +260,9 @@ describe('the agent-preset settings controller', () => {
|
||||
|
||||
it('reports a transport that rejects rather than answering', async () => {
|
||||
const controller = derivedController({
|
||||
agentPresets: { list: () => Promise.reject(new Error('socket closed')) },
|
||||
} as unknown as IApiClient)
|
||||
api: {} as IApiClient,
|
||||
remote: fakeRoster([], { throwOnList: true }),
|
||||
})
|
||||
|
||||
await controller.load()
|
||||
|
||||
@@ -249,27 +289,43 @@ describe('the new-session chip controller', () => {
|
||||
function chip(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
current: SeatSession | undefined | (() => SeatSession | undefined),
|
||||
options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {},
|
||||
options: {
|
||||
writes?: Recorded[]
|
||||
failSelect?: string
|
||||
failList?: string
|
||||
failListCode?: string
|
||||
throwOn?: 'list' | 'select'
|
||||
} = {},
|
||||
): AgentPresetSeatController {
|
||||
const api = {
|
||||
const remote = {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
if (options.throwOn === 'list') return Promise.reject(new Error('socket closed'))
|
||||
return Promise.resolve(options.failList === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } })
|
||||
? { ok: true as const, value: { presets, authorable: true } }
|
||||
: {
|
||||
ok: false as const,
|
||||
error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} },
|
||||
})
|
||||
},
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
select: (agentId: SessionId, agentPreset: string) => {
|
||||
if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
|
||||
options.writes?.push({ ns: 'select', patch: payload.agentPreset })
|
||||
options.writes?.push({ ns: 'select', patch: agentPreset })
|
||||
return Promise.resolve(options.failSelect === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } })
|
||||
? { ok: true as const, value: agentPreset }
|
||||
: {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: 'agent-preset-locked',
|
||||
message: options.failSelect,
|
||||
details: { sessionId: agentId, agentPreset },
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
} as unknown as IApiClient
|
||||
} as unknown as Pick<ClientRemote, 'agentPresets'>
|
||||
return new AgentPresetSeatController(
|
||||
api,
|
||||
remote,
|
||||
typeof current === 'function' ? current : () => current,
|
||||
)
|
||||
}
|
||||
@@ -325,6 +381,17 @@ describe('the new-session chip controller', () => {
|
||||
expect(controller.store.getSnapshot().current).toBe('')
|
||||
})
|
||||
|
||||
it('opens on nothing when the optional namespace is unavailable', async () => {
|
||||
const controller = chip([], undefined, {
|
||||
failList: 'no active Remote method exports this endpoint',
|
||||
failListCode: 'invocation-unavailable',
|
||||
})
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ current: '', error: null, options: [] })
|
||||
})
|
||||
|
||||
it('stages a pick made before any session exists', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, undefined, { writes })
|
||||
@@ -500,18 +567,12 @@ describe('the new-session chip controller', () => {
|
||||
})
|
||||
|
||||
it('degrades to a read-only row while the mirror holds no answer', async () => {
|
||||
const api = {
|
||||
agentPresets: {
|
||||
list: () => Promise.resolve({
|
||||
rpcId: 'r',
|
||||
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
|
||||
}),
|
||||
},
|
||||
const controller = derivedController({
|
||||
// The roster answered; the mirror's read is what failed, so the row
|
||||
// shows the current default without offering a write it never confirmed.
|
||||
settings: { describe: () => Promise.reject(new Error('socket closed')) },
|
||||
} as unknown as IApiClient
|
||||
const controller = derivedController(api)
|
||||
api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as IApiClient,
|
||||
remote: fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }]),
|
||||
})
|
||||
|
||||
await controller.load()
|
||||
|
||||
|
||||
@@ -244,205 +244,6 @@ describe('a capability the session\'s preset mounts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentPreset.list', () => {
|
||||
it('marks the default and carries each preset\'s trust', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
|
||||
const response = await api.agentPresets.list(request({}))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.presets).toEqual([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'minimal', trust: 'system', isDefault: false },
|
||||
])
|
||||
expect(response.result.value.authorable).toBe(true)
|
||||
})
|
||||
|
||||
it('answers with an empty roster when the deployment composes no presets', async () => {
|
||||
const { api } = await harness()
|
||||
|
||||
const response = await api.agentPresets.list(request({}))
|
||||
|
||||
// Composing no presets is a valid deployment, not an error: every session
|
||||
// then shares the host composition and the browser offers no choice.
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.presets).toEqual([])
|
||||
// Nothing to write to either, so a surface offering "new preset" knows to
|
||||
// stay hidden rather than offering a button whose save always fails.
|
||||
expect(response.result.value.authorable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentPreset.select', () => {
|
||||
it('recomposes a blank session', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
await createSession(api, { sessionId: SessionId('sel-1'), agentPreset: 'standard' })
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-1'), agentPreset: 'minimal' }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.agentPreset).toBe('minimal')
|
||||
})
|
||||
|
||||
it('records the switch in the log', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await createSession(api, { sessionId: SessionId('sel-log'), agentPreset: 'standard' })
|
||||
|
||||
await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' }))
|
||||
|
||||
// The header is written once at creation, so the switch lives in the log —
|
||||
// this is what a restart replays and what every projection resolves from.
|
||||
// Asserting only the RPC's echo would miss a switch that never persisted.
|
||||
const session = ctx.sessions.get(SessionId('sel-log'))
|
||||
if (session === undefined) throw new Error('unreachable')
|
||||
expect(session.header.agentPreset).toBe('standard')
|
||||
expect(session.events.findLast(event => event.type === 'agent-preset/selected')?.data)
|
||||
.toEqual({ agentPreset: 'minimal' })
|
||||
})
|
||||
|
||||
it('serializes two concurrent selects on one session', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await createSession(api, { sessionId: SessionId('sel-race'), agentPreset: 'standard' })
|
||||
|
||||
// Both pass the blank check; unserialized, the second unmount finds no
|
||||
// record because the first already removed it, and two compositions end up
|
||||
// in one agent layer. The client's busy flag is not enforcement.
|
||||
const [first, second] = await Promise.all([
|
||||
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'minimal' })),
|
||||
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })),
|
||||
])
|
||||
|
||||
expect(first.result.ok).toBe(true)
|
||||
expect(second.result.ok).toBe(true)
|
||||
const session = ctx.sessions.get(SessionId('sel-race'))
|
||||
if (session === undefined) throw new Error('unreachable')
|
||||
// One winner, and the log agrees with it: the last committed switch.
|
||||
expect(session.events.findLast(event => event.type === 'agent-preset/selected')?.data)
|
||||
.toEqual({ agentPreset: 'standard' })
|
||||
})
|
||||
|
||||
it('refuses once the conversation has started', async () => {
|
||||
const { api, ctx } = await harness(['standard', 'minimal'])
|
||||
await createSession(api, { sessionId: SessionId('sel-2'), agentPreset: 'standard' })
|
||||
// One turn is enough: the history from here on was produced under
|
||||
// `standard`'s tools, and a swap would strand those tool calls.
|
||||
ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 })
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-2'), agentPreset: 'minimal' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-locked')
|
||||
})
|
||||
|
||||
it('reports an unknown preset without disturbing the session', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await createSession(api, { sessionId: SessionId('sel-3') })
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('reports a deployment that composes no presets', async () => {
|
||||
const { api } = await harness()
|
||||
await createSession(api, { sessionId: SessionId('sel-4') })
|
||||
|
||||
const response = await api.agentPresets.select(
|
||||
request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring over the wire', () => {
|
||||
it('reads a composition with its trust', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.read(request({ agentPreset: 'standard' }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
// The shipped set is readable: it is the known-good composition a copy
|
||||
// starts from, and trust is what tells a surface to say so.
|
||||
expect(response.result.value.trust).toBe('system')
|
||||
expect(response.result.value.content).toContain('- id: x')
|
||||
})
|
||||
|
||||
it('copies a preset under a new id', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.copy(
|
||||
request({ from: 'standard', agentPreset: 'mine', name: '我的模式' }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.agentPreset).toBe('mine')
|
||||
})
|
||||
|
||||
it('rejects a copy target that could escape the preset root', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: '../escape' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-invalid')
|
||||
})
|
||||
|
||||
it('rejects a copy target the roster already supplies', async () => {
|
||||
const { api } = await harness(['standard', 'minimal'])
|
||||
|
||||
const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: 'minimal' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-invalid')
|
||||
expect(response.result.error.message).toMatch(/already exists/)
|
||||
})
|
||||
|
||||
it('rejects a copy whose source is unknown', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.copy(request({ from: 'never-existed', agentPreset: 'mine' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('reports a deployment that composes no presets', async () => {
|
||||
const { api } = await harness()
|
||||
|
||||
const response = await api.agentPresets.read(request({ agentPreset: 'anything' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('reports an unknown id on delete rather than succeeding silently', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.remove(request({ agentPreset: 'never-existed' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('opening a preset directory', () => {
|
||||
it('hands the resolved directory to the native opener', async () => {
|
||||
const opened: string[] = []
|
||||
@@ -492,7 +293,7 @@ describe('opening a preset directory', () => {
|
||||
expect(opened).toEqual([])
|
||||
})
|
||||
|
||||
it('reports the roster capability on list', async () => {
|
||||
it('reports the opener capability on host.describe', async () => {
|
||||
const openable = await harness(['standard'], {
|
||||
defaults: { canOpenPath: () => true },
|
||||
})
|
||||
@@ -500,11 +301,13 @@ describe('opening a preset directory', () => {
|
||||
defaults: { canOpenPath: () => false },
|
||||
})
|
||||
|
||||
const yes = await openable.api.agentPresets.list(request({}))
|
||||
const no = await headless.api.agentPresets.list(request({}))
|
||||
// The capability a surface joins onto the roster to decide between opening
|
||||
// a preset directory and showing its path as text.
|
||||
const yes = await openable.api.host.describe(request({}))
|
||||
const no = await headless.api.host.describe(request({}))
|
||||
|
||||
expect(yes.result.ok && yes.result.value.hasDocument).toBe(true)
|
||||
expect(no.result.ok && no.result.value.hasDocument).toBe(false)
|
||||
expect(yes.result.ok && yes.result.value.canOpenPath).toBe(true)
|
||||
expect(no.result.ok && no.result.value.canOpenPath).toBe(false)
|
||||
})
|
||||
|
||||
it('counts an injected opener as openable', async () => {
|
||||
@@ -512,9 +315,9 @@ describe('opening a preset directory', () => {
|
||||
defaults: { openPath: () => Promise.resolve() },
|
||||
})
|
||||
|
||||
const response = await api.agentPresets.list(request({}))
|
||||
const response = await api.host.describe(request({}))
|
||||
|
||||
expect(response.result.ok && response.result.value.hasDocument).toBe(true)
|
||||
expect(response.result.ok && response.result.value.canOpenPath).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -47,12 +47,7 @@ function scriptedApi(overrides: {
|
||||
},
|
||||
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
|
||||
agentPresets: {
|
||||
list: r => ok(r, { presets: [], authorable: false, hasDocument: false }),
|
||||
select: r => ok(r, { agentPreset: r.payload.agentPreset }),
|
||||
read: r => ok(r, { agentPreset: r.payload.agentPreset, trust: 'user' as const, content: '' }),
|
||||
copy: r => ok(r, { agentPreset: r.payload.agentPreset }),
|
||||
openDocument: r => ok(r, { opened: true as const }),
|
||||
remove: r => ok(r, {}),
|
||||
...overrides.agentPresets,
|
||||
},
|
||||
settings: {
|
||||
@@ -115,16 +110,9 @@ describe('unary round trip', () => {
|
||||
expect(response.result).toMatchObject({ ok: true, value: { version: '0-test' } })
|
||||
})
|
||||
|
||||
it('routes the agent-preset roster and switch through the wire', async () => {
|
||||
const c = client(scriptedApi())
|
||||
|
||||
const listed = await c.agentPresets.list({})
|
||||
expect(listed.result).toEqual({ ok: true, value: { presets: [], authorable: false, hasDocument: false } })
|
||||
|
||||
// The switch carries the session it is about: the host refuses one whose
|
||||
// conversation has started, and it can only know which by id.
|
||||
const selected = await c.agentPresets.select({ sessionId: sid('s1'), agentPreset: 'standard' })
|
||||
expect(selected.result).toEqual({ ok: true, value: { agentPreset: 'standard' } })
|
||||
it('routes the agent-preset document opener through the wire', async () => {
|
||||
const opened = await client(scriptedApi()).agentPresets.openDocument({ agentPreset: 'mine' })
|
||||
expect(opened.result).toEqual({ ok: true, value: { opened: true } })
|
||||
})
|
||||
|
||||
it('passes business errors through as 200 + err result, not a throw', async () => {
|
||||
|
||||
@@ -57,30 +57,9 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy {
|
||||
},
|
||||
},
|
||||
agentPresets: {
|
||||
list(request: RpcRequest<{}>) {
|
||||
return Promise.resolve({
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true as const, value: { presets: [], authorable: false, hasDocument: false } },
|
||||
})
|
||||
},
|
||||
select(request: RpcRequest<{ agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
read(request: RpcRequest<{ agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset, trust: 'user' as const, content: '' }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
copy(request: RpcRequest<{ from: string; agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
openDocument(request: RpcRequest<{ agentPreset: string }>) {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { opened: true as const } } })
|
||||
},
|
||||
remove(request: RpcRequest<{ agentPreset: string }>) {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: {} } })
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
async list(request) {
|
||||
@@ -162,26 +141,12 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('settings-rejected')
|
||||
})
|
||||
|
||||
it('round-trips every agent-preset method, authoring included', async () => {
|
||||
const c = client()
|
||||
|
||||
// The whole domain crosses the carrier: the roster a picker reads, the
|
||||
// per-session switch, and the authoring calls the settings page makes.
|
||||
// Each has its own request schema, so a registration missing from either
|
||||
// half fails here rather than in the browser.
|
||||
expect((await c.agentPresets.list({})).result).toEqual({
|
||||
ok: true, value: { presets: [], authorable: false, hasDocument: false },
|
||||
})
|
||||
expect((await c.agentPresets.select({ sessionId: 's' as never, agentPreset: 'minimal' })).result)
|
||||
.toEqual({ ok: true, value: { agentPreset: 'minimal' } })
|
||||
expect((await c.agentPresets.read({ agentPreset: 'mine' })).result).toEqual({
|
||||
ok: true, value: { agentPreset: 'mine', trust: 'user', content: '' },
|
||||
})
|
||||
expect((await c.agentPresets.copy({ from: 'standard', agentPreset: 'mine' })).result)
|
||||
.toEqual({ ok: true, value: { agentPreset: 'mine' } })
|
||||
expect((await c.agentPresets.openDocument({ agentPreset: 'mine' })).result)
|
||||
it('round-trips the agent-preset document opener', async () => {
|
||||
// The opener is the domain's whole carried surface: its request schema is
|
||||
// registered in both halves, so a missing registration fails here rather
|
||||
// than in the browser.
|
||||
expect((await client().agentPresets.openDocument({ agentPreset: 'mine' })).result)
|
||||
.toEqual({ ok: true, value: { opened: true } })
|
||||
expect((await c.agentPresets.remove({ agentPreset: 'mine' })).result).toEqual({ ok: true, value: {} })
|
||||
})
|
||||
|
||||
it('round-trips the native picker without the default unary timeout', async () => {
|
||||
|
||||
@@ -318,4 +318,10 @@ describe('canOpenNativePath', () => {
|
||||
|
||||
expect(canOpenNativePath({ platform: 'linux', osRelease: '6.8.0-generic' })).toBe(expected)
|
||||
})
|
||||
|
||||
it('samples the ambient platform when none is named', () => {
|
||||
// The internals are a test seam; a deployment calls this with nothing and
|
||||
// must get the answer for the host it is actually running on.
|
||||
expect(canOpenNativePath()).toBe(canOpenNativePath({ platform: process.platform }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,9 +11,7 @@ import {
|
||||
hostListDirectoryRequestSchema, hostListDirectoryValueSchema,
|
||||
} from '../src/api/host.schema.ts'
|
||||
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
|
||||
} from '../src/api/agent-presets.schema.ts'
|
||||
import { agentPresetOpenDocumentValueSchema } from '../src/api/agent-presets.schema.ts'
|
||||
import { subagentPromptRequestSchema } from '../src/api/subagents.schema.ts'
|
||||
|
||||
describe('RpcId', () => {
|
||||
@@ -178,20 +176,6 @@ describe('skills domain schemas', () => {
|
||||
})
|
||||
|
||||
describe('agent-preset schemas', () => {
|
||||
it('accepts a roster row and rejects an unknown trust', () => {
|
||||
expect(agentPresetEntrySchema.parse({ id: 'standard', trust: 'system', isDefault: true }))
|
||||
.toEqual({ id: 'standard', trust: 'system', isDefault: true })
|
||||
expect(() => agentPresetEntrySchema.parse({ id: 'x', trust: 'root', isDefault: false })).toThrow()
|
||||
expect(() => agentPresetEntrySchema.parse({ id: '', trust: 'user', isDefault: false })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts an empty roster', () => {
|
||||
// A deployment composing no presets still reports its authoring and
|
||||
// native-open capabilities, so a surface knows what to offer.
|
||||
expect(agentPresetListValueSchema.parse({ presets: [], authorable: false, hasDocument: false }))
|
||||
.toEqual({ presets: [], authorable: false, hasDocument: false })
|
||||
})
|
||||
|
||||
it('answers the open-document union by its discriminant', () => {
|
||||
expect(agentPresetOpenDocumentValueSchema.parse({ opened: true })).toEqual({ opened: true })
|
||||
expect(agentPresetOpenDocumentValueSchema.parse({ opened: false, path: '/presets/mine' }))
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* The agent-preset Remote namespace: the path-free roster a client reads, the
|
||||
* composition view behind the read-only viewer, and the per-session switch —
|
||||
* which is the only one of the three that mutates an agent.
|
||||
*/
|
||||
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { TypertRemoteFailure, type RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentPresets, { COMPOSITION_FILE, METADATA_FILE } from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const ROOTS = [
|
||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||
{ path: join(FIXTURES, 'user'), trust: 'user' as const },
|
||||
]
|
||||
const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
async function remoteFailure(operation: Promise<unknown>): Promise<RemoteFailure> {
|
||||
try {
|
||||
await operation
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(TypertRemoteFailure)
|
||||
if (error instanceof TypertRemoteFailure) return error.failure
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected the Remote operation to fail')
|
||||
}
|
||||
|
||||
function availableOf(failure: RemoteFailure): readonly string[] {
|
||||
if (!('available' in failure.details)) throw new Error('expected available preset ids')
|
||||
const available: unknown = failure.details.available
|
||||
if (!Array.isArray(available)
|
||||
|| !available.every((value: unknown): value is string => typeof value === 'string')) {
|
||||
throw new Error('expected available preset ids')
|
||||
}
|
||||
return available
|
||||
}
|
||||
|
||||
function reasonOf(failure: RemoteFailure): string {
|
||||
if (!('reason' in failure.details)) throw new Error('expected a preset failure reason')
|
||||
const reason: unknown = failure.details.reason
|
||||
if (typeof reason !== 'string') throw new Error('expected a preset failure reason')
|
||||
return reason
|
||||
}
|
||||
|
||||
async function harness(
|
||||
roster: Config = { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false },
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(AgentPresets, roster)
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function agentOn(ctx: Context, id: string, presetId?: string): Promise<Agent> {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId(id),
|
||||
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, presetId),
|
||||
})
|
||||
return handle.agent
|
||||
}
|
||||
|
||||
/** The recorded preset a restart replays, which is what a switch must move. */
|
||||
const recordedPreset = (agent: Agent): unknown =>
|
||||
agent.session.events.findLast(event => event.type === 'agent-preset/selected')?.data
|
||||
|
||||
describe('the roster a client reads', () => {
|
||||
it('projects path-free rows, marking the default and carrying published metadata', async () => {
|
||||
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
|
||||
await mkdir(join(userRoot, 'documented'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID)
|
||||
await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n')
|
||||
const ctx = await harness({
|
||||
default: 'minimal',
|
||||
roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }],
|
||||
includeShippedRoot: false,
|
||||
includeUserRoot: false,
|
||||
})
|
||||
|
||||
const roster = await ctx.agentPresets.remoteExportList()
|
||||
|
||||
expect(roster.authorable).toBe(true)
|
||||
expect(roster.presets).toEqual([
|
||||
{ id: 'minimal', trust: 'system', isDefault: true },
|
||||
{ id: 'standard', trust: 'system', isDefault: false },
|
||||
{ id: 'documented', trust: 'user', isDefault: false, name: '我的模式', description: '只做检索。' },
|
||||
])
|
||||
// No row carries the composition's location: a preset is addressed by id
|
||||
// everywhere off the Host.
|
||||
expect(roster.presets.every(row => !('path' in row))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a broken preset on the roster with its reason', async () => {
|
||||
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
|
||||
await mkdir(join(userRoot, 'damaged'), { recursive: true })
|
||||
const ctx = await harness({
|
||||
default: 'standard',
|
||||
roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }],
|
||||
includeShippedRoot: false,
|
||||
includeUserRoot: false,
|
||||
})
|
||||
|
||||
const roster = await ctx.agentPresets.remoteExportList()
|
||||
|
||||
// The directory still occupies the id, so a surface must be able to show
|
||||
// and delete it; offering it for selection is what the reason prevents.
|
||||
expect(roster.presets.find(row => row.id === 'damaged')?.broken).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('answers an empty roster with nothing authorable', async () => {
|
||||
const ctx = await harness({ default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false })
|
||||
|
||||
const roster = await ctx.agentPresets.remoteExportList()
|
||||
|
||||
// Composing no presets is a valid deployment: every session then shares
|
||||
// the host composition, and nothing can be written either.
|
||||
expect(roster).toEqual({ presets: [], authorable: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reading one composition', () => {
|
||||
it('rejects an empty id before resolving it', async () => {
|
||||
const ctx = await harness()
|
||||
const resolve = vi.spyOn(ctx.agentPresets, 'resolve')
|
||||
|
||||
await expect(ctx.agentPresets.readDocument(''))
|
||||
.rejects.toMatchObject({ failure: { code: 'bad-request' } })
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers the stored text with the row it belongs to', async () => {
|
||||
const ctx = await harness()
|
||||
|
||||
const document = await ctx.agentPresets.readDocument('standard')
|
||||
|
||||
// The shipped set is readable: it is the known-good composition a copy
|
||||
// starts from, and trust is what tells a surface to say so.
|
||||
expect(document).toEqual({
|
||||
agentPreset: 'standard',
|
||||
trust: 'system',
|
||||
content: await ctx.agentPresets.read('standard'),
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the display metadata a preset published', async () => {
|
||||
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
|
||||
await mkdir(join(userRoot, 'documented'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID)
|
||||
await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n')
|
||||
const ctx = await harness({
|
||||
default: 'documented',
|
||||
roots: [{ path: userRoot, trust: 'user' }],
|
||||
includeShippedRoot: false,
|
||||
includeUserRoot: false,
|
||||
})
|
||||
|
||||
const document = await ctx.agentPresets.readDocument('documented')
|
||||
|
||||
// The viewer titles the dialog from the published name, so both optional
|
||||
// fields have to survive the projection rather than only the id.
|
||||
expect(document).toEqual({
|
||||
agentPreset: 'documented',
|
||||
trust: 'user',
|
||||
content: VALID,
|
||||
name: '我的模式',
|
||||
description: '只做检索。',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses an id no root supplies', async () => {
|
||||
const ctx = await harness()
|
||||
|
||||
const failure = await remoteFailure(ctx.agentPresets.readDocument('never-existed'))
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
code: 'agent-preset-not-found',
|
||||
details: {
|
||||
agentPreset: 'never-existed',
|
||||
},
|
||||
})
|
||||
expect(failure.message)
|
||||
.toMatch(/^agent-presets: preset "never-existed" not found \(available: .+\)$/)
|
||||
expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard']))
|
||||
})
|
||||
|
||||
it('keeps the legacy internal diagnostic for an unrelated read failure', async () => {
|
||||
const ctx = await harness()
|
||||
vi.spyOn(ctx.agentPresets, 'read').mockRejectedValueOnce(new Error('disk failed'))
|
||||
|
||||
const failure = await remoteFailure(ctx.agentPresets.readDocument('standard'))
|
||||
|
||||
expect(failure).toEqual({
|
||||
code: 'internal',
|
||||
message: 'agent preset "standard": Error: disk failed',
|
||||
details: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring over Remote', () => {
|
||||
it('rejects empty source, target, and delete ids before authoring', async () => {
|
||||
const ctx = await harness()
|
||||
const copy = vi.spyOn(ctx.agentPresets, 'copy')
|
||||
const remove = vi.spyOn(ctx.agentPresets, 'remove')
|
||||
|
||||
for (const operation of [
|
||||
() => ctx.agentPresets.remoteExportCopy('', 'mine'),
|
||||
() => ctx.agentPresets.remoteExportCopy('standard', ''),
|
||||
() => ctx.agentPresets.remoteExportDelete(''),
|
||||
]) {
|
||||
await expect(operation()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
|
||||
}
|
||||
expect(copy).not.toHaveBeenCalled()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies and deletes through the Remote adapters', async () => {
|
||||
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
|
||||
const ctx = await harness({
|
||||
default: 'standard',
|
||||
roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }],
|
||||
includeShippedRoot: false,
|
||||
includeUserRoot: false,
|
||||
})
|
||||
|
||||
await ctx.agentPresets.remoteExportCopy('standard', 'mine', '我的模式')
|
||||
expect((await ctx.agentPresets.resolve('mine')).name).toBe('我的模式')
|
||||
|
||||
await ctx.agentPresets.remoteExportDelete('mine')
|
||||
await expect(ctx.agentPresets.resolve('mine')).rejects.toThrow(/not found/)
|
||||
})
|
||||
|
||||
it('preserves not-found details for an unknown copy source', async () => {
|
||||
const ctx = await harness()
|
||||
|
||||
const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('never-existed', 'mine'))
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
code: 'agent-preset-not-found',
|
||||
details: {
|
||||
agentPreset: 'never-existed',
|
||||
},
|
||||
})
|
||||
expect(failure.message)
|
||||
.toMatch(/^agent-presets: preset "never-existed" not found \(available: .+\)$/)
|
||||
expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard']))
|
||||
})
|
||||
|
||||
it('preserves invalid-id and occupied-id failures', async () => {
|
||||
const ctx = await harness()
|
||||
|
||||
const invalid = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', '../escape'))
|
||||
expect(invalid).toMatchObject({
|
||||
code: 'agent-preset-invalid',
|
||||
details: { agentPreset: '../escape' },
|
||||
})
|
||||
expect(reasonOf(invalid)).toContain('must match')
|
||||
|
||||
const occupied = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'minimal'))
|
||||
expect(occupied).toMatchObject({
|
||||
code: 'agent-preset-invalid',
|
||||
details: { agentPreset: 'minimal' },
|
||||
})
|
||||
expect(reasonOf(occupied)).toContain('already exists')
|
||||
})
|
||||
|
||||
it('keeps the requested id when no writable root exists', async () => {
|
||||
const ctx = await harness({
|
||||
default: 'standard',
|
||||
roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }],
|
||||
includeShippedRoot: false,
|
||||
includeUserRoot: false,
|
||||
})
|
||||
|
||||
const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'mine'))
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
code: 'agent-preset-read-only',
|
||||
details: { agentPreset: 'mine' },
|
||||
})
|
||||
expect(reasonOf(failure)).toContain('no user-writable preset root')
|
||||
})
|
||||
|
||||
it('preserves read-only and not-found delete failures', async () => {
|
||||
const ctx = await harness()
|
||||
|
||||
const readOnly = await remoteFailure(ctx.agentPresets.remoteExportDelete('standard'))
|
||||
expect(readOnly).toMatchObject({
|
||||
code: 'agent-preset-read-only',
|
||||
details: { agentPreset: 'standard' },
|
||||
})
|
||||
expect(reasonOf(readOnly)).toContain('ships with the deployment')
|
||||
|
||||
const missing = await remoteFailure(ctx.agentPresets.remoteExportDelete('never-existed'))
|
||||
expect(missing).toMatchObject({
|
||||
code: 'agent-preset-not-found',
|
||||
details: { agentPreset: 'never-existed' },
|
||||
})
|
||||
expect(availableOf(missing)).toEqual(expect.arrayContaining(['minimal', 'standard']))
|
||||
})
|
||||
|
||||
it('keeps the legacy internal diagnostic for an unrelated authoring failure', async () => {
|
||||
const ctx = await harness()
|
||||
vi.spyOn(ctx.agentPresets, 'copy').mockRejectedValueOnce(new Error('copy failed'))
|
||||
|
||||
const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'mine'))
|
||||
|
||||
expect(failure).toEqual({
|
||||
code: 'internal',
|
||||
message: 'agent preset "mine": Error: copy failed',
|
||||
details: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('switching one session\'s composition', () => {
|
||||
it('rejects an empty preset id before queuing a switch', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = await agentOn(ctx, 'sel-empty', 'standard')
|
||||
const recompose = vi.spyOn(ctx.agentPresets, 'recompose')
|
||||
|
||||
await expect(ctx.agentPresets.select(agent, ''))
|
||||
.rejects.toMatchObject({ failure: { code: 'bad-request' } })
|
||||
expect(recompose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recomposes a blank session and records what it now runs', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = await agentOn(ctx, 'sel-1', 'standard')
|
||||
|
||||
expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal')
|
||||
|
||||
// The header is written once at creation, so the switch lives in the log:
|
||||
// that is what a restart replays and what every projection resolves from.
|
||||
expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('minimal')
|
||||
expect(recordedPreset(agent)).toEqual({ agentPreset: 'minimal' })
|
||||
})
|
||||
|
||||
it('serializes two concurrent switches on one session', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = await agentOn(ctx, 'sel-race', 'standard')
|
||||
|
||||
// Both pass the blank check; unserialized, the second re-link finds the
|
||||
// record the first already replaced and two compositions end up in one
|
||||
// agent layer. A client's busy flag is not enforcement.
|
||||
await Promise.all([
|
||||
ctx.agentPresets.select(agent, 'minimal'),
|
||||
ctx.agentPresets.select(agent, 'standard'),
|
||||
])
|
||||
|
||||
// One winner, and the log agrees with it: the last committed switch.
|
||||
expect(recordedPreset(agent)).toEqual({ agentPreset: 'standard' })
|
||||
expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('standard')
|
||||
})
|
||||
|
||||
it('refuses once the conversation has started', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = await agentOn(ctx, 'sel-locked', 'standard')
|
||||
// One turn is enough: the history from here on was produced under
|
||||
// `standard`'s tools, and a swap would strand those tool calls.
|
||||
agent.session.append('turn/start', { turn: 0 })
|
||||
|
||||
const failure = await remoteFailure(ctx.agentPresets.select(agent, 'minimal'))
|
||||
|
||||
expect(failure).toEqual({
|
||||
code: 'agent-preset-locked',
|
||||
message: 'session "sel-locked" has already started; its agent preset is fixed',
|
||||
details: { sessionId: SessionId('sel-locked'), agentPreset: 'minimal' },
|
||||
})
|
||||
expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('standard')
|
||||
expect(recordedPreset(agent)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves the session on its composition when the named preset is unknown', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = await agentOn(ctx, 'sel-unknown', 'standard')
|
||||
|
||||
const failure = await remoteFailure(ctx.agentPresets.select(agent, 'nope'))
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
code: 'agent-preset-not-found',
|
||||
details: { agentPreset: 'nope' },
|
||||
})
|
||||
expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard']))
|
||||
// Resolution happens before any re-link, and nothing is recorded until the
|
||||
// swap commits.
|
||||
expect(ctx.agentPresets.composedPreset(agent.ctx)).toBe('standard')
|
||||
expect(recordedPreset(agent)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('serves a later switch after one was refused', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = await agentOn(ctx, 'sel-after-failure', 'standard')
|
||||
|
||||
await expect(ctx.agentPresets.select(agent, 'nope')).rejects.toThrow(/not found/)
|
||||
|
||||
// The queue holds a failure-swallowing guard, so a refused switch does not
|
||||
// reject the next caller's chain.
|
||||
expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal')
|
||||
})
|
||||
|
||||
it('reports an unusable composition with its discovery reason', async () => {
|
||||
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
|
||||
await mkdir(join(userRoot, 'damaged'), { recursive: true })
|
||||
const ctx = await harness({
|
||||
default: 'standard',
|
||||
roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }],
|
||||
includeShippedRoot: false,
|
||||
includeUserRoot: false,
|
||||
})
|
||||
const agent = await agentOn(ctx, 'sel-broken', 'standard')
|
||||
|
||||
const failure = await remoteFailure(ctx.agentPresets.select(agent, 'damaged'))
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
code: 'agent-preset-invalid',
|
||||
details: { agentPreset: 'damaged' },
|
||||
})
|
||||
expect(reasonOf(failure)).not.toBe('')
|
||||
})
|
||||
|
||||
it('keeps the legacy internal diagnostic for an unrelated switch failure', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = await agentOn(ctx, 'sel-internal', 'standard')
|
||||
vi.spyOn(ctx.agentPresets, 'recompose').mockRejectedValueOnce(new Error('mount failed'))
|
||||
|
||||
const failure = await remoteFailure(ctx.agentPresets.select(agent, 'minimal'))
|
||||
|
||||
expect(failure).toEqual({
|
||||
code: 'internal',
|
||||
message: 'failed to select agent preset "minimal": Error: mount failed',
|
||||
details: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user