From c5be99838ce2f49b4e951ac5d6ef1a6515672720 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:55:26 +0800 Subject: [PATCH] test(agent-presets): cover the Remote migration --- .../tests/fake-api.client.ts | 11 - .../client/connection/src/client/fixture.ts | 153 +++--- .../connection/tests/fake-api.client.ts | 11 - .../connection/tests/node-half.host.spec.ts | 6 +- .../tests/apply.client.spec.ts | 100 ++-- .../tests/section-store.client.spec.ts | 190 +++++--- .../tests/settings-store.client.spec.ts | 127 +++-- .../tests/api-proxy-agent-preset.spec.ts | 215 +------- .../apiproxy/tests/client-handler.spec.ts | 18 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 45 +- .../apiproxy/tests/native-path-opener.spec.ts | 6 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 18 +- .../preset/agent-presets/tests/remote.spec.ts | 460 ++++++++++++++++++ 13 files changed, 838 insertions(+), 522 deletions(-) create mode 100644 packages/preset/agent-presets/tests/remote.spec.ts diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index a08af90309..b3aa0fe29d 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -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'] = { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index ce2fcdae6a..c02f0e11bc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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 { + 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 { + 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 { + 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; 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[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) diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index 1d0266b8d0..90d042f171 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -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'] = { diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 75691394ed..e1cb7abe83 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -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]) diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index f0215a0341..43f76dc2ad 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -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', }, diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index dc62f74aff..ca253c69fa 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -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 } @@ -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, defaultId: { id: string }, options: FakeOptions = {}, -): Pick { +): Pick { 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 + } as unknown as Pick +} + +/** + * 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, + defaultId: { id: string }, + options: FakeOptions = {}, +): Pick { + 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 } function seed(): Map { @@ -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) + const broken = new AgentPresetSectionController( + { agentPresets: {}, settings: {}, host: {} } as unknown as Pick, + { + agentPresets: { + list: () => Promise.reject(new Error('gone')), + deletePreset: () => Promise.reject(new Error('socket closed')), + }, + } as unknown as Pick, + ) 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') diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 6d66b4ee6d..74ef828a35 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -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 +} + /** 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 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 { + 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 +} + +/** 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 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() diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 990b658c9d..76643472e0 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -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) }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 6f5fd528f0..1911eec99d 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -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 () => { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 125d2a4171..10614e8caa 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -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 () => { diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts index e1904cbcf1..cf4489f864 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -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 })) + }) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 3a821a8e3f..2644262bfa 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -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' })) diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts new file mode 100644 index 0000000000..3a8380cb02 --- /dev/null +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -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): Promise { + 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 { + 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 { + 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: {}, + }) + }) +})