feat: add the dsh-superagent research prototype

This commit is contained in:
DeepSeek Harness Team
2026-10-31 20:05:39 +08:00
commit 6337fc823c
216 changed files with 37068 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
/** Async coordination helpers for tests. */
import { vi } from 'vitest'
/** Create an externally resolvable promise. */
export function deferred<T = void>() {
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 }
}
/** Observe whether a promise has settled without awaiting it. */
export function track<T>(promise: Promise<T>) {
const state = { settled: false, error: undefined as unknown, promise }
promise.then(() => { state.settled = true }, (error: unknown) => { state.settled = true; state.error = error })
return state
}
export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
export const waitUntil = (check: () => boolean, timeout = 3000) => vi.waitFor(() => { if (!check()) throw new Error('not yet') }, { timeout, interval: 2 })
+19
View File
@@ -0,0 +1,19 @@
/** Default deployment and machine fixtures for tests. */
import type { Volatile } from '@deepseek-ai/cordis'
import type { Deployment, Machine } from '../../src/deployment.ts'
/** Mutable Volatile reference for simulating live configuration updates. */
export const reference = <T>(value: T): Volatile<T> & { set(next: T): void } => {
let current = value
return { get: () => current as never, set: next => { current = next } }
}
export const testMachine = (overrides: Partial<Machine> = {}): Machine => ({
stateDir: '/tmp/dsh-superagent-test', promptMaxChars: reference(100000), admissionContextMaxPages: reference(10),
admissionContextTimeoutMs: reference(15000), ...overrides,
})
export const testDeployment = (overrides: Partial<Deployment> = {}): Deployment => ({
...testMachine(), repository: 'acme/widgets',
github: { cli: '/bin/false', login: 'agent-bot', noreply: '7+agent-bot@users.noreply.github.com' },
feishu: { cli: '/bin/false', botId: 'ou_bot', appId: 'cli_bot', brand: 'feishu', name: 'Widgets Bot' },
...overrides,
})
+9
View File
@@ -0,0 +1,9 @@
/** The environment of the git processes the tests start. */
import { GIT_LOCATION } from '../../src/infra/environment.ts'
/**
* The process environment with `extra`, and without the variables that tell git which repository to act on (`GIT_LOCATION`):
* the tests run inside `git rebase --exec` inherit GIT_DIR, and a `git init` there would reinitialize the repository under test.
*/
export const gitEnv = (extra: Record<string, string> = {}): Record<string, string | undefined> =>
({ ...process.env, ...Object.fromEntries(GIT_LOCATION.map(name => [name, undefined])), ...extra })
+299
View File
@@ -0,0 +1,299 @@
/**
* In-memory DSH host fixture (as in tests/runtime.spec.ts) running a configured Runtime with GitHub and Feishu sources,
* allowed member Alice (`ou_alice`), listened chat `oc_team`, and `auto` eval mode. Stop it with `stop()`.
*/
import { Context } from '@deepseek-ai/cordis'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { vi, type Mock } from 'vitest'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import { Runtime } from '../../src/app/runtime.ts'
import { Store } from '../../src/app/store.ts'
import { SettingsSchema } from '../../src/policy.ts'
import { feishuKind, feishuTrigger } from '../../src/sources/feishu.ts'
import { githubKind } from '../../src/sources/github.ts'
import { Commands } from '../../src/infra/commands.ts'
import { credentialHelper, workerGitConfig } from '../../src/infra/environment.ts'
import type { Trigger } from '../../src/core/index.ts'
import { testMachine } from './deployment.ts'
export const MEMBER = 'ou_alice'
export const CHAT = 'oc_team'
/** Build a Feishu mention trigger from Alice in `oc_team`, using the digits in `id` as the timestamp offset in seconds. */
export function feishu(id: string, text: string, overrides: Record<string, unknown> = {}): Trigger {
const at = String(Date.parse('2026-09-09T00:00:00Z') + Number(id.replace(/\D/g, '') || 0) * 1000)
return feishuTrigger({ message_id: `om_${id}`, chat_id: CHAT, chat_type: 'group', sender_id: MEMBER, sender_type: 'user', content: text, create_time: at,
mentions: [{ id: 'ou_bot' }], ...overrides }, 'ou_bot', 'cli_bot')!
}
type RegisteredTool = { name: string; execute: (args: unknown, execution: { signal: AbortSignal }) => Promise<unknown> }
type FakeAgent = {
id: string; status: string; inbox: { nextTurn: UserMessage[]; nextStep: UserMessage[] }
cancel: Mock; inject: Mock<(message: UserMessage) => void>; steer: Mock<(message: UserMessage) => void>; followup: Mock<(message: UserMessage) => void>
}
/** A text message, as DSH's job tools and subagents send their notices. */
const notice = (id: string, text: string) => ({ id, role: 'user', content: [{ type: 'text', text }], source: { kind: 'tool-jobs', form: 'notice' } }) as unknown as UserMessage
type HttpHandler = (req: unknown, res: unknown) => Promise<void>
type Delivery = { sessionId: string; mode: 'queue' | 'steer'; id: string; text: string }
/**
* DSH's job registry as the Executor uses it, with DSH's job tools: the jobs a Session can see, their events, a wait and a
* kill. `run` starts a job a Session owns, `output` has one print, and `end` ends one, as its process does; an end no wait
* collected reaches its owner, which it wakes when idle, as the job tools deliver it. `released` counts the waits ends
* released.
* @param owner - the agent of a Session, which an end reaches.
*/
export function jobRegistry(owner: (sessionId: string) => FakeAgent | undefined = () => undefined) {
type Job = { id: string; owner: string; status: string; label?: string }
const jobs = new Map<string, Job>()
const listeners = new Set<{ owner: string; listener: (event: { type: string }) => void }>()
const waits = new Map<string, ((job: Job) => void)[]>()
let released = 0
const emit = (job: Job, type: string) => { for (const listener of [...listeners]) if (listener.owner === job.owner) listener.listener({ type }) }
const end = (id: string, status = 'completed') => {
const job = jobs.get(id)!
job.status = status
const waiting = waits.get(id) ?? []
waits.delete(id); released += waiting.length
for (const wake of waiting) wake({ ...job })
emit(job, 'settled')
const agent = waiting.length ? undefined : owner(job.owner)
const ended = notice(`notice-${id}`, `Job ${id} ended: ${status}`)
if (agent?.status === 'idle') agent.followup(ended)
else agent?.inject(ended)
}
const service = {
list: vi.fn((caller: string) => [...jobs.values()].filter(job => job.owner === caller)),
// A wait ends with the job, or fails once its signal aborts, which leaves the job's end uncollected.
wait: vi.fn((id: string, _timeoutMs: number, _caller: string, signal?: AbortSignal) => new Promise<Job>((resolve, reject) => {
const wake = (job: Job) => { signal?.removeEventListener('abort', abort); resolve(job) }
const abort = () => { waits.set(id, (waits.get(id) ?? []).filter(item => item !== wake)); reject(new Error('wait aborted')) }
waits.set(id, [...waits.get(id) ?? [], wake])
signal?.addEventListener('abort', abort, { once: true })
})),
// A killed job ends a moment later, as its process does, also under a test's fake timers.
kill: vi.fn((id: string) => { queueMicrotask(() => { end(id, 'killed') }); return 'requested' }) as Mock<(id: string) => string>,
events: { subscribe: (filter: { owner: string }, listener: (event: { type: string }) => void) => {
const entry = { owner: filter.owner, listener }
listeners.add(entry)
return () => { listeners.delete(entry) }
} },
}
return { service, end, output: (id: string) => { emit(jobs.get(id)!, 'output') }, get released() { return released },
run: (owner: string, label?: string) => { const id = `bash-${jobs.size + 1}`; jobs.set(id, { id, owner, status: 'running', label }); return id } }
}
/**
* DSH's subagents as the Executor uses them. `spawn` starts a child of a Session, an agent while it lives, which a drain of
* the Session's descendants refuses from then on; `settle` ends one, whose end wakes its idle parent or steers a running one
* before a wait for the parent's children returns. Releasing or interrupting a child settles it; a drain of descendants
* injects their ends. The catalog lists every child a Session had.
*/
export function subagentRuntime(agents: Map<string, FakeAgent>) {
const children = new Map<string, { parent: string; live: boolean; settled: PromiseWithResolvers<void> }>()
const closed = new Set<string>()
const settle = (id: string, wake = true) => {
const child = children.get(id)
if (!child?.live) return
child.live = false
agents.delete(id)
const parent = agents.get(child.parent), ended = notice(`settled-${id}`, `Subagent ${id} settled`)
if (!wake) parent?.inject(ended)
else if (parent?.status === 'idle') parent.followup(ended)
else parent?.steer(ended)
child.settled.resolve()
}
const service = {
waitForChildren: vi.fn(async (parent: { id: string }) => {
const live = [...children.values()].filter(child => child.parent === parent.id && child.live)
await Promise.all(live.map(child => child.settled.promise))
return live.length > 0
}),
listChildren: vi.fn(async (id: string) => [...children].filter(([, child]) => child.parent === id).map(([childId]) => ({ id: childId }))),
drainChildren: vi.fn(async (_parent: { id: string }, ids: readonly string[]) => { for (const id of ids) settle(id) }),
drainDescendants: vi.fn(async (parents: readonly { id: string }[]) => {
for (const parent of parents) {
closed.add(parent.id)
for (const [id, child] of children) if (child.parent === parent.id) settle(id, false)
}
}),
interrupt: vi.fn((id: string) => { settle(id) }),
}
const spawn = (parent: string) => {
if (closed.has(parent)) throw new Error('DRAINING: the Session admits no new subagents')
const id = `child-${children.size + 1}`
children.set(id, { parent, live: true, settled: Promise.withResolvers<void>() })
agents.set(id, { id, status: 'running', session: { header: { parentSession: parent } } } as never)
return id
}
return { service, settle, spawn, live: (id: string) => !!children.get(id)?.live }
}
/** @param seed - writes to the database before the Runtime opens it, such as records an earlier version stored. */
export async function startRuntime(seed?: (store: Store) => void) {
const directory = mkdtempSync(join(tmpdir(), 'orchestrator-host-'))
const AGENT = join(directory, 'agent-gh'), BOT = join(directory, 'lark-bot')
const repository = { default_branch: 'master', permissions: { push: true } }
vi.spyOn(Commands.prototype, 'gh').mockImplementation(async args => args[1] === 'user' ? { login: 'agent-bot' } : repository)
vi.spyOn(Commands.prototype, 'lark').mockResolvedValue({ users: [{ member_id: MEMBER, name: 'Alice' }], has_more: false })
vi.spyOn(Commands.prototype, 'git').mockResolvedValue('')
vi.spyOn(Commands.prototype, 'run').mockResolvedValue('{"ok":true,"data":{}}')
const scoped = 'git config -z --show-scope --get-regexp ^(credential\\..*|http\\..*extraheader)$'
const answers: Record<string, string> = {
[`${AGENT} api user`]: '{"login":"agent-bot","id":7}',
[`${AGENT} api repos/acme/widgets --jq {name: .full_name, push: .permissions.push}`]: '{"name":"acme/widgets","push":true}\n',
[`${BOT} auth status`]: JSON.stringify({ appId: 'cli_bot', brand: 'feishu', identities: { bot: { status: 'ready' } } }),
[`${BOT} config strict-mode`]: 'strict-mode: bot (source: profile)',
[`${BOT} api GET /open-apis/bot/v3/info --as bot --format ndjson`]: '{"bot":{"open_id":"ou_bot"},"code":0,"msg":"ok"}',
['git config -z --show-origin --get-all credential.https://github.com.helper']: `file:${workerGitConfig(directory)}\0${credentialHelper(AGENT)}\0`,
[scoped]: `global\0credential.helper\n\0global\0credential.https://github.com.helper\n${credentialHelper(AGENT)}\0`,
}
/** A prepare command (`sh -c`) waits for this while it is set. */
const hooks: { prepare?: Promise<void> } = {}
const subprocess = { spawn: vi.fn((spec: { argv: readonly string[] }) => {
const argv = [...spec.argv]
while (argv[0] === 'env' || /^[A-Z_]+=/.test(argv[0] ?? '')) argv.shift()
const reader = (text: string) => ({ readFrom: () => ({ text, nextOffset: text.length, lossy: false }) })
const exited = { exitCode: 0, signal: null }
const done = argv[0] === 'sh' && hooks.prepare ? hooks.prepare.then(() => exited) : Promise.resolve(exited)
return { done, collected: { stdout: reader(answers[argv.join(' ')] ?? ''), stderr: reader('') } }
}) }
const agents = new Map<string, FakeAgent>()
/** Each Session's next step, which takes the messages that wait for one. */
const steps = new Map<string, () => void>(), claims = new Map<string, () => void>()
const tools = new Map<string, Map<string, RegisteredTool>>()
const deliveries = vi.fn<(request: Delivery) => void>()
let http!: HttpHandler
const resolveDefault = async (id: string) => agents.has(id) ? { agent: agents.get(id) } : { error: { code: 'session/not-found' } }
const controller = {
create: vi.fn(async ({ sessionId }: { sessionId: string }) => {
if (agents.has(sessionId)) return
const agentCtx = new Context()
const sessionTools = new Map<string, RegisteredTool>()
tools.set(sessionId, sessionTools)
const register = (tool: RegisteredTool) => { sessionTools.set(tool.name, tool); return () => sessionTools.delete(tool.name) }
agentCtx.reflect.provide('tools', { register, get: () => undefined, restrict: vi.fn(() => () => {}), guard: vi.fn(() => () => {}) })
agentCtx.reflect.provide('skills', { register: () => () => {} })
agentCtx.reflect.provide('systemPrompt', { section: () => () => {} })
const inbox = { nextTurn: [] as UserMessage[], nextStep: [] as UserMessage[],
// A pending message taken out, as DSH's inbox does by its id.
remove: (id: string) => [inbox.nextTurn, inbox.nextStep].some(list => {
const at = list.findIndex(message => message.id === id)
if (at >= 0) list.splice(at, 1)
return at >= 0
}) }
// A step claims each message once, all that wait for a step and, as a turn starts, the next queued one, and takes them into
// the Session's history, unless it is `cut` off before, as a cancel in its pre-step does; DSH says each.
const taken = new Set<string>()
let turn = 0
const take = (starts: boolean, cut = false) => {
const agent = agents.get(sessionId)!
const queued = starts ? inbox.nextTurn.filter(message => !taken.has(message.id)).slice(0, 1) : []
for (const message of [...inbox.nextStep.filter(item => !taken.has(item.id)), ...queued]) {
taken.add(message.id)
ctx.emit('agent/inbox/claimed', { agent, message, turn } as never)
if (!cut) ctx.emit('session/event', session as never, { type: 'user/message', data: message } as never)
}
}
steps.set(sessionId, () => { take(false) })
claims.set(sessionId, () => { take(false, true) })
const deliver = (mode: 'queue' | 'steer') => vi.fn((message: UserMessage) => {
inbox[mode === 'queue' ? 'nextTurn' : 'nextStep'].push(message)
deliveries({ sessionId, mode, id: message.id, text: message.content.map(block => block.type === 'text' ? block.text : '').join('') })
// A message wakes an idle agent: its turn starts at once, and DSH says so, and its first step takes what waits.
const agent = agents.get(sessionId)!
if (agent.status !== 'idle') return
agent.status = 'running'
ctx.emit('agent/status', { agent, status: 'running' } as never)
turn++
take(true)
})
const session = { id: sessionId, snapshotEvents: () => [], requestHeader: () => undefined, append: vi.fn() }
let status = 'idle'
const idle: (() => void)[] = []
// A cancel drops the inbox unless kept, and the turn it stops ends a moment later, which DSH says.
const cancel = vi.fn((_cause: unknown, options?: { keepInbox?: boolean }) => {
if (!options?.keepInbox) { inbox.nextTurn.length = 0; inbox.nextStep.length = 0 }
if (status === 'idle') return
queueMicrotask(() => {
const agent = agents.get(sessionId)!
if (agent.status === 'idle') return
agent.status = 'idle'
ctx.emit('agent/status', { agent, status: 'idle' } as never)
})
})
agents.set(sessionId, { id: sessionId, ctx: agentCtx, inbox, followup: deliver('queue'), steer: deliver('steer'), inject: vi.fn(), session, cancel,
get status() { return status }, set status(next: string) { status = next; if (next === 'idle') for (const wake of idle.splice(0)) wake() },
// Once its turn ended, as DSH's does.
whenIdle: () => status === 'idle' ? Promise.resolve() : new Promise<void>(resolve => { idle.push(resolve) }) } as never)
}),
selectModel: vi.fn(async () => {}), resolveAgent: vi.fn(resolveDefault), rename: vi.fn(async () => {}),
prompt: vi.fn(() => { throw new Error('Plugin delivery must not use user RPC') }),
inspect: vi.fn(async () => ({ events: [] })),
}
const reader = { read: async () => ({ events: [] }), [Symbol.asyncDispose]: async () => {} }
const persistence = { flush: vi.fn(async () => {}), open: vi.fn(async () => reader) }
const webServer = { host: '127.0.0.1', port: 3082, register: (route: { handler: HttpHandler }) => { http = route.handler; return () => {} } }
const jobs = jobRegistry(id => agents.get(id))
const subagents = subagentRuntime(agents)
const services = { agents, llm: { resolveCallConfig: vi.fn(async (value: unknown) => value) }, sessionController: controller,
sessionPersistence: persistence, workspaceRegistry: { create: async (cwd: string) => ({ id: cwd }) }, permissionPresets: { set: () => {} }, subprocess, webServer,
jobs: jobs.service, subagents: subagents.service }
const ctx = new Context()
for (const [name, service] of Object.entries(services)) ctx.reflect.provide(name, service)
for (const cli of [AGENT, BOT]) writeFileSync(cli, '#!/bin/sh\n', { mode: 0o755 })
const seeded = new Store(join(directory, 'orchestrator.sqlite'))
const sources = { github: { enabled: true, config: { repository: 'acme/widgets', cli: AGENT } }, feishu: { enabled: true, config: { cli: BOT } } }
seeded.saveSettings(SettingsSchema.parse({ ...seeded.settings(), sources }))
seed?.(seeded)
seeded.close()
const runtime = new Runtime(ctx, testMachine({ stateDir: directory }), vi.fn())
await runtime.ready
runtime.sources.register(feishuKind); runtime.sources.register(githubKind)
const settings = runtime.store.settings()
runtime.store.saveSettings({ ...settings, eval: { ...settings.eval, mode: 'auto' },
policy: { ...settings.policy, sources: { feishu: { allowed: [{ id: MEMBER, name: 'Alice' }], syncChat: CHAT }, github: { allowed: [{ id: 'alice' }] } } },
sources: { ...sources, feishu: { enabled: true, config: { cli: BOT, chats: [{ chatId: CHAT, listen: 'all' }], directMessages: true } } } })
deliveries.mockClear()
/** Parsed JSON payloads delivered to `sessionId`. */
const relays = (sessionId: string) => deliveries.mock.calls.map(([call]) => call).filter(call => call.sessionId === sessionId)
.map(call => JSON.parse(call.text) as Record<string, unknown>)
const candidates = () => relays(runtime.masterId).filter(item => item.kind === 'candidate').map(item => item.trigger_id)
const runTool = async (sessionId: string, name: string, args: unknown) => {
const tool = tools.get(sessionId)?.get(name)
if (!tool) throw new Error(`Tool ${name} is not registered for ${sessionId}`)
return await tool.execute(args, { signal: new AbortController().signal }) as Record<string, unknown>
}
/** Route one decision with the main Session's orchestrator_route, answering its result; a refused one throws its reason. */
async function route(item: Record<string, unknown>): Promise<Record<string, unknown>> {
const { results } = await runTool(runtime.masterId, 'orchestrator_route', { decisions: [item] }) as { results: Record<string, unknown>[] }
if (results[0]!.refused) throw new Error(String(results[0]!.refused))
return results[0]!
}
async function acceptedTicket(trigger: Trigger) {
runtime.receive(trigger)
await runtime.idle()
const result = await route({ kind: 'accept', trigger_id: trigger.id, title: `Fix ${trigger.id}`, worktree_name: 'fix-regression', acknowledgement: '收到,修复回归。' })
await runtime.idle()
const ticket = runtime.store.tickets.get((result.ticket as { ticket_id: string }).ticket_id)!
return { trigger, ticket, sessionId: `worker-${ticket.id}` }
}
async function api(method: string, path: string, json?: unknown) {
let status = 0, text = ''
const chunks = json === undefined ? [] : [Buffer.from(JSON.stringify(json))]
const req = { method, url: `/orchestrator/api/${path}`, headers: { host: '127.0.0.1:3082', 'x-orchestrator-request': '1' }, async *[Symbol.asyncIterator]() { yield* chunks } }
await http(req, { writeHead: (code: number) => { status = code }, end: (data: string) => { text = data } })
return { status, body: JSON.parse(text) as Record<string, unknown> }
}
async function stop() { await runtime.stop(); vi.restoreAllMocks(); rmSync(directory, { recursive: true, force: true }) }
/** The Session's model takes its next step, which takes the messages steered in since its last. */
const step = (sessionId: string) => { steps.get(sessionId)!() }
/** A step of the worker claims what waits for it, and is cut off before the Session took it. */
const claim = (sessionId: string) => { claims.get(sessionId)!() }
return { runtime, ctx, directory, agents, tools, deliveries, relays, candidates, runTool, route, acceptedTicket, api, controller, subprocess, hooks, resolveDefault, stop,
jobs, subagents, step, claim }
}
export type Host = Awaited<ReturnType<typeof startRuntime>>
+480
View File
@@ -0,0 +1,480 @@
/**
* Typed fake management API server for panel tests. Implements every route in `src/app/api.ts` and records any
* undeclared query parameter or body mismatch in `server.misuse`, which `usePanelServer` asserts is empty after each test.
*/
import { afterEach, beforeEach, expect, vi } from 'vitest'
import { z } from 'zod'
import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { Api, ApiResponse, RouteParams } from '../../src/app/api.ts'
import { SKILL_NAME, SKILL_PREFIX, TEAM_SECTIONS, builtInBase, builtInPart, builtInParts, composePrompt, composeSkill, defaultPrompts,
environmentSection, policySection, promptParts } from '../../src/app/prompts.ts'
import type { DroppedReply, HistoryView, Problem, PromptsView, SetupView, SourceStatusRow, SourceView, StateView, ThreadPageView,
ThreadRow, TicketPageView,
TicketRow, TicketStage,
TriggerRow } from '../../src/client/api.ts'
import { SETUP_FIELDS } from '../../src/deployment.ts'
import { EvalConfigSchema, ExecutorConfigSchema, PolicyConfigSchema, SettingsSchema } from '../../src/policy.ts'
import { SetupError, type SetupCode } from '../../src/setup.ts'
import { feishuChannel } from '../../src/sources/feishu-trigger.ts'
import { githubChannel } from '../../src/sources/github-trigger.ts'
import { feishuKind } from '../../src/sources/feishu.ts'
import { githubKind } from '../../src/sources/github.ts'
import type { Health } from '../../src/sources/host.ts'
import type { SourceKind } from '../../src/sources/kind.ts'
import { testDeployment } from './deployment.ts'
export const MEMBER = 'ou_alice'
const ALLOWED: Record<string, { id: string; name?: string }[]> = { feishu: [{ id: MEMBER, name: 'Alice' }], github: [{ id: 'alice' }] }
type Actor = TriggerRow['actor']
/** Resolve an actor display label using the fixture policy's allowed list or the source kind's unnamed fallback. */
export function actor(source: string, id: string, human = true): Actor {
if (!id) return { source, id }
const entry = ALLOWED[source]?.find(item => item.id.toLowerCase() === id.toLowerCase())
const unnamed = source === 'feishu' ? feishuKind.policy?.unnamed : undefined
const name = entry ? entry.name || entry.id : unnamed ? undefined : id
return { source, id, name: name ? { zh: name, en: name } : human ? unnamed!.person : unnamed!.bot }
}
type TicketFields = Omit<TicketRow, 'stage' | 'action'>
const TICKET = {
id: 't1', title: '修复回归', state: 'open', actions: { retry: false, stop: true }, strength: 'explicit', ended: undefined,
attention: undefined, assignments: 1, inputs: 2, undelivered: 0, deliveryError: undefined, sessionId: 'worker-t1', directory: '/w/t1',
summary: 'working on it', createdAt: '2026-09-22T00:00:00Z', updatedAt: '2026-09-23T00:00:00Z',
threads: [{ key: 'github:42', source: 'github', title: '#42 Fix login', url: 'https://github.com/acme/widgets/pull/42',
facts: { number: { value: '#42' }, ci: { label: { zh: 'CI 失败', en: 'CI failed' }, value: '1', problem: true } } }],
} satisfies TicketFields
export const ticketSummary = (fields: Partial<TicketFields> = {}, stage: TicketStage = { stage: 'working' }): TicketRow =>
({ ...TICKET, ...fields, ...stage })
export const threadSummary = (fields: Partial<ThreadRow> = {}): ThreadRow => ({
key: 'feishu:oc_1:om_root', source: 'feishu', title: '这个文案好奇怪', stage: 'subscribed', latest: undefined, lastAt: '2026-09-23T00:00:00Z',
createdAt: '2026-09-22T00:00:00Z', subscriptions: 1, tickets: [{ id: 't1', title: '修复回归', state: 'open' }], evaluating: undefined,
pending: 0, pollError: undefined, url: undefined, facts: { chat: { value: '团队群' } }, ...fields,
})
type TriggerFields = Partial<Omit<TriggerRow, 'disposition' | 'actions'>> & { disposition?: Partial<TriggerRow['disposition']> }
/** Build a trigger row on the default thread and compute `actions.requeue` using the host's eligibility rules. */
export function trigger(id: string, at: string, { disposition, ...fields }: TriggerFields = {}): TriggerRow {
const row = { id, source: 'feishu', actor: actor('feishu', MEMBER), human: true, at, text: `message ${id}`, preview: `message ${id}`,
url: undefined, threads: ['feishu:oc_1:om_root'], ...fields,
disposition: { queued: false, evaluating: false, delivered: false, filtered: undefined, fanout: [], handbacks: [], decision: undefined,
...disposition } }
return { ...row, actions: { requeue: requeueable(row) } }
}
const requeueable = ({ human, disposition: d }: Pick<TriggerRow, 'human' | 'disposition'>) => human && !d.queued && !d.evaluating
&& (!!d.filtered || d.decision?.outcome === 'unavailable' || d.delivered || (d.fanout.length > 0 && !d.decision))
type SearchItem = ApiResponse<'GET search'>['items'][number]
export function found(id: string, at: string, { threadTitle, filtered,
...fields }: TriggerFields & { threadTitle?: string; filtered?: string } = {}): SearchItem {
const { text: _text, ...result } = trigger(id, at, fields)
return filtered === undefined ? { ...result, threadTitle } : { ...result, threadTitle: undefined, filtered }
}
export const promptVersion = (revision: number, config: PromptsView['current']['config']): PromptsView['current'] =>
({ revision, previousRevision: revision ? revision - 1 : null, savedAt: '', source: 'panel', config })
/** Build a dropped outbound reply fixture (`feishu` replies to `oc_1`/`om_root`; `github` comments on PR #42). */
export const droppedReply = (id: string, hoursAgo: number, source: 'feishu' | 'github', reason: DroppedReply['dropped']['reason'],
{ ticket, thread }: { ticket?: DroppedReply['ticket']; thread?: false } = {}): DroppedReply => ({
id, preview: `Reply ${id}`, kind: 'reply',
channel: source === 'feishu' ? feishuChannel('oc_1', 'om_root') : githubChannel(42),
dropped: { at: new Date(Date.now() - hoursAgo * 3_600_000).toISOString(), reason }, ...ticket ? { ticket } : {},
...thread === false ? {} : { thread: source === 'feishu' ? 'feishu:oc_1:om_root' : 'github:42' },
})
const dayAgo = () => new Date(Date.now() - 86_400_000).toISOString()
export const problemOf = (code: SetupCode, values: Record<string, string> = {}) => new SetupError(code, values).problem
// The kinds' configurations differ, and the fake reads them only as the records the views carry.
const KINDS = [feishuKind, githubKind] as unknown as SourceKind[]
export const MACHINE = { stateDir: '/srv/dsh-superagent', promptMaxChars: 100000, admissionContextMaxPages: 10,
admissionContextTimeoutMs: 15000 }
type Settings = ReturnType<typeof SettingsSchema.parse>
export interface PanelServer {
joined?: Record<string, string>; joining?: Record<string, string>
joinError?: string
settings: Settings; revisions: Record<string, number>
prompts: PromptsView
tickets: { open: TicketRow[]; ended: TicketRow[] }; ticketDetails: Record<string, TicketPageView>
threads: ThreadRow[]
threadPages: (key: string, query: { before?: string; around?: string }) => ThreadPageView | undefined
person: TicketRow[]; search: Pick<ApiResponse<'GET search'>, 'items' | 'threads'>; history: HistoryView
requests: { path: string; method: string; body?: unknown }[]
/** Requests with undeclared query params or invalid body presence; asserted empty after each test. */
misuse: string[]
queued: Set<string>
searching?: (query: string) => Promise<void>
setup: SetupView
health: Record<string, Health>
/** When true, all API requests return an empty 404 to simulate the host plugin being stopped. */
down: boolean
/** Count of responses not yet consumed by the client. */
pending: number
form?: ConfigForm<Record<string, unknown>> | undefined
opened: string[]
dropped: DroppedReply[]
failing: Extract<Problem, { kind: 'outgoing' }>['outgoing'][]
}
export let server: PanelServer
function resolved() {
const accounts = server.setup.accounts
return accounts ? testDeployment({ repository: accounts.repository, github: { cli: '/bin/false', login: accounts.agent, noreply: '' },
...accounts.feishu ? { feishu: { cli: '/bin/false', ...accounts.feishu } } : { feishu: undefined } }) : undefined
}
/** Default source health: paused during setup; Feishu defaults to `socket closed` unless overridden or paused by setup. */
function healthOf(name: string): Health {
if (server.setup.problem) return { state: 'paused', detail: 'Waiting for setup', code: 'setupPending' }
if (server.health[name]) return server.health[name]
if (name !== 'feishu') return { state: 'ready', detail: '' }
return server.setup.feishuProblem ? { state: 'paused', detail: server.setup.feishuProblem.detail } : { state: 'error',
detail: 'socket closed' }
}
const problemOf_ = (id: string) => [server.setup.problem, server.setup.feishuProblem].find(item => item?.place === id) ?? undefined
function sourceSettings(): SourceView[] {
return KINDS.map(kind => {
const config = kind.schema.parse(server.settings.sources[kind.kind]?.config ?? {})
const problem = problemOf_(kind.kind)
const { admission: _admission, ...part } = server.settings.policy.sources[kind.kind] ?? { allowed: [], admission: {} }
const sync = kind.policy?.sync && { label: kind.policy.sync.label, ready: kind.policy.sync.ready(kind.policy.schema.parse(part)),
health: server.health[`sync:${kind.kind}`] }
const policy = kind.policy && { fields: kind.policy.fields, explicit: kind.policy.explicit, ...kind.policy.rows
? { rows: kind.policy.rows(config) } : {}, ...sync ? { sync } : {} }
return { id: kind.kind, kind: kind.kind, title: kind.title, description: kind.description, installed: true,
value: { enabled: server.settings.sources[kind.kind]?.enabled ?? true, config },
revision: server.revisions[`source:${kind.kind}`] ?? 0,
restarts: SETUP_FIELDS[kind.kind] ?? [], fields: kind.fields, facts: kind.facts?.(resolved()) ?? {},
labels: kind.kind === 'feishu' ? { oc_team: '团队群', oc_other: '另一个群', ...server.joined } : {}, ...problem ? { problem } : {},
...policy ? { policy } : {} }
})
}
function sourceStatuses(): SourceStatusRow[] {
return KINDS.map(kind => {
const enabled = server.settings.sources[kind.kind]?.enabled ?? true
const health = kind.health.map(name => ({ name, ...healthOf(name) }))
const problem = problemOf_(kind.kind)
const state = !enabled ? 'off' : problem || health.some(item => item.state === 'error') ? 'error' : server.setup.problem ? 'setup'
: health.some(item => item.state === 'starting') ? 'starting' : health.some(item => item.state === 'paused') ? 'paused' : 'ready'
const feishu = kind.kind === 'feishu'
return { id: kind.kind, title: kind.title, installed: true, enabled, state, health, ...problem ? { problem } : {},
stats: { received: feishu ? 12 : 0, filtered: feishu ? 2 : 0, lastAt: '2026-09-23T00:00:00Z' },
filtered: feishu ? [] : [{ id: 'e9', at: '2026-09-23T00:02:00Z', actor: actor('github', 'someone'), preview: 'looks wrong',
reason: { code: 'source_disabled' } }],
dropped: server.dropped.filter(item => item.channel.source === kind.kind && item.dropped.at >= dayAgo()) }
})
}
const stateView = (): StateView => ({
setup: server.setup, load: { running: server.setup.problem ? 0 : 2, queued: 0, maxWorkers: server.settings.executor.maxWorkers },
masterId: 'master', paused: server.settings.paused,
// The panel does not read health; the service script does.
health: {},
})
const settingsView = (): ApiResponse<'GET settings'> => ({
policyVersion: 'abcdef1234567890',
blocks: { policy: { value: server.settings.policy, revision: server.revisions.policy ?? 0 },
eval: { value: server.settings.eval, revision: server.revisions.eval ?? 0 },
executor: { value: server.settings.executor, revision: server.revisions.executor ?? 0 } },
sources: sourceSettings(),
environment: { root: '/srv/dsh-superagent/work', variables: [
{ owner: 'executor', enabled: true, variables: [{ key: 'DSH_SUPERAGENT_DIR', description: { zh: '工单的执行目录',
en: 'The ticket directory' } },
{ key: 'DSH_SUPERAGENT_NAME', description: { zh: '工单短名', en: 'Short name' } }] },
{ owner: 'github', enabled: false, variables: [{ key: 'DSH_SUPERAGENT_GITHUB_REPOSITORY', description: { zh: '仓库',
en: 'Repository' } }] }] },
})
const overviewView = (): ApiResponse<'GET overview'> => {
const { problem, feishuProblem } = server.setup
const setup = problem ?? feishuProblem
return {
problems: [...setup ? [{ kind: 'setup' as const, ...setup }] : [],
...KINDS.some(kind => server.settings.sources[kind.kind]?.enabled !== false) ? [] : [{ kind: 'no-source' as const }],
...feishuProblem || server.settings.sources.feishu?.enabled === false ? [] : [{ kind: 'source-error' as const, source: { id: 'feishu',
title: { zh: '飞书', en: 'Feishu' },
health: { name: 'feishu', state: 'error' as const, detail: 'socket closed' } } }],
...server.threads.filter(thread => thread.evaluating && thread.evaluating.deadline < new Date().toISOString())
.map(thread => ({ kind: 'eval-overdue' as const, thread })),
{ kind: 'eval-unavailable' as const, decisionId: 'u1', thread: threadSummary(), reason: [{ code: 'timeout' }], triggerId: 'e3' },
...server.settings.paused ? [{ kind: 'executor-paused' as const }] : [],
// As on the host, every entry in error that is not a Source's own, a Policy sync, or the individually listed unavailable Evals is a runtime-error.
...Object.entries(server.health).filter(([name, item]) => item.state === 'error' && !KINDS.some(kind => kind.health.includes(name))
&& !name.startsWith('sync:') && name !== 'evals')
.map(([name, item]) => ({ kind: 'runtime-error' as const, name, detail: item.detail })),
...Object.values(server.settings.policy.sources).some(part => part.allowed.length) ? [] : [{ kind: 'no-allowed' as const }],
...server.failing.map(outgoing => ({ kind: 'outgoing' as const, outgoing }))],
person: server.person, executor: { running: 2, queued: 0, maxWorkers: server.settings.executor.maxWorkers, idle: 1 },
flow: { received: 12, filtered: 2, triggers: 10, judged: 5, accepted: 1, evaluating: 0,
dropped: server.dropped.filter(item => item.dropped.at >= dayAgo()).length },
policyVersion: 'abcdef1234567890',
}
}
export const acceptDecision = (): NonNullable<TicketPageView['decision']> => ({ id: 'd1', kind: 'accept', outcome: 'accept',
reason: [{ code: 'explicitRouted' }], at: '2026-09-22T00:00:00Z', strength: 'explicit', ticketId: undefined,
triggerIds: ['e1'], thread: 'feishu:oc_1:om_root', rules: [], preview: '@bot 修一下', ticketTitle: undefined })
const ticketDetail = (): TicketPageView => ({
...ticketSummary(), paused: false, decision: acceptDecision(), threads: [threadSummary()],
mandate: { strength: 'explicit', policyVersion: 'abcdef1234567890', basis: { via: 'addressed', sender: 'Alice', addressed: 'mention' },
actor: actor('feishu', MEMBER), scope: '修复登录页回归' },
basis: { facts: [{ name: 'sender', value: 'Alice' }, { name: 'addressed', value: 'mention' }], legacy: false },
environment: [{ key: 'DSH_SUPERAGENT_DIR', value: '/w/t1', description: { zh: '执行目录', en: 'Directory' }, owner: 'executor' },
{ key: 'DSH_SUPERAGENT_GITHUB_NUMBER', value: '', description: { zh: '编号', en: 'Number' }, owner: 'github' }],
channels: [{ source: 'feishu', url: 'https://applink.feishu.cn/client/chat/open?openChatId=oc_1', label: { zh: '飞书消息',
en: 'Feishu message' } },
{ source: 'github', url: 'https://github.com/acme/widgets/issues/42', label: { zh: 'GitHub #42', en: 'GitHub #42' } }],
assignments: [{ id: 'a1', state: 'running', createdAt: '2026-09-22T00:00:00Z',
startedAt: '2026-09-22T00:01:00Z',
report: { state: 'progress', summary: 'working', at: '2026-09-22T00:02:00Z' } }],
inputs: [{ id: 'i1', kind: 'trigger', triggerId: 'e2', at: '2026-09-22T00:03:00Z', delivered: false,
trigger: { id: 'e2', actor: actor('feishu', MEMBER), human: true, preview: '顺便改标题', thread: 'feishu:oc_1:om_root' } },
{ id: 'i2', kind: 'policy-changed', at: '2026-09-22T00:04:00Z', delivered: true, from: 'aaaaaaaaaaaaaaaa', to: 'bbbbbbbbbbbbbbbb' }],
})
function threadPage(key: string, { before, around }: { before?: string; around?: string }): ThreadPageView | undefined {
if (key !== threadSummary().key) return undefined
const base = { ...threadSummary(), tickets: [ticketSummary()], correlated: [{ key: 'github:42', title: 'PR 42' }], decisions: [],
url: 'https://example.test/thread', context: [] }
const decision = { id: 'd1', outcome: 'accept' as const, reason: [{ code: 'said', values: { text: 'x' } }], ticketId: 't1' }
if (before === 'cursor-1' || around === 'e1') return { ...base, timeline: [trigger('e1', '2026-09-20T00:00:00Z',
{ disposition: { decision } })], earlier: null }
return { ...base, earlier: 'cursor-1', timeline: [trigger('e3', '2026-09-23T00:00:00Z', { disposition: { fanout: ['t1'] } }),
trigger('e4', '2026-09-23T00:01:00Z', { disposition: { filtered: { code: 'not_a_member' } } })] }
}
function initial(): PanelServer {
const settings = SettingsSchema.parse({
policy: { sources: { feishu: { allowed: [{ id: 'ou_alice', name: 'Alice' }], syncChat: 'oc_team' },
github: { allowed: [{ id: 'alice' }] } },
rules: [{ id: 'copy', name: '文案修改', enabled: true, instruction: '修正明确的文案错误' }] },
sources: { feishu: { enabled: true, config: { cli: 'lark-bot', chats: [{ chatId: 'oc_team', listen: 'mentions' }],
directMessages: true } },
github: { enabled: true, config: { repository: 'acme/widgets', cli: 'gh-agent' } } },
executor: { blockedCommands: ['gh-personal'] },
})
const ended = ticketSummary({ id: 't0', title: '旧工单', state: 'ended', actions: { retry: false, stop: false },
ended: { at: '2026-09-21T00:00:00Z', by: 'human', reason: 'Human asked to stop', outcome: 'stopped' } }, { stage: 'ended' })
const reply = { source: 'feishu', url: 'https://applink.feishu.cn/client/chat/open?openChatId=oc_1', label: { zh: '飞书消息',
en: 'Feishu message' } }
const defaults = { revision: 0, previousRevision: null, savedAt: '', source: 'defaults' }
return {
health: {}, down: false, pending: 0, opened: [], requests: [], misuse: [], queued: new Set(), dropped: [], failing: [],
setup: { machine: MACHINE, problem: null, feishuProblem: null,
accounts: { repository: 'acme/widgets', agent: 'agent-bot', feishu: { appId: 'cli_bot', brand: 'feishu', botId: 'ou_bot',
name: 'Widgets Bot' } } },
settings, revisions: { policy: 3, eval: 1, executor: 0 },
prompts: { masterId: 'master', sections: TEAM_SECTIONS, skillName: SKILL_NAME.source, builtin: ['feishu', 'github'],
skillPrefix: SKILL_PREFIX, revision: 0, current: { ...defaults, config: defaultPrompts() },
defaults: defaultPrompts(), parts: Object.entries(builtInParts()).map(([key, part]) => ({ key, ...part, base: builtInBase(part.text) })),
defaultChanged: [], revisions: [{ ...defaults, changed: [] }], sessions: [], notificationPending: false, next_offset: null },
tickets: { open: [ticketSummary()], ended: [ended] }, ticketDetails: { t1: ticketDetail() },
threads: [threadSummary()], threadPages: threadPage,
person: [ticketSummary({ attention: { id: 'q1', question: '需要确认按钮颜色' }, note: { text: '需要确认按钮颜色', error: false }, reply },
{ stage: 'person', action: 'answer' }),
ticketSummary({ id: 't4', title: '截图工具', actions: { retry: true, stop: true }, sessionId: 'worker-t4',
summary: 'LlmError: no adapter registered for provider "provider-a"',
note: { text: 'LlmError: no adapter registered for provider "provider-a"', error: true } }, { stage: 'person', action: 'failed' })],
search: { items: [], threads: [] },
history: { current: 3, policyVersion: 'abcdef1234567890', items: [
{ revision: 4, savedAt: '2026-09-23T00:00:00Z', source: 'panel', policyVersion: '9876543210fedcba', policyChanged: true, notified: [],
block: 'source:feishu',
changes: [{ code: 'sourceRowsAdded', source: 'feishu', field: 'chats', items: ['oc_team'] }] },
{ revision: 3, savedAt: '2026-09-22T00:00:00Z', source: 'panel', policyVersion: 'abcdef1234567890', policyChanged: true,
notified: [{ id: 't1', title: '修复回归' }], block: 'policy',
changes: [{ code: 'mode', from: 'off', to: 'observe' }, { code: 'sourceField', source: 'feishu', field: 'directMessages',
from: true, to: false },
{ code: 'sourceRowsAdded', source: 'feishu', field: 'chats', items: ['oc_other', 'oc_new'] }] }] },
}
}
/** A refusal response carrying the host's HTTP status code and JSON error body. Returned by route handlers when a request is rejected. */
class Refusal { constructor(readonly status: 400 | 404 | 409 | 500, readonly body: Record<string, unknown>) {} }
const refuse = (status: Refusal['status'], error: string, more: Record<string, unknown> = {}) => new Refusal(status, { error, ...more })
type Query<K extends keyof Api> = Api[K] extends { query?: infer Q } ? keyof NonNullable<Q> & string : never
type Body<K extends keyof Api> = Api[K] extends { body: infer B } ? B : undefined
interface Request<K extends keyof Api> { params: RouteParams<K>; query: Partial<Record<Query<K>, string>>; body: Body<K> }
type Answer<K extends keyof Api> = ApiResponse<K> | Refusal
type Route<K extends keyof Api> = { query?: readonly Query<K>[]; answer: (request: Request<K>) => Answer<K> | Promise<Answer<K>> }
& (Api[K] extends { body: unknown } ? { body: true } : { body?: never })
const page = <T>(items: T[]) => ({ items, total: items.length, next_offset: null })
const ticketOf = (id: string) =>
[...server.tickets.open, ...server.person, ...server.tickets.ended].find(ticket => ticket.id === id) ?? server.ticketDetails[id]
const SourceSave = z.object({ enabled: z.boolean(), config: z.unknown() })
const ROUTES: { [K in keyof Api]: Route<K> } = {
'GET state': { answer: stateView },
'GET overview': { answer: overviewView },
'GET search': { query: ['q'], answer: async ({ query }) => {
const q = query.q ?? ''
await server.searching?.(q)
return { tickets: [], threads: server.search.threads, items: server.search.items.filter(item => item.preview.includes(q)) }
} },
'GET sources': { answer: sourceStatuses },
'GET settings': { answer: settingsView },
'POST sources/:id/labels': { answer: () => {
// Label refresh failures come from the bot CLI and are returned verbatim.
if (server.joinError) return refuse(500, server.joinError)
const added = Object.values(server.joining ?? {}); server.joined = { ...server.joined, ...server.joining }; server.joining = undefined
return { added }
} },
'GET threads': { query: ['filter', 'offset', 'limit'], answer: () => page(server.threads) },
'GET threads/:key': { query: ['limit', 'before', 'around'], answer: ({ params, query }) => {
const found = server.threadPages(params.key, query)
if (!found) return refuse(404, 'Thread not found')
// A Trigger sent to Eval again is queued and cannot be requeued.
const queued = (item: TriggerRow) => ({ ...item, disposition: { ...item.disposition, queued: true }, actions: { requeue: false } })
return { ...found, timeline: found.timeline.map(item => server.queued.has(item.id) ? queued(item) : item) }
} },
'POST threads/:key/unsubscribe': { answer: ({ params }) => {
const removed = server.threads.length > 0
server.threads = []
return { removed, thread: params.key, context_delivery: 'injected' }
} },
'POST triggers/:id/requeue': { answer: ({ params }) => { server.queued.add(params.id); return { queued: true } } },
'GET decisions': { query: ['kind', 'window', 'offset', 'limit'], answer: ({ query }) =>
({ items: query.kind === 'ignore' ? [] : [acceptDecision()], next_offset: null }) },
'POST decisions/:id/dismiss': { answer: ({ params }) => params.id === 'u1' ? { dismissed: true } : refuse(404,
'Unavailable Eval not found') },
'GET tickets': { query: ['state', 'offset', 'limit'], answer: ({ query }) => page(server.tickets[query.state === 'ended' ? 'ended'
: 'open']) },
'GET tickets/:id': { answer: ({ params }) => server.ticketDetails[params.id] ?? refuse(404, 'Ticket not found') },
'POST tickets/:id/retry': { answer: ({ params }) => {
const ticket = ticketOf(params.id)
if (!ticket) return refuse(404, 'Ticket not found')
return ticket.actions.retry ? { retried: true } : refuse(400, 'Only an open ticket can be retried, and only when its worker last reported failed or blocked and no person has sent a message since, or when delivering an Input to its worker failed',
{ code: 'notRetryable', values: {} })
} },
'POST tickets/:id/stop': { answer: ({ params }) => {
if (!ticketOf(params.id)) return refuse(404, 'Ticket not found')
server.tickets.open = []; server.person = server.person.filter(ticket => ticket.id !== params.id)
return { stopped: true }
} },
'POST executor/pause': { body: true, answer: ({ body }) => {
server.settings = { ...server.settings, paused: body.paused }
server.revisions.pause = (server.revisions.pause ?? 0) + 1
return { paused: body.paused }
} },
'PUT settings/:block': { body: true, answer: ({ params: { block }, body }) => {
const source = block.startsWith('source:') ? KINDS.find(kind => `source:${kind.kind}` === block) : undefined
if (!['policy', 'eval', 'executor'].includes(block) && !source) return block.startsWith('source:') ? refuse(404, 'Source not found')
: refuse(400, 'Invalid settings: block')
if (body.revision !== (server.revisions[block] ?? 0)) return refuse(409, 'Settings changed. Reload before saving.')
let restarting = false
if (block === 'policy') server.settings = { ...server.settings, policy: PolicyConfigSchema.parse(body.value) }
else if (block === 'executor') server.settings = { ...server.settings, executor: ExecutorConfigSchema.parse(body.value) }
else if (block === 'eval') server.settings = { ...server.settings, eval: EvalConfigSchema.parse(body.value) }
else if (source) {
const { enabled, config: value } = SourceSave.parse(body.value)
const config = source.schema.parse(value)
// As on the server, a changed CLI that fails its check refuses the save with the setup problem.
if (config.cli === 'lark-broken') {
const refused = new SetupError('feishuCliFailed', { output: 'lark-cli: app secret missing' })
return refuse(400, refused.message, { setup: refused.problem })
}
restarting = (config.cli ?? '') !== (server.settings.sources[source.kind]?.config.cli ?? '')
server.settings = { ...server.settings, sources: { ...server.settings.sources, [source.kind]: { enabled, config } } }
}
server.revisions[block] = (server.revisions[block] ?? 0) + 1
return { restarting, revision: server.revisions[block] }
} },
'GET policy/history': { answer: () => server.history },
'POST policy-sync/:id': { answer: ({ params }) => ({ policy: server.settings.policy.sources[params.id] }) },
'POST setup/check': { answer: () => ({ restarting: true }) },
'GET models': { answer: () => ({ default: { provider: 'host', model: 'default' }, routableProviders: [], failures: [],
groups: [{ id: 'worker-provider', name: 'Worker provider', models: [{ id: 'worker-model', name: 'Worker model',
reasoning: { efforts: [{ id: 'high', name: 'High' }] } }] }] }) },
'GET prompts': { query: ['revision', 'offset'], answer: () => server.prompts },
'PUT prompts': { body: true, answer: ({ body }) => {
const { revision } = server.prompts
if (body.baseRevision !== revision) return refuse(409,
`Prompt version changed to ${revision}; compare the latest version before saving`)
// As the host does: a template keeps its placeholders, and no overrides are stored as none.
const { overrides = {}, ...config } = body.config as PromptsView['current']['config']
for (const [part, override] of Object.entries(overrides)) {
if (!builtInPart(part)) return refuse(400, 'refused', { code: 'unknownPromptPart', values: { part } })
const missing = (builtInPart(part)?.placeholders ?? []).filter(placeholder => !override.text.includes(placeholder))
if (missing.length) return refuse(400, 'refused', { code: 'promptPlaceholders', values: { part, placeholders: missing.join(', ') } })
}
server.prompts.revision++
server.prompts.current = promptVersion(server.prompts.revision, Object.keys(overrides).length ? { ...config, overrides } : config)
return { revision: server.prompts.revision, context_delivery: 'injected' }
} },
'POST prompts/preview': { body: true, answer: ({ body }) => {
const skills = [...server.setup.accounts?.feishu ? ['feishu'] : [], 'github', ...Object.keys(body.skills)].map(name => [name,
composeSkill(body, name)])
// The generated parts from their templates, without variables or rules.
const environment = environmentSection(body, []), policy = policySection(SettingsSchema.parse({ policy: { rules: [] } }), body)
return { parts: { orchestrator: promptParts(body, 'orchestrator', environment, policy), worker: promptParts(body, 'worker', environment) },
skills: Object.fromEntries(skills),
orchestrator: composePrompt(body, 'orchestrator'), worker: composePrompt(body, 'worker'), scope: '' }
} },
}
function match(method: string, segments: string[]) {
for (const key of Object.keys(ROUTES) as (keyof Api)[]) {
const [verb, path] = key.split(' ') as [string, string]
const pattern = path.split('/')
if (verb !== method || pattern.length !== segments.length || !pattern.every((part, index) => part.startsWith(':')
|| part === segments[index])) continue
return { key, params: Object.fromEntries(pattern.flatMap((part, index) => part.startsWith(':') ? [[part.slice(1),
decodeURIComponent(segments[index]!)]] : [])) }
}
return undefined
}
async function run<K extends keyof Api>(key: K, params: Record<string, string>, query: URLSearchParams, body: unknown): Promise<Answer<K>> {
const route: Route<K> = ROUTES[key]
const read: readonly string[] = route.query ?? []
for (const name of query.keys()) if (!read.includes(name)) server.misuse.push(`${key} with the query parameter ${name}`)
if ((body !== undefined) !== !!route.body) server.misuse.push(`${key} ${body === undefined ? 'without' : 'with'} a body`)
const values: Partial<Record<Query<K>, string>> = {}
for (const name of route.query ?? []) { const value = query.get(name); if (value !== null) values[name] = value }
return route.answer({ params: params as RouteParams<K>, query: values, body: body as Body<K> })
}
/**
* Response wrapper that increments `server.pending` when constructed and decrements it when `.text()` is consumed.
* Requests held back by `server.searching` are not constructed until `searching` resolves, so they do not count as pending early.
*/
class Reply extends Response {
constructor(value: unknown, status: number) { super(JSON.stringify(value), { status,
headers: { 'content-type': 'application/json' } }); server.pending++ }
override async text() { server.pending--; return super.text() }
}
export function usePanelServer(cleanup: () => void) {
beforeEach(() => {
sessionStorage.clear()
server = initial()
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit): Promise<Response> => {
if (server.down) return new Response(null, { status: 404 })
const address = new URL(url, 'http://127.0.0.1:3082')
const path = address.pathname.replace('/orchestrator/api/', '')
const method = init?.method ?? 'GET'
const body: unknown = init?.body ? JSON.parse(String(init.body)) : undefined
server.requests.push({ path: decodeURIComponent(`${path}${address.search}`), method, ...body === undefined ? {} : { body } })
const route = match(method, path.split('/'))
if (!route) return new Reply({ error: 'Unknown operation' }, 404)
const answer = await run(route.key, route.params, address.searchParams, body)
return answer instanceof Refusal ? new Reply(answer.body, answer.status) : new Reply(answer, 200)
}))
})
afterEach(() => {
try { expect(server.misuse).toEqual([]) }
finally { cleanup(); vi.unstubAllGlobals() }
})
}
type Ops = Parameters<ConfigForm<Record<string, unknown>>['mutate']>[0]
/** Fake DSH host configuration form that records mutations and optionally applies them to its snapshot. */
export function fakeForm(writable = true, accept = true) {
const listeners = new Set<() => void>()
let snapshot: ConfigFormSnapshot<Record<string, unknown>> = { status: 'ready', value: { ...MACHINE }, base: undefined, user: undefined,
revision: 1, writable,
mode: 'host' }
const notify = () => { for (const listener of listeners) listener() }
const mutations: { ops: Ops; revision: number | undefined }[] = []
const form: ConfigForm<Record<string, unknown>> = {
getSnapshot: () => snapshot,
subscribe: listener => { listeners.add(listener); return () => { listeners.delete(listener) } },
mutate: async (ops, revision) => {
mutations.push({ ops, revision })
if (!accept || revision !== snapshot.revision) return false
const value = { ...snapshot.value }
for (const op of ops) { if (op.op === 'set') value[op.path[0]!] = op.value; else delete value[op.path[0]!] }
snapshot = { ...snapshot, value, revision: snapshot.revision! + 1 }
notify()
return true
},
set: async () => false, unset: async () => false,
}
const publish = (patch: Partial<ConfigFormSnapshot<Record<string, unknown>>>) => { snapshot = { ...snapshot, ...patch }; notify() }
return { form, mutations, publish }
}
+79
View File
@@ -0,0 +1,79 @@
/** DOM rendering and interaction helpers for panel tests. */
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { expect, vi } from 'vitest'
import { App } from '../../src/client/app.tsx'
import { usePageForm } from '../../src/client/deployment.tsx'
import { zh } from '../../src/client/locales.ts'
import type { T } from '../../src/client/text.ts'
import { server, usePanelServer } from './panel-server.ts'
declare global { var IS_REACT_ACT_ENVIRONMENT: boolean }
globalThis.IS_REACT_ACT_ENVIRONMENT = true
export const translator = (dictionary: Record<string, string>): T => ((key: string, params?: Record<string, unknown>) => {
const template = dictionary[key] ?? key
return params ? template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match) : template
}) as T
function Panel({ t }: { t: T }) {
return <App t={t} openSession={async id => { server.opened.push(id) }} deploymentForm={usePageForm(server.form)} />
}
let root: Root | undefined
let container: HTMLElement | undefined
function unmount() {
act(() => { root?.unmount() })
root = undefined
container?.remove()
}
export const usePanel = () => usePanelServer(unmount)
/** Flush pending fake-server responses and React state updates. */
export async function settle() {
await vi.waitFor(async () => {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
expect(server.pending).toBe(0)
})
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
}
export async function mount(dictionary: Record<string, string> = zh) {
container = document.createElement('div'); document.body.append(container)
root = createRoot(container)
await act(async () => { root!.render(<Panel t={translator(dictionary)} />) })
await settle()
}
export async function remount(dictionary: Record<string, string> = zh) { unmount(); await mount(dictionary) }
export const all = (selector: string) => [...document.querySelectorAll<HTMLElement>(selector)]
const CONTROLS = 'button, [role="button"], [role="tab"], [role="switch"], a'
export function find(name: string, selector = CONTROLS): HTMLElement {
const match = all(selector).find(element => element.textContent?.trim() === name || element.getAttribute('aria-label') === name)
if (!match) throw new Error(`No ${selector} named ${name}`)
return match
}
export const tab = (name: string) => find(name, '[role="tab"]')
export async function click(target: string | HTMLElement) {
const element = typeof target === 'string' ? find(target) : target
await act(async () => { element.click() })
await settle()
}
export async function type(element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, value: string) {
const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : element instanceof HTMLSelectElement
? HTMLSelectElement.prototype
: HTMLInputElement.prototype
await act(async () => {
Object.getOwnPropertyDescriptor(prototype, 'value')!.set!.call(element, value)
element.dispatchEvent(new Event(element instanceof HTMLSelectElement ? 'change' : 'input', { bubbles: true }))
})
await settle()
}
/** The page's text without its stylesheet. */
export const text = () => [...document.body.querySelectorAll('style')].reduce((all, style) => all.replace(style.textContent ?? '', ''),
document.body.textContent ?? '')
export function field<E extends HTMLElement>(label: string): E {
const labelled = (element: HTMLElement) => !!element.id && all('label').some(item => (item as HTMLLabelElement).htmlFor === element.id
&& item.textContent === label)
const match = all('input, textarea, select').find(element => element.getAttribute('aria-label') === label || labelled(element))
if (!match) throw new Error(`No field ${label}`)
return match as unknown as E
}
export const at = (route: object) => sessionStorage.setItem('dsh-superagent/route', JSON.stringify({ route, stack: [] }))