Files
deepseek-harness/packages/client/connection/tests/fake-api.ts
Yichen Jiang 6dfc568ec2 feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.

All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.

Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.

Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.

Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
2026-08-07 00:41:50 +08:00

304 lines
16 KiB
TypeScript

// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
export interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
reject(error: unknown): void
}
/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let nextRpc = 0
export function ok<T>(value: T): RpcResponse<T> {
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
}
type StreamItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' } | { kind: 'fail'; error: unknown }
interface StreamConn<F> {
feed(item: StreamItem<F>): void
}
export class FakeApiClient implements IApiClient {
/** Chronological call record: [method, payload]. */
readonly calls: { method: string; payload: unknown }[] = []
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: { provider: 'deepseek-official', model: 'deepseek-chat' },
groups: [],
failures: [],
}))
onSelectModel: (payload: ModelTarget & { sessionId: SessionId })
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
lastSearchSignal: AbortSignal | undefined
// Parameter annotations below are local structural types on purpose: the CI
// lint lane runs without built artifacts, where IApiClient's wire types
// (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
search: (payload: unknown, signal?: AbortSignal) => {
this.lastSearchSignal = signal
return this.record('session.search', payload, this.onSearch(payload))
},
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({
entries: [],
parentAvailable: true,
}))),
history: (payload: unknown) => this.record('subagent.history', payload, Promise.resolve(ok({
events: [],
hasMore: false,
}))),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
messageId: 'fake-message' as never,
}))),
}
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
}
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [], archivedSessionIds: [] }))),
create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
created: true,
}))),
rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))),
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
archiveSession: (payload: unknown) => this.record('workspace.archiveSession', payload, Promise.resolve(ok({
archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId],
}))),
}
// Payloads stay `unknown` (lint-lane note above); response rows are the real
// wire shapes so cases can program catalogs and skill lists without casts.
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: 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: '', writable: true,
}))),
write: (payload: { agentPreset: string }) =>
this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
remove: (payload: { agentPreset: string }) =>
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
}
readonly skills: IApiClient['skills'] = {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}
readonly goals: IApiClient['goals'] = {
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
}
readonly credentials: IApiClient['credentials'] = {
describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))),
set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))),
unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))),
}
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false
/** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them.
* Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */
holdStreamOpen = false
private heldOpens: (() => void)[] = []
releaseStreamOpens(): void {
const held = this.heldOpens
this.heldOpens = []
for (const fire of held) fire()
}
readonly events: IApiClient['events'] = {
mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) =>
this.openStream(this.muxConns, signal, onOpen),
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) =>
this.openStream(this.hostConns, signal, onOpen),
}
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
}
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */
pushMux(frame: MuxFrame, rpcId?: string): void {
for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
}
pushHost(frame: HostFrame, rpcId?: string): void {
for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
}
/** End (clean close) or fail (throw) every open stream — reconnect-path material. */
endStreams(): void {
for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' })
}
failStreams(error: unknown): void {
for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error })
}
get openMuxCount(): number {
return this.muxConns.length
}
callsOf(method: string): unknown[] {
return this.calls.filter(c => c.method === method).map(c => c.payload)
}
private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
this.calls.push({ method, payload })
return response
}
private async *openStream<F>(registry: StreamConn<F>[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator<RpcRequest<F>> {
const inbox: StreamItem<F>[] = []
let wake: (() => void) | null = null
const conn: StreamConn<F> = {
feed: (item) => {
inbox.push(item)
wake?.()
},
}
registry.push(conn)
if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen)
else if (!this.suppressStreamOpen) onOpen?.()
try {
while (!signal.aborted) {
while (inbox.length > 0) {
const item = inbox.shift() as StreamItem<F>
if (item.kind === 'end') return
if (item.kind === 'fail') throw item.error
yield item.envelope
}
await new Promise<void>((resolve) => {
wake = resolve
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
wake = null
}
} finally {
registry.splice(registry.indexOf(conn), 1)
}
}
}