From 1f77d99a5535186f000764ce857ad305acdace66 Mon Sep 17 00:00:00 2001 From: Eivind Hyldmo <3465788+hyldmo@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:20:39 +0200 Subject: [PATCH 1/3] feat(agents): store agent definitions as markdown files --- ARCHITECTURE.md | 18 +- src/agents/agent-file.ts | 150 +++++++++++ src/agents/agent-migration.ts | 51 ++++ src/agents/agent-store.ts | 260 +++++++++++++++++++ src/agents/auto-model/config.ts | 8 +- src/agents/roles.ts | 8 +- src/agents/routing.ts | 64 +++++ src/http/router.ts | 2 + src/http/routes/agents.ts | 65 +++++ src/http/routes/auto-model.ts | 4 +- src/http/services/auto-model.ts | 3 +- src/http/services/base.ts | 8 +- src/routes.ts | 7 +- src/wire.ts | 44 +++- tests/agents/agent-store.test.ts | 412 +++++++++++++++++++++++++++++++ tests/http/agents.test.ts | 235 ++++++++++++++++++ tests/http/router.test.ts | 9 +- tests/http/routes.test.ts | 14 ++ 18 files changed, 1340 insertions(+), 22 deletions(-) create mode 100644 src/agents/agent-file.ts create mode 100644 src/agents/agent-migration.ts create mode 100644 src/agents/agent-store.ts create mode 100644 src/agents/routing.ts create mode 100644 src/http/routes/agents.ts create mode 100644 tests/agents/agent-store.test.ts create mode 100644 tests/http/agents.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 80dd433e..b95431cc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -32,7 +32,8 @@ src/ Node relay; source runs as .ts, npm ships emitted dist- router-types.ts handler contract and NOT_HANDLED sentinel input.ts JSON body decoding with a caller error for malformed JSON services/ - base.ts own DBs, Reads, SessionPoller, actuator, caches, role store, shared UI lease + base.ts own DBs, Reads, SessionPoller, actuator, caches, agent store, shared UI lease + auto-model.ts assembled routing config and durable Auto selection/delivery queue delivery.ts prompt budgets/receipts, chat opening, first-prompt and parked queues delegations.ts ordinary-child UI adapters, completion observation, DelegationQueue workflow.ts coordinator adapters and managed effect dispatch @@ -43,7 +44,7 @@ src/ Node relay; source runs as .ts, npm ships emitted dist- files.ts static PWA, attachment and tool-image responses responses.ts authentication, body limits, conditional JSON, redaction/compression routes/ state, system, workspaces, create-workspace, sessions, prompts, - files, workflows, voice; injected services, no startup side effects + agents, auto-model, files, workflows, voice; injected services, no startup side effects reads/ repository.ts Reads facade over the same read-only ConductorDb workspaces.ts workspace/repo reads, search targets, archive metadata @@ -104,9 +105,16 @@ src/ Node relay; source runs as .ts, npm ships emitted dist- prompt.ts child assignment with the frozen parent chat reference return.ts saved final-answer report and completion notice types.ts queue adapter contracts - agents/ agent-config.ts: model receipt before effort/Fast; model-cache.ts: - observed picker labels; roles.ts: strict global roles.json; - conductor-settings.ts: surgical new-chat defaults in settings.toml + agents/ + agent-config.ts model receipt before effort/Fast; model-cache.ts: observed picker labels + agent-file.ts flat frontmatter parse/patch, preserving unknown blocks and Markdown verbatim + agent-store.ts cached agents/*.md roster, legacy role/Auto facades and write-through semantics + agent-migration.ts copy-only roles.json + auto-model.json merge, publishing a complete directory + roles.ts role decoding/resolution and legacy JSON reader for migration + routing.ts routing.json globals, derived profiles and fallback validation + conductor-settings.ts surgical new-chat defaults in settings.toml + auto-model/ config.ts/types.ts: assembled v1 contract, validation and frozen job config; + decision.ts/provider.ts: isolated router; queue.ts: durable Auto ownership delivery/ firstprompt.ts, parked.ts, sendonce.ts: durable ordinary prompt queues and the in-process repeated-client-id send memo dev-server/ controller.ts owns preview/forward state; proxy.ts tunnels requests; diff --git a/src/agents/agent-file.ts b/src/agents/agent-file.ts new file mode 100644 index 00000000..4ec7b1fe --- /dev/null +++ b/src/agents/agent-file.ts @@ -0,0 +1,150 @@ +/** Flat frontmatter only. Unknown blocks and Markdown are never parsed as YAML. */ +import { z } from 'zod' +import { AGENT_EFFORTS, modelPickerLabel } from '../shared.ts' +import type { AgentDefinition, AgentsConfig } from '../wire.ts' + +export const AGENT_NAME = /^[a-z][a-z0-9_-]{0,63}$/ +export const MAX_AGENTS = 32 +const FRONTMATTER_KEYS = ['description', 'model', 'effort', 'fast', 'routing'] as const +type FrontmatterKey = (typeof FRONTMATTER_KEYS)[number] +type Frontmatter = Pick + +const agentSchema = z + .object({ + name: z.string().regex(AGENT_NAME), + description: z.string().max(1000).optional(), + model: z.string().trim().min(1).max(256).transform(modelPickerLabel), + effort: z.enum(AGENT_EFFORTS).optional(), + fast: z.boolean().optional(), + routing: z.boolean().optional(), + preamble: z.string().max(50_000).optional() + }) + .strict() +const agentsSchema = z.object({ version: z.literal(1), agents: z.array(agentSchema).max(MAX_AGENTS) }).strict() + +export function decodeAgent(raw: unknown): AgentDefinition { + return agentSchema.parse(raw) +} + +export function decodeAgents(raw: unknown): AgentsConfig { + const config = agentsSchema.parse(raw) + if (new Set(config.agents.map(agent => agent.name)).size !== config.agents.length) + throw new Error('Agent names must be unique.') + return config +} + +interface RawBlock { + key?: string + lines: string[] +} + +export interface AgentFile { + fields: Partial + body: string + /** Includes original line endings, comments and unrecognized syntax. */ + blocks: RawBlock[] + opening: string + closing: string + newline: string +} + +function lineText(line: string): string { + return line.replace(/\r?\n$/, '') +} + +function knownKey(key: string | undefined): key is FrontmatterKey { + return FRONTMATTER_KEYS.some(known => known === key) +} + +/** Quoted scalars accept YAML single-quote escaping and JSON double-quote escapes. */ +function scalar(raw: string, key: string): string { + const value = raw.trim() + if (value.startsWith('"')) { + const match = /^("(?:[^"\\]|\\.)*")(?:\s+#.*)?$/.exec(value) + if (!match) throw new Error(`${key} has an invalid quoted scalar`) + try { + return JSON.parse(match[1]) as string + } catch { + throw new Error(`${key} has an invalid quoted scalar`) + } + } + if (value.startsWith("'")) { + const match = /^'((?:[^']|'')*)'(?:\s+#.*)?$/.exec(value) + if (!match) throw new Error(`${key} has an invalid quoted scalar`) + return match[1].replaceAll("''", "'") + } + const bare = value.replace(/\s+#.*$/, '').trimEnd() + if (/^(?:[|>&*!]|\[|\{)/.test(bare)) throw new Error(`${key} must be a flat scalar`) + return bare +} + +/** Parsing does not require a model: imports and body-only files can be patched first. */ +export function parseAgentFile(source: string): AgentFile { + const lines = source.match(/[^\n]*\n|[^\n]+$/g) ?? [] + const newline = source.includes('\r\n') ? '\r\n' : '\n' + const file: AgentFile = { fields: {}, body: source, blocks: [], opening: '', closing: '', newline } + const first = lines[0] ?? '' + if (lineText(first) !== '---') return file + const end = lines.findIndex((line, index) => index > 0 && lineText(line) === '---') + if (end < 0) throw new Error('Frontmatter is missing its closing --- line') + file.opening = first + file.closing = lines[end] + file.body = lines.slice(end + 1).join('') + for (const line of lines.slice(1, end)) { + const key = /^([^\s:#][^:]*):/.exec(lineText(line))?.[1] + const previous = file.blocks.at(-1) + // Blank lines and comments do not end a key's block. In particular, a + // colon inside a comment must not detach the next indented continuation. + if (!key && previous) previous.lines.push(line) + else file.blocks.push({ key, lines: [line] }) + } + const seen = new Set() + for (const block of file.blocks) { + if (!knownKey(block.key)) continue + const key = block.key + if (seen.has(key)) throw new Error(`Duplicate ${key} frontmatter`) + seen.add(key) + if (block.lines.slice(1).some(line => /^[ \t]+\S/.test(line) && !line.trimStart().startsWith('#'))) + throw new Error(`${key} must be a flat scalar`) + const value = scalar(lineText(block.lines[0]).slice(key.length + 1), key) + if (key === 'fast' || key === 'routing') { + if (value !== 'true' && value !== 'false') throw new Error(`${key} must be true or false`) + file.fields[key] = value === 'true' + } else if (key === 'effort') { + file.fields.effort = z.enum(AGENT_EFFORTS).parse(value) + } else file.fields[key] = value + } + return file +} + +/** Only keys present in patch are touched; undefined removes one known key. */ +export function serializeAgentFile(file: AgentFile, patch: Partial = {}, body = file.body): string { + const remaining = new Set(FRONTMATTER_KEYS.filter(key => Object.hasOwn(patch, key))) + const rendered: string[] = [] + for (const block of file.blocks) { + const key = block.key + if (!knownKey(key) || !remaining.has(key)) { + rendered.push(...block.lines) + continue + } + remaining.delete(key) + if (patch[key] === file.fields[key]) { + rendered.push(...block.lines) + continue + } + if (patch[key] !== undefined) { + const newline = block.lines[0].endsWith('\r\n') ? '\r\n' : '\n' + rendered.push(`${key}: ${JSON.stringify(patch[key])}${newline}`) + } + // Keep comments, blank lines and other opaque syntax around the known line. + rendered.push(...block.lines.slice(1)) + } + for (const key of remaining) { + if (patch[key] !== undefined) rendered.push(`${key}: ${JSON.stringify(patch[key])}${file.newline}`) + } + if (!file.opening && !rendered.length) return body + const opening = file.opening || `---${file.newline}` + let closing = file.closing || `---${file.newline}` + if (body && !closing.endsWith('\n')) closing += file.newline + return `${opening}${rendered.join('')}${closing}${body}` +} diff --git a/src/agents/agent-migration.ts b/src/agents/agent-migration.ts new file mode 100644 index 00000000..9424c50a --- /dev/null +++ b/src/agents/agent-migration.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { AgentDefinition } from '../wire.ts' +import { decodeAgents, parseAgentFile, serializeAgentFile } from './agent-file.ts' +import { AutoModelConfigStore } from './auto-model/config.ts' +import { RoleStore } from './roles.ts' +import { RoutingConfigStore, routingGlobals } from './routing.ts' + +/** Copy once, publishing a complete roster. The two legacy files are never written. */ +export function migrateAgents(directory: string): void { + try { + if (!fs.lstatSync(directory).isDirectory()) throw new Error('agents must be a directory') + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + const root = path.dirname(directory) + const roles = new RoleStore(path.join(root, 'roles.json')).read() + // A malformed legacy file must stay repairable, not turn defaults into permanent state. + if (roles.warning) throw new Error(roles.warning) + const auto = new AutoModelConfigStore(path.join(root, 'auto-model.json')).read() + const agents = new Map( + Object.entries(roles.config.roles).map(([name, role]) => [name, { name, ...role }]) + ) + for (const { id, description, ...tuple } of auto.profiles) { + const role = agents.get(id) + agents.set(id, { ...(role ?? { name: id, ...tuple }), description }) + } + const config = decodeAgents({ version: 1, agents: [...agents.values()] }) + fs.mkdirSync(root, { recursive: true }) + const staging = fs.mkdtempSync(path.join(root, '.agents-migration-')) + try { + for (const { name, preamble = '', ...fields } of config.agents) { + fs.writeFileSync(path.join(staging, `${name}.md`), serializeAgentFile(parseAgentFile(''), fields, preamble), { + mode: 0o600 + }) + } + const routingFile = path.join(root, 'routing.json') + // A prior interrupted migration may have published the globals already. + if (!fs.existsSync(routingFile)) new RoutingConfigStore(routingFile).write(routingGlobals(auto)) + try { + fs.renameSync(staging, directory) + } catch (error) { + if (!['EEXIST', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error + // Another relay finished the same copy while this process prepared its files. + if (!fs.lstatSync(directory).isDirectory()) throw error + } + } finally { + fs.rmSync(staging, { recursive: true, force: true }) + } +} diff --git a/src/agents/agent-store.ts b/src/agents/agent-store.ts new file mode 100644 index 00000000..bfa12b9d --- /dev/null +++ b/src/agents/agent-store.ts @@ -0,0 +1,260 @@ +import { isUtf8 } from 'node:buffer' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { stateDir } from '../config.ts' +import type { AgentDefinition, AgentsConfig, RolesConfig, RoutingConfig } from '../wire.ts' +import { + type AgentFile, + decodeAgent, + decodeAgents, + MAX_AGENTS, + parseAgentFile, + serializeAgentFile +} from './agent-file.ts' +import { migrateAgents } from './agent-migration.ts' +import { decodeAutoModelConfig } from './auto-model/config.ts' +import type { AutoModelConfig } from './auto-model/types.ts' +import { decodeRoles, type RoleStoreRead, type RoleStoreWrite } from './roles.ts' +import { + agentProfiles, + assertRoutingFallback, + decodeRoutingConfig, + RoutingConfigStore, + routingGlobals +} from './routing.ts' + +export interface AgentStoreRead extends AgentsConfig { + warning?: string +} + +export type AgentStoreWrite = { ok: true; config: AgentStoreRead } | { ok: false; error: string } + +interface StoredFile { + agent: AgentDefinition + file: AgentFile + source: string +} + +interface Snapshot { + files: Map + warning?: string +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function agentsRoles(agents: AgentDefinition[]): RolesConfig { + return { + version: 1, + roles: Object.fromEntries( + agents.map(({ name, model, effort, fast, preamble }) => [ + name, + { model, ...(effort ? { effort } : {}), ...(fast !== undefined ? { fast } : {}), preamble: preamble ?? '' } + ]) + ) + } +} + +/** One canonical directory, with legacy method shapes for unchanged runtime consumers. */ +export class AgentStore { + private readonly directory: string + private readonly routingStore: RoutingConfigStore + private cache: Snapshot | undefined + private cacheStamp: string | undefined + + readonly roles = { + read: (): RoleStoreRead => this.readRoles(), + write: (raw: unknown): RoleStoreWrite => this.writeRoles(raw) + } + readonly autoModel = { + read: (): AutoModelConfig => this.readAutoModel(), + write: (raw: unknown): AutoModelConfig => this.writeAutoModel(raw) + } + readonly routing = { + read: (): RoutingConfig => this.readRouting(), + write: (raw: unknown): RoutingConfig => this.writeRouting(raw) + } + + constructor(directory = path.join(stateDir(), 'agents')) { + this.directory = directory + this.routingStore = new RoutingConfigStore(path.join(path.dirname(directory), 'routing.json')) + } + + private inspect(): { stamp: string; names: string[] } { + migrateAgents(this.directory) + const names = fs + .readdirSync(this.directory) + .filter(name => name.endsWith('.md')) + .sort() + const stamps = [this.directory, ...names.map(name => path.join(this.directory, name))].map(file => { + const stat = fs.lstatSync(file) + return `${file}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}:${stat.ino}:${stat.mode}` + }) + return { stamp: stamps.join('\n'), names } + } + + private snapshot(): Snapshot { + try { + const { stamp, names } = this.inspect() + if (this.cache && this.cacheStamp === stamp) return this.cache + const files = new Map() + const warnings: string[] = [] + for (const filename of names) { + try { + if (files.size >= MAX_AGENTS) throw new Error(`agents must contain at most ${MAX_AGENTS} entries`) + const filePath = path.join(this.directory, filename) + if (!fs.lstatSync(filePath).isFile()) throw new Error('agent definitions must be regular files') + const bytes = fs.readFileSync(filePath) + // Decoding with replacement characters would corrupt opaque frontmatter + // on the next save, violating the byte-for-byte preservation contract. + if (!isUtf8(bytes)) throw new Error('agent definitions must be valid UTF-8') + const source = bytes.toString('utf8') + const file = parseAgentFile(source) + const agent = decodeAgent({ name: filename.slice(0, -3), ...file.fields, preamble: file.body }) + files.set(agent.name, { agent, file, source }) + } catch (error) { + warnings.push(`Could not read ${filename}: ${message(error)}`) + } + } + this.cache = { files, ...(warnings.length ? { warning: warnings.join('\n') } : {}) } + this.cacheStamp = stamp + return this.cache + } catch (error) { + // Don't cache inspection failures: permissions or migration can be repaired externally. + this.cache = undefined + this.cacheStamp = undefined + return { files: new Map(), warning: `Could not read agents: ${message(error)}` } + } + } + + read(): AgentStoreRead { + const stored = this.snapshot() + return { + version: 1, + agents: [...stored.files.values()].map(({ agent }) => ({ ...agent })), + ...(stored.warning ? { warning: stored.warning } : {}) + } + } + + readRoles(): RoleStoreRead { + const { agents, warning } = this.read() + return { config: agentsRoles(agents), ...(warning ? { warning } : {}) } + } + + private editable(): Snapshot { + const stored = this.snapshot() + // A whole-roster PATCH cannot represent undecodable files. Refuse the batch + // rather than interpreting their omission as permission to delete or replace them. + if (stored.warning) throw new Error(`${stored.warning}\nRepair the agent files before saving.`) + return stored + } + + /** Validate and render the entire batch before the first per-file atomic replacement. */ + private persist(agents: AgentDefinition[], stored: Snapshot, routing?: RoutingConfig): void { + decodeAgents({ version: 1, agents }) + const replacements = agents.flatMap(({ name, preamble = '', model, effort, fast, description, routing }) => { + const previous = stored.files.get(name) + const contents = serializeAgentFile( + previous?.file ?? parseAgentFile(''), + { model, effort, fast, description, routing }, + preamble + ) + return contents === previous?.source ? [] : [{ file: path.join(this.directory, `${name}.md`), contents }] + }) + const names = new Set(agents.map(agent => agent.name)) + const removed = [...stored.files.keys()].filter(name => !names.has(name)) + const staged: Array<{ file: string; temporary: string }> = [] + try { + for (const { file, contents } of replacements) { + const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp` + staged.push({ file, temporary }) + fs.writeFileSync(temporary, contents, { mode: 0o600, flag: 'wx' }) + } + for (const { file, temporary } of staged) fs.renameSync(temporary, file) + if (routing) this.routingStore.write(routing) + for (const name of removed) fs.unlinkSync(path.join(this.directory, `${name}.md`)) + } finally { + this.cache = undefined + this.cacheStamp = undefined + for (const { temporary } of staged) fs.rmSync(temporary, { force: true }) + } + } + + write(raw: unknown): AgentStoreWrite { + try { + const config = decodeAgents(raw) + this.persist(config.agents, this.editable()) + return { ok: true, config: this.read() } + } catch (error) { + return { ok: false, error: message(error) } + } + } + + writeRoles(raw: unknown): RoleStoreWrite { + try { + const config = decodeRoles(raw) + const stored = this.editable() + const agents = Object.entries(config.roles).map(([name, role]) => { + const previous = stored.files.get(name)?.agent + return { name, ...role, description: previous?.description, routing: previous?.routing } + }) + this.persist(agents, stored) + return { ok: true, config: this.readRoles().config } + } catch (error) { + return { ok: false, error: `could not persist roles: ${message(error)}` } + } + } + + readRouting(): RoutingConfig { + migrateAgents(this.directory) + return this.routingStore.read() + } + + writeRouting(raw: unknown): RoutingConfig { + const config = decodeRoutingConfig(raw) + const agents = [...this.editable().files.values()].map(file => file.agent) + assertRoutingFallback(config, agents) + return this.routingStore.write(config) + } + + readAutoModel(): AutoModelConfig { + const { agents, warning } = this.read() + if (warning) throw new Error(`Auto settings could not be read. ${warning}`) + const routing = this.readRouting() + assertRoutingFallback(routing, agents) + return decodeAutoModelConfig({ ...routing, profiles: agentProfiles(agents) }) + } + + writeAutoModel(raw: unknown): AutoModelConfig { + const config = decodeAutoModelConfig(raw) + const stored = this.editable() + const agents = new Map([...stored.files].map(([name, file]) => [name, { ...file.agent }])) + const profiles = new Set(config.profiles.map(profile => profile.id)) + for (const { id } of agentProfiles([...agents.values()])) { + if (!profiles.has(id)) { + const previous = agents.get(id)! + delete previous.description + } + } + for (const { id, model, effort, fast, description } of config.profiles) { + const previous = agents.get(id) + agents.set(id, { + ...previous, + name: id, + model, + effort, + fast, + description, + // Inclusion in a legacy profiles list is an explicit routing opt-in. + ...(previous?.routing === false ? { routing: true } : {}) + }) + } + const next = [...agents.values()] + const globals = routingGlobals(config) + assertRoutingFallback(globals, next) + this.persist(next, stored, globals) + return this.readAutoModel() + } +} diff --git a/src/agents/auto-model/config.ts b/src/agents/auto-model/config.ts index 7835dcb3..2a34b0ee 100644 --- a/src/agents/auto-model/config.ts +++ b/src/agents/auto-model/config.ts @@ -68,7 +68,7 @@ const tuple = z fast: z.boolean().optional() }) .strict() -const configSchema = z +export const autoModelConfigSchema = z .object({ version: z.literal(1), defaultAuto: z.boolean(), @@ -80,7 +80,8 @@ const configSchema = z .strict() ) .min(1) - .max(16), + // The canonical agent directory can contribute every one of its 32 files. + .max(32), fallback: z.string(), rules: z.string().max(12_000), timeoutMs: z.number().int().min(2000).max(30_000) @@ -88,7 +89,7 @@ const configSchema = z .strict() export function decodeAutoModelConfig(raw: unknown): AutoModelConfig { - const config = configSchema.parse(raw) + const config = autoModelConfigSchema.parse(raw) if (new Set(config.profiles.map(p => p.id)).size !== config.profiles.length) throw new Error('Profile names must be unique.') if (!config.profiles.some(p => p.id === config.fallback)) throw new Error('Choose an existing fallback profile.') @@ -140,6 +141,7 @@ export function atomicJson(file: string, value: unknown): void { } } +/** Legacy JSON store retained for migration. Runtime consumers use AgentStore.autoModel. */ export class AutoModelConfigStore { private readonly file: string constructor(file: string) { diff --git a/src/agents/roles.ts b/src/agents/roles.ts index aa519f6c..dacf9b94 100644 --- a/src/agents/roles.ts +++ b/src/agents/roles.ts @@ -1,8 +1,8 @@ /** - * Global delegated-role definitions. + * Delegated-role defaults, validation and resolution. * - * Roles are relay preferences, not Conductor state, and therefore live beside the - * relay's other private JSON files. The decoder is strict on purpose: silently + * AgentStore now owns persistence; RoleStore reads legacy JSON during migration. + * The decoder is strict on purpose: silently * ignoring a hand-written `plan` field would make a role appear safe while still * inviting callers to depend on Conductor's currently unreliable Plan mode. */ @@ -228,7 +228,7 @@ export function resolveRole(config: RolesConfig, name: string, groups: CachedMod return { ok: true, role: { ...cloneRole(role), agentType } } } -/** Cached, process-local store. A rejected write leaves both memory and disk untouched. */ +/** Legacy JSON store retained for migration. Runtime consumers use AgentStore.roles. */ export class RoleStore { private readonly file: string private cache: RoleStoreRead | null = null diff --git a/src/agents/routing.ts b/src/agents/routing.ts new file mode 100644 index 00000000..1c4b6ee0 --- /dev/null +++ b/src/agents/routing.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs' +import type { AgentDefinition, CachedModelGroup, RoutingConfig } from '../wire.ts' +import { atomicJson, autoModelConfigSchema, autoModelIssues } from './auto-model/config.ts' +import type { AutoModelConfig, AutoModelProfile } from './auto-model/types.ts' + +const routingSchema = autoModelConfigSchema.omit({ profiles: true }) + +export function decodeRoutingConfig(raw: unknown): RoutingConfig { + return routingSchema.parse(raw) +} + +export function routingGlobals(config: AutoModelConfig): RoutingConfig { + const { profiles: _profiles, ...globals } = config + return decodeRoutingConfig(globals) +} + +/** Deliberately excludes the Markdown body: ordinary Auto chats get only a tuple. */ +export function agentProfiles(agents: AgentDefinition[]): AutoModelProfile[] { + return agents.flatMap(({ name, description, model, effort, fast, routing }) => + description?.trim() && routing !== false + ? [ + { + id: name, + description: description.trim(), + model, + ...(effort ? { effort } : {}), + ...(fast !== undefined ? { fast } : {}) + } + ] + : [] + ) +} + +export function assertRoutingFallback(config: RoutingConfig, agents: AgentDefinition[]): void { + if (!agentProfiles(agents).some(profile => profile.id === config.fallback)) + throw new Error('Choose an existing fallback profile.') +} + +/** Optional profile model issues belong to the agents editor, not the globals panel. */ +export function routingIssues(config: RoutingConfig, agents: AgentDefinition[], groups: CachedModelGroup[]): string[] { + const profiles = agentProfiles(agents).filter(profile => profile.id === config.fallback) + const issues = autoModelIssues({ ...config, profiles }, groups) + if (!profiles.length) issues.unshift('Choose an existing fallback profile.') + return issues +} + +export class RoutingConfigStore { + private readonly file: string + constructor(file: string) { + this.file = file + } + read(): RoutingConfig { + try { + return decodeRoutingConfig(JSON.parse(fs.readFileSync(this.file, 'utf8'))) + } catch (error) { + throw new Error('Auto settings could not be read. Repair routing.json before using Auto.', { cause: error }) + } + } + write(raw: unknown): RoutingConfig { + const config = decodeRoutingConfig(raw) + atomicJson(this.file, config) + return config + } +} diff --git a/src/http/router.ts b/src/http/router.ts index 1f170e3d..05248eda 100644 --- a/src/http/router.ts +++ b/src/http/router.ts @@ -3,6 +3,7 @@ import http from 'node:http' import { InputError } from '../contracts/validation.ts' import { UiBusyError, uiQueueDepth, withUiPriority } from '../writes/ui-lock.ts' import { NOT_HANDLED } from './router-types.ts' +import { createAgentsRoutes } from './routes/agents.ts' import { createAutoModelRoutes } from './routes/auto-model.ts' import { createCreateWorkspaceRoutes } from './routes/create-workspace.ts' import { createFilesRoutes } from './routes/files.ts' @@ -18,6 +19,7 @@ import type { RelayServices } from './services.ts' export function createRelayServer(services: RelayServices) { const { handleMcpHttp, serveStatic, authed, json, PayloadTooLargeError, workflowHttpError } = services const handlers = [ + createAgentsRoutes(services), createAutoModelRoutes(services), createStateRoutes(services), createWorkflowsRoutes(services), diff --git a/src/http/routes/agents.ts b/src/http/routes/agents.ts new file mode 100644 index 00000000..cf727c14 --- /dev/null +++ b/src/http/routes/agents.ts @@ -0,0 +1,65 @@ +import { decodeAgents } from '../../agents/agent-file.ts' +import { agentsRoles } from '../../agents/agent-store.ts' +import { roleModelIssues } from '../../agents/roles.ts' +import { decodeRoutingConfig, routingIssues } from '../../agents/routing.ts' +import { isRoute, routes } from '../../routes.ts' +import type { AgentsConfig, AgentsResponse, UpdateAgentsResult } from '../../wire.ts' +import { NOT_HANDLED, type RouteHandler } from '../router-types.ts' +import type { RelayServices } from '../services.ts' + +export function createAgentsRoutes( + services: Pick +): RouteHandler { + const { agentStore, routingConfig, modelCache, readBody, json } = services + const issuesFor = (config: AgentsConfig) => + roleModelIssues(agentsRoles(config.agents), modelCache.list()).map(({ role, error }) => ({ agent: role, error })) + function response(): AgentsResponse { + const stored = agentStore.read() + return { ...stored, issues: issuesFor(stored) } + } + return async (req, res, url) => { + if (isRoute(routes.agents, req.method, url.pathname)) return json(req, res, 200, response()) + if (isRoute(routes.updateAgents, req.method, url.pathname)) { + let config: AgentsConfig + try { + config = decodeAgents(JSON.parse((await readBody(req)) || '{}')) + } catch (error) { + return json(req, res, 400, { + ok: false, + error: { + code: 'invalid_request', + message: error instanceof Error ? error.message : String(error), + retryable: false + } + } satisfies UpdateAgentsResult) + } + const issues = issuesFor(config) + if (issues.length) + return json(req, res, 409, { ok: false, error: issues[0].error, issues } satisfies UpdateAgentsResult) + const written = agentStore.write(config) + if (!written.ok) { + return json(req, res, 500, { + ok: false, + error: { code: 'state_invalid', message: written.error, retryable: true } + } satisfies UpdateAgentsResult) + } + return json(req, res, 200, { ok: true, config: response() } satisfies UpdateAgentsResult) + } + if (isRoute(routes.routing, req.method, url.pathname)) { + const config = routingConfig.read() + return json(req, res, 200, { config, issues: routingIssues(config, agentStore.read().agents, modelCache.list()) }) + } + if (isRoute(routes.updateRouting, req.method, url.pathname)) { + try { + const config = decodeRoutingConfig(JSON.parse(await readBody(req))) + const issues = routingIssues(config, agentStore.read().agents, modelCache.list()) + if (issues.length) return json(req, res, 400, { error: issues.join(' '), issues }) + routingConfig.write(config) + return json(req, res, 200, { config: routingConfig.read(), issues: [] }) + } catch (error) { + return json(req, res, 400, { error: error instanceof Error ? error.message : 'Invalid routing settings.' }) + } + } + return NOT_HANDLED + } +} diff --git a/src/http/routes/auto-model.ts b/src/http/routes/auto-model.ts index 5318fe99..5759358d 100644 --- a/src/http/routes/auto-model.ts +++ b/src/http/routes/auto-model.ts @@ -17,8 +17,8 @@ export function createAutoModelRoutes( const config = decodeAutoModelConfig(JSON.parse(await readBody(req))) const issues = autoModelIssues(config, modelCache.list()) if (issues.length) return json(req, res, 400, { error: issues.join(' ') }) - autoModelConfig.write(config) - return json(req, res, 200, { config, issues: [] }) + const saved = autoModelConfig.write(config) + return json(req, res, 200, { config: saved, issues: [] }) } catch { return json(req, res, 400, { error: 'Invalid Auto settings. Check the profiles, fallback, and router.' }) } diff --git a/src/http/services/auto-model.ts b/src/http/services/auto-model.ts index 7afb9f42..f0e6ccdc 100644 --- a/src/http/services/auto-model.ts +++ b/src/http/services/auto-model.ts @@ -1,5 +1,4 @@ import path from 'node:path' -import { AutoModelConfigStore } from '../../agents/auto-model/config.ts' import { chooseAutoModel, routingInput } from '../../agents/auto-model/decision.ts' import { runRouter } from '../../agents/auto-model/provider.ts' import { AutoModelQueue, type AutoTarget } from '../../agents/auto-model/queue.ts' @@ -26,7 +25,7 @@ export function createAutoModelServices(services: BaseServices & DeliveryService deliverPrompt, STAGED_ATTACHMENTS_DIR } = services - const autoModelConfig = new AutoModelConfigStore(path.join(stateDir(), 'auto-model.json')) + const autoModelConfig = services.agentStore.autoModel const received = (job: AutoModelJob) => !!( job.sessionId && diff --git a/src/http/services/base.ts b/src/http/services/base.ts index bbdb171e..b168cf7d 100644 --- a/src/http/services/base.ts +++ b/src/http/services/base.ts @@ -1,8 +1,8 @@ import crypto from 'node:crypto' import path from 'node:path' +import { AgentStore } from '../../agents/agent-store.ts' import { ModelCache } from '../../agents/model-cache.ts' -import { RoleStore } from '../../agents/roles.ts' import { loadConfig, stateDir } from '../../config.ts' import { ConductorDb } from '../../db.ts' import { DevServerController } from '../../dev-server/controller.ts' @@ -85,7 +85,9 @@ export function createBaseServices() { const toolUsage = new ToolUsageService(cfg.dbPath) - const roleStore = new RoleStore(path.join(stateDir(), 'roles.json')) + const agentStore = new AgentStore(path.join(stateDir(), 'agents')) + const roleStore = agentStore.roles + const routingConfig = agentStore.routing const orchestration = new OrchestrationDb(path.join(stateDir(), 'orchestration.db'), { processProbe: processIdentityAlive @@ -295,7 +297,9 @@ export function createBaseServices() { search, planUsage, toolUsage, + agentStore, roleStore, + routingConfig, devServers, recoverUiLease, liveDelegationStores diff --git a/src/routes.ts b/src/routes.ts index 1a1542a8..7321b296 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -126,7 +126,12 @@ export const routes = { pushSubscribe: flat('POST', '/api/push/subscribe'), pushUnsubscribe: flat('POST', '/api/push/unsubscribe'), pushTest: flat('POST', '/api/push/test'), - /** Global picker-backed delegated-role definitions. */ + /** Canonical Markdown definitions and routing globals. */ + agents: flat('GET', '/api/agents'), + updateAgents: flat('PATCH', '/api/agents'), + routing: flat('GET', '/api/routing'), + updateRouting: flat('PATCH', '/api/routing'), + /** Compatibility views for cached PWAs and MCP. */ roles: flat('GET', '/api/roles'), autoModelConfig: flat('GET', '/api/auto-model'), updateAutoModelConfig: flat('PATCH', '/api/auto-model'), diff --git a/src/wire.ts b/src/wire.ts index 9940c78a..e213c50b 100644 --- a/src/wire.ts +++ b/src/wire.ts @@ -21,6 +21,7 @@ * The one exception is `src/shared.ts`, which is stdlib-free on purpose. */ +import type { AutoModelTuple } from './agents/auto-model/types.ts' import type { DefaultEfforts } from './agents/conductor-settings.ts' import type { CachedModelGroup } from './agents/model-cache.ts' import type { FirstPrompt } from './delivery/firstprompt.ts' @@ -117,12 +118,44 @@ export interface ResolvedDelegatedRole extends DelegatedRole { agentType: string } -/** The versioned document persisted at `stateDir()/roles.json`. */ +/** Compatibility view of the canonical Markdown agent definitions. */ export interface RolesConfig { version: 1 roles: Record } +/** One `stateDir()/agents/.md` file; unknown frontmatter stays on disk. */ +export interface AgentDefinition { + name: string + description?: string + model: string + effort?: AgentEffort + fast?: boolean + routing?: boolean + /** Verbatim Markdown body, applied only to delegation. */ + preamble?: string +} + +export interface AgentsConfig { + version: 1 + agents: AgentDefinition[] +} + +/** Routing globals in routing.json; profiles are derived from agent files. */ +export interface RoutingConfig { + version: 1 + defaultAuto: boolean + router: AutoModelTuple + fallback: string + rules: string + timeoutMs: number +} + +export interface RoutingConfigResponse { + config: RoutingConfig + issues: string[] +} + export type DelegationReturnMode = 'queue' | 'steer' export type DelegationStatus = @@ -416,6 +449,15 @@ export type UpdateRolesResult = | { ok: true; config: RolesConfig } | { ok: false; error: DelegationError; issues?: Array<{ role: string; error: DelegationError }> } +export interface AgentsResponse extends AgentsConfig { + issues: Array<{ agent: string; error: DelegationError }> + warning?: string +} + +export type UpdateAgentsResult = + | { ok: true; config: AgentsResponse } + | { ok: false; error: DelegationError; issues?: Array<{ agent: string; error: DelegationError }> } + export interface DelegationsResponse { delegations: DelegationProjection[] } diff --git a/tests/agents/agent-store.test.ts b/tests/agents/agent-store.test.ts new file mode 100644 index 00000000..397cb47e --- /dev/null +++ b/tests/agents/agent-store.test.ts @@ -0,0 +1,412 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { decodeAgents, parseAgentFile, serializeAgentFile } from '../../src/agents/agent-file.ts' +import { AgentStore } from '../../src/agents/agent-store.ts' +import { DEFAULT_AUTO_MODEL_CONFIG, freezeAutoModelConfig } from '../../src/agents/auto-model/config.ts' +import { DEFAULT_ROLES } from '../../src/agents/roles.ts' +import { decodeRoutingConfig, routingGlobals, routingIssues } from '../../src/agents/routing.ts' +import type { AgentDefinition, AutoModelConfig, RolesConfig } from '../../src/wire.ts' + +const directories: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const dir of directories.splice(0)) fs.rmSync(dir, { recursive: true, force: true }) +}) + +const globals = () => routingGlobals(DEFAULT_AUTO_MODEL_CONFIG) +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-store-test-')) + directories.push(root) + const directory = path.join(root, 'agents') + const store = new AgentStore(directory) + const file = (name: string) => path.join(directory, `${name}.md`) + const readFile = (name: string) => fs.readFileSync(file(name), 'utf8') + const save = (agents: AgentDefinition[]) => { + expect(store.write({ version: 1, agents })).toMatchObject({ ok: true }) + } + return { root, directory, store, file, readFile, save } +} + +describe('flat agent frontmatter', () => { + test('round-trips unknown blocks, comments, mixed newlines and the Markdown body byte for byte', () => { + const unknown = + 'tools:\r\n - Read\r\n - "Bash(node:*)"\r\n# retained comment\n \r\ncolor: purple\r\npermissions:\r\n shell: deny\r\n' + const body = '\r\n# Instructions\r\n\r\nUse $HOME literally. \r\n---\nFinal line' + const source = `---\r\nmodel: '5.6 Sol' # picker\r\n${unknown}effort: high\r\n---\r\n${body}` + const parsed = parseAgentFile(source) + expect(parsed.fields).toEqual({ model: '5.6 Sol', effort: 'high' }) + expect(parsed.body).toBe(body) + expect(serializeAgentFile(parsed)).toBe(source) + expect(serializeAgentFile(parsed, { model: '5.6 Sol', effort: 'high' })).toBe(source) + const rewritten = serializeAgentFile(parsed, { model: '5.6 Terra', fast: false, effort: undefined }) + expect(rewritten).toContain(unknown) + expect(rewritten).toContain('model: "5.6 Terra"\r\n') + expect(rewritten).toContain('fast: false\r\n') + expect(rewritten).not.toContain('effort:') + expect(parseAgentFile(rewritten).body).toBe(body) + }) + + test('decodes bare, single-quoted and double-quoted scalars without treating unknown YAML as values', () => { + const parsed = parseAgentFile(`--- +description: 'It''s for "small" tasks: # literal' # comment +model: "5.6 Sol" +effort: 'xhigh' +fast: "false" +routing: true +tools: [Read, Bash] +--- +body`) + expect(parsed.fields).toEqual({ + description: 'It\'s for "small" tasks: # literal', + model: '5.6 Sol', + effort: 'xhigh', + fast: false, + routing: true + }) + const description = 'A "quote", a slash \\ and a\nnewline' + expect(parseAgentFile(serializeAgentFile(parsed, { description })).fields.description).toBe(description) + expect(parseAgentFile('---\nmodel: 5.6 Sol # exact\ndescription: bare: text\n---').fields).toEqual({ + model: '5.6 Sol', + description: 'bare: text' + }) + }) + + test('preserves files without frontmatter and can add a header without changing their body', () => { + for (const source of ['', '# A role\n\nBody with trailing spaces \n', 'No trailing newline']) { + const parsed = parseAgentFile(source) + expect(serializeAgentFile(parsed)).toBe(source) + expect(parsed.body).toBe(source) + const added = serializeAgentFile(parsed, { model: '5.6 Sol' }) + expect(parseAgentFile(added)).toMatchObject({ fields: { model: '5.6 Sol' }, body: source }) + } + expect(serializeAgentFile(parseAgentFile('---\nmodel: 5.6 Sol\n---'))).toBe('---\nmodel: 5.6 Sol\n---') + }) + + test('keeps continuations with their key across blank and comment lines', () => { + const unknown = 'tools:\n - Read\n\n# still the tools block\n - Bash\n' + const source = `---\n${unknown}model: 5.6 Sol\n---\nBody` + const parsed = parseAgentFile(source) + expect(parsed.blocks.find(block => block.key === 'tools')?.lines.join('')).toBe(unknown) + expect(serializeAgentFile(parsed, { model: '5.6 Terra' })).toContain(unknown) + for (const separator of ['\n', '# comment\n', '# comment: containing a colon\n']) { + expect(() => + parseAgentFile(`---\ndescription: first line\n${separator} continued value\nmodel: 5.6 Sol\n---\n`) + ).toThrow('flat scalar') + } + }) + + test.each([ + '---\nmodel: 5.6 Sol', + '---\nmodel: 5.6 Sol\nmodel: 5.6 Terra\n---', + '---\nfast: yes\n---', + '---\neffort: turbo\n---', + '---\nmodel: "unterminated\n---', + '---\ndescription: |\n multiline\n---', + '---\nmodel: 5.6 Sol\n nested: value\n---' + ])('refuses ambiguous or unsupported known values: %s', source => { + expect(() => parseAgentFile(source)).toThrow() + }) +}) + +describe('agent migration', () => { + test('copies role tuples and bodies, merges only profile descriptions on collision, and leaves legacy bytes unchanged', () => { + const f = fixture() + const roles: RolesConfig = { + version: 1, + roles: { + exploration: { model: '5.6 Sol', effort: 'xhigh', fast: true, preamble: '\n# Explore\nNo trimming. \n' }, + implementation: { model: '5.6 Terra', preamble: 'Implement.' } + } + } + const auto: AutoModelConfig = { + ...DEFAULT_AUTO_MODEL_CONFIG, + defaultAuto: true, + fallback: 'quick', + rules: 'Custom rules.', + timeoutMs: 8000, + profiles: [ + { id: 'exploration', model: '5.6 Luna', effort: 'low', fast: false, description: 'Find evidence.' }, + { id: 'quick', model: '5.6 Luna', effort: 'low', description: 'Small changes.' } + ] + } + const legacy = [ + ['roles.json', JSON.stringify(roles, null, 2)], + ['auto-model.json', JSON.stringify(auto, null, 2)] + ] as const + for (const [name, contents] of legacy) fs.writeFileSync(path.join(f.root, name), contents) + expect(f.store.read()).toEqual({ + version: 1, + agents: [ + { name: 'exploration', ...roles.roles.exploration, description: 'Find evidence.' }, + { name: 'implementation', ...roles.roles.implementation }, + { name: 'quick', model: '5.6 Luna', effort: 'low', description: 'Small changes.', preamble: '' } + ] + }) + expect(f.store.roles.read().config.roles.exploration).toEqual(roles.roles.exploration) + expect(f.store.autoModel.read().profiles.find(p => p.id === 'exploration')).toEqual({ + id: 'exploration', + model: '5.6 Sol', + effort: 'xhigh', + fast: true, + description: 'Find evidence.' + }) + expect(JSON.parse(fs.readFileSync(path.join(f.root, 'routing.json'), 'utf8'))).toEqual(routingGlobals(auto)) + for (const [name, contents] of legacy) expect(fs.readFileSync(path.join(f.root, name), 'utf8')).toBe(contents) + for (const name of ['exploration', 'implementation', 'quick']) + expect(fs.statSync(f.file(name)).mode & 0o777).toBe(0o600) + expect(fs.statSync(path.join(f.root, 'routing.json')).mode & 0o777).toBe(0o600) + expect(fs.readdirSync(f.root).sort()).toEqual(['agents', 'auto-model.json', 'roles.json', 'routing.json']) + // An old relay's later edits are a frozen legacy snapshot, not another live authority. + fs.writeFileSync(path.join(f.root, 'roles.json'), 'broken') + expect(new AgentStore(f.directory).roles.read().config.roles.exploration).toEqual(roles.roles.exploration) + }) + + test('uses both shipped defaults when legacy files are absent without creating legacy JSON', () => { + const f = fixture() + expect(f.store.read().agents).toHaveLength(8) + expect(f.store.roles.read().config.roles.exploration).toEqual(DEFAULT_ROLES.roles.exploration) + expect(f.store.routing.read()).toEqual(globals()) + expect(fs.readdirSync(f.root).sort()).toEqual(['agents', 'routing.json']) + }) + + test.each([ + 'roles.json', + 'auto-model.json' + ])('preserves corrupt legacy %s without publishing a partial migration', name => { + const f = fixture() + fs.writeFileSync(path.join(f.root, name), '{broken') + expect(f.store.read().warning).toContain(name) + expect(fs.existsSync(f.directory)).toBe(false) + expect(fs.readFileSync(path.join(f.root, name), 'utf8')).toBe('{broken') + fs.unlinkSync(path.join(f.root, name)) + expect(f.store.read().warning).toBeUndefined() + }) +}) + +describe('canonical agent store and compatibility views', () => { + test('patches known fields while preserving unknown bytes and body, and deletes omitted files', () => { + const f = fixture() + f.store.read() + const body = '\n# Custom instructions\n\nDo this exactly. ' + const unknown = 'tools:\n - Read\n - Bash\ncolor: green\n' + fs.writeFileSync(f.file('custom'), `---\n${unknown}model: '5.6 Sol'\n---\n${body}`) + const custom = f.store.read().agents.find(a => a.name === 'custom')! + f.save([{ ...custom, model: '5.6 Terra', description: 'Bounded edits.' }]) + expect(f.readFile('custom')).toContain(unknown) + expect(parseAgentFile(f.readFile('custom')).body).toBe(body) + expect(fs.readdirSync(f.directory)).toEqual(['custom.md']) + expect(fs.statSync(f.file('custom')).mode & 0o777).toBe(0o600) + f.save([]) + expect(f.store.read()).toEqual({ version: 1, agents: [] }) + expect(fs.readdirSync(f.directory)).toEqual([]) + }) + + test('sees hand edits, additions and deletions across store instances and returns detached values', () => { + const f = fixture() + f.save([{ name: 'custom', model: '5.6 Sol' }]) + const other = new AgentStore(f.directory) + expect(other.read().agents[0].model).toBe('5.6 Sol') + fs.writeFileSync(f.file('custom'), '---\nmodel: 5.6 Terra\n---\nNew body') + expect(other.read().agents[0]).toMatchObject({ model: '5.6 Terra', preamble: 'New body' }) + const detached = other.read() + detached.agents[0].model = 'Mutated outside the cache' + expect(other.read().agents[0].model).toBe('5.6 Terra') + fs.writeFileSync(f.file('new'), '---\nmodel: 5.6 Luna\n---\n') + expect(other.read().agents).toHaveLength(2) + fs.unlinkSync(f.file('custom')) + expect(other.read().agents.map(a => a.name)).toEqual(['new']) + }) + + test('reports undecodable files and refuses a batch that would silently delete or replace them', () => { + const f = fixture() + f.save([{ name: 'valid', model: '5.6 Sol' }]) + const bad = '---\nmodel: 5.6 Sol\nfast: maybe\n---\nKeep me.' + fs.writeFileSync(f.file('bad'), bad) + const read = f.store.read() + expect(read.agents.map(a => a.name)).toEqual(['valid']) + expect(read.warning).toContain('bad.md') + expect(f.store.roles.read().warning).toBe(read.warning) + expect(f.store.write({ version: 1, agents: [] })).toMatchObject({ ok: false }) + expect(f.store.write({ version: 1, agents: [{ name: 'bad', model: '5.6 Terra' }] })).toMatchObject({ ok: false }) + expect(fs.readdirSync(f.directory)).toEqual(['bad.md', 'valid.md']) + expect(f.readFile('bad')).toBe(bad) + fs.writeFileSync(f.file('bad'), '---\nmodel: 5.6 Terra\n---\nFixed.') + expect(f.store.read().warning).toBeUndefined() + }) + + test('does not follow symlinks or hide a body-only file with a missing model', () => { + const f = fixture() + f.save([]) + fs.writeFileSync(path.join(f.root, 'outside.md'), '---\nmodel: 5.6 Sol\n---\nPrivate') + fs.symlinkSync(path.join(f.root, 'outside.md'), f.file('link')) + fs.writeFileSync(f.file('body'), '# Instructions only') + expect(f.store.read()).toMatchObject({ agents: [], warning: expect.stringContaining('link.md') }) + expect(f.store.read().warning).toContain('body.md') + }) + + test('reports invalid UTF-8 without replacing opaque bytes during a rewrite', () => { + const f = fixture() + f.save([{ name: 'valid', model: '5.6 Sol' }]) + const bytes = Buffer.concat([ + Buffer.from('---\nmodel: 5.6 Sol\nunknown: '), + Buffer.from([0xff]), + Buffer.from('\n---\nKeep the original bytes.') + ]) + fs.writeFileSync(f.file('invalid'), bytes) + expect(f.store.read()).toMatchObject({ + agents: [{ name: 'valid', model: '5.6 Sol', preamble: '' }], + warning: expect.stringContaining('invalid.md') + }) + expect(f.store.read().warning).toContain('UTF-8') + expect(f.store.write({ version: 1, agents: [{ name: 'invalid', model: '5.6 Terra' }] })).toMatchObject({ + ok: false + }) + expect(fs.readFileSync(f.file('invalid'))).toEqual(bytes) + }) + + test('rejects invalid or excessive rosters before persisting anything', () => { + const f = fixture() + f.save([{ name: 'valid', model: '5.6 Sol' }]) + const before = f.readFile('valid') + for (const agents of [ + [{ name: '../escape', model: '5.6 Sol' }], + [{ name: 'UPPER', model: '5.6 Sol' }], + [{ name: 'valid', model: '5.6 Sol', plan: true }], + [{ name: 'valid', model: '5.6 Sol', description: 'x'.repeat(1001) }], + [{ name: 'valid', model: '5.6 Sol', preamble: 'x'.repeat(50_001) }], + [{ name: 'valid', model: '5.6 Sol', routing: 'false' }], + [ + { name: 'valid', model: '5.6 Sol' }, + { name: 'valid', model: '5.6 Terra' } + ], + Array.from({ length: 33 }, (_, n) => ({ name: `agent-${n}`, model: '5.6 Sol' })) + ]) + expect(f.store.write({ version: 1, agents })).toMatchObject({ ok: false }) + expect(f.readFile('valid')).toBe(before) + expect(fs.readdirSync(f.directory)).toEqual(['valid.md']) + expect(() => decodeAgents({ version: 2, agents: [] })).toThrow() + }) + + test('roles PATCH changes tuples/body, removes omitted agents, and leaves surviving routing metadata verbatim', () => { + const f = fixture() + f.save([{ name: 'custom', model: '5.6 Sol', description: 'Route here.', routing: false }]) + fs.writeFileSync( + f.file('custom'), + `---\nmodel: 5.6 Sol\ndescription: 'Route here.' # keep\nrouting: false\ntools:\n - Read\n---\nOld body` + ) + expect( + f.store.roles.write({ version: 1, roles: { custom: { model: '5.6 Luna', fast: false, preamble: 'New body\n' } } }) + ).toMatchObject({ ok: true }) + expect(f.readFile('custom')).toContain("description: 'Route here.' # keep\nrouting: false\ntools:\n - Read\n") + expect(f.store.read().agents[0]).toEqual({ + name: 'custom', + model: '5.6 Luna', + fast: false, + description: 'Route here.', + routing: false, + preamble: 'New body\n' + }) + expect(f.store.roles.write({ version: 1, roles: { replacement: { model: '5.6 Terra' } } })).toMatchObject({ + ok: true + }) + expect(fs.readdirSync(f.directory)).toEqual(['replacement.md']) + }) + + test('derives only descriptions opted into routing and keeps delegation instructions out of Auto', () => { + const f = fixture() + f.save([ + { + name: 'fallback', + model: '5.6 Sol', + effort: 'high', + fast: false, + description: ' Fallback. ', + preamble: 'Do not leak.' + }, + { name: 'included', model: '5.6 Luna', description: 'Cheap.', routing: true }, + { name: 'excluded', model: '5.6 Terra', description: 'Disabled.', routing: false }, + { name: 'empty', model: '5.6 Sol', description: ' ' }, + { name: 'role', model: '5.6 Sol' } + ]) + f.store.routing.write({ ...globals(), fallback: 'fallback' }) + const config = f.store.autoModel.read() + expect(config.profiles).toEqual([ + { id: 'fallback', model: '5.6 Sol', effort: 'high', fast: false, description: 'Fallback.' }, + { id: 'included', model: '5.6 Luna', description: 'Cheap.' } + ]) + expect(JSON.stringify(config)).not.toContain('Do not leak.') + for (const fallback of ['absent', 'excluded', 'empty', 'role']) + expect(() => f.store.routing.write({ ...globals(), fallback })).toThrow('Choose an existing fallback profile.') + f.save(f.store.read().agents.filter(a => a.name !== 'fallback')) + expect(() => f.store.autoModel.read()).toThrow('Choose an existing fallback profile.') + expect(f.store.routing.read().fallback).toBe('fallback') + }) + + test('supports 32 routable agents and retains existing unavailable-fallback messages', () => { + const f = fixture() + f.save(Array.from({ length: 32 }, (_, n) => ({ name: `agent-${n}`, model: '5.6 Sol', description: 'Task.' }))) + f.store.routing.write({ ...globals(), fallback: 'agent-0' }) + expect(f.store.autoModel.read().profiles).toHaveLength(32) + expect(() => freezeAutoModelConfig(f.store.autoModel.read(), [])).toThrow( + 'Auto’s fallback model is unavailable. Update Auto settings.' + ) + }) + + test('Auto PATCH upserts profiles, clears removed descriptions, and keeps role files, bodies and unknown frontmatter', () => { + const f = fixture() + f.save([ + { name: 'kept', model: '5.6 Sol', description: 'Old.', preamble: '# Keep body\n' }, + { name: 'removed', model: '5.6 Sol', description: 'Remove description.' }, + { name: 'disabled', model: '5.6 Sol', description: 'Keep opt-out.', routing: false }, + { name: 'role', model: '5.6 Terra', preamble: 'Role only.' } + ]) + fs.writeFileSync(f.file('kept'), '---\nmodel: 5.6 Sol\ndescription: Old.\ntools:\n - Read\n---\n# Keep body\n') + f.store.routing.write({ ...globals(), fallback: 'kept' }) + const saved = f.store.autoModel.write({ + ...globals(), + defaultAuto: true, + fallback: 'new', + profiles: [ + { id: 'kept', model: '5.6 Luna', fast: false, description: 'New description.' }, + { id: 'new', model: '5.6 Sol', description: 'New profile.' } + ] + }) + expect(saved.defaultAuto).toBe(true) + expect(saved.profiles.map(p => p.id)).toEqual(['kept', 'new']) + expect(f.readFile('kept')).toContain('tools:\n - Read\n') + expect(f.store.roles.read().config.roles.kept).toMatchObject({ + model: '5.6 Luna', + fast: false, + preamble: '# Keep body\n' + }) + expect(f.store.read().agents.find(a => a.name === 'removed')?.description).toBeUndefined() + expect(f.store.read().agents.find(a => a.name === 'disabled')).toMatchObject({ + description: 'Keep opt-out.', + routing: false + }) + expect(fs.readdirSync(f.directory)).toEqual(['disabled.md', 'kept.md', 'new.md', 'removed.md', 'role.md']) + // Legacy editors can explicitly re-include an opted-out file. + f.store.autoModel.write({ + ...saved, + profiles: [...saved.profiles, { id: 'disabled', model: '5.6 Sol', description: 'Enabled.' }] + }) + expect(f.store.autoModel.read().profiles.map(p => p.id)).toContain('disabled') + }) + + test('routing validation isolates router/fallback issues and rejects profiles in globals', () => { + const agents = [ + { name: 'fallback', model: '5.6 Sol', description: 'Fallback.' }, + { name: 'optional', model: 'missing', description: 'Unavailable.' } + ] + const config = { ...globals(), fallback: 'fallback' } + const catalog = [{ agentType: 'codex', models: ['5.6 Luna', '5.6 Sol'], updatedAt: 1 }] + expect(routingIssues(config, agents, catalog)).toEqual([]) + expect(routingIssues({ ...config, fallback: 'optional' }, agents, catalog)).toEqual([ + expect.stringContaining('profile_optional') + ]) + expect(routingIssues({ ...config, router: { model: 'Fable 5' } }, agents, catalog)[0]).toContain('router supports') + expect(() => decodeRoutingConfig({ ...config, profiles: [] })).toThrow() + }) +}) diff --git a/tests/http/agents.test.ts b/tests/http/agents.test.ts new file mode 100644 index 00000000..68aaf6aa --- /dev/null +++ b/tests/http/agents.test.ts @@ -0,0 +1,235 @@ +import { once } from 'node:events' +import fs from 'node:fs' +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { AgentStore } from '../../src/agents/agent-store.ts' +import { DEFAULT_AUTO_MODEL_CONFIG } from '../../src/agents/auto-model/config.ts' +import { routingGlobals } from '../../src/agents/routing.ts' +import { createRelayServer } from '../../src/http/router.ts' +import { createResponsesServices } from '../../src/http/services/responses.ts' +import type { RelayServices } from '../../src/http/services.ts' +import type { AgentDefinition, AgentsResponse, RolesResponse, UpdateAgentsResult } from '../../src/wire.ts' + +const servers: Server[] = [] +const directories: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all( + servers.splice(0).map(server => { + server.closeAllConnections() + return new Promise((resolve, reject) => server.close(error => (error ? reject(error) : resolve()))) + }) + ) + for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true }) +}) + +/** Real auth, dispatch, HTTP envelopes and stores; no DB, GUI, classifier or live relay. */ +async function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-http-test-')) + directories.push(root) + const cfg = { + token: 'agent-http-token', + dbPath: '/unused/db', + workspacesRoot: '/unused/workspaces', + publicDir: '/unused/public', + port: 0, + host: '127.0.0.1', + writeStrategy: 'applescript' as const, + preventScreenLock: false + } + const store = new AgentStore(path.join(root, 'agents')) + const seed: AgentDefinition[] = [ + { name: 'helper', model: '5.6 Sol', effort: 'high', description: 'Bounded work.', preamble: '# Delegate\n' }, + { name: 'extra', model: '5.6 Luna', description: 'Simple work.' } + ] + expect(store.write({ version: 1, agents: seed })).toMatchObject({ ok: true }) + store.routing.write({ ...routingGlobals(DEFAULT_AUTO_MODEL_CONFIG), fallback: 'helper' }) + const services = { + ...createResponsesServices({ cfg }), + agentStore: store, + roleStore: store.roles, + autoModelConfig: store.autoModel, + routingConfig: store.routing, + modelCache: { + list: () => [ + { + agentType: 'codex', + models: ['5.6 Sol', '5.6 Luna', '5.6 Terra', 'opencode-go/muse-spark-1.3-contributor'], + updatedAt: 1 + } + ] + }, + workflowHttpError: () => null + } as unknown as RelayServices + const server = createRelayServer(services) + servers.push(server) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + const request = (pathname: string, method = 'GET', body?: unknown, authenticated = true) => + fetch(`${base}${pathname}`, { + method, + headers: authenticated ? { authorization: `Bearer ${cfg.token}` } : {}, + ...(body !== undefined ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(5000) + }) + const file = (name: string) => path.join(root, 'agents', `${name}.md`) + return { root, store, request, seed, file } +} + +describe('agent and routing HTTP contracts', () => { + test('authenticates all four new routes before reading or writing', async () => { + const f = await fixture() + const read = vi.spyOn(f.store, 'read') + const write = vi.spyOn(f.store, 'write') + for (const endpoint of ['/api/agents', '/api/routing']) { + for (const method of ['GET', 'PATCH']) + expect((await f.request(endpoint, method, undefined, false)).status).toBe(401) + } + expect(read).not.toHaveBeenCalled() + expect(write).not.toHaveBeenCalled() + }) + + test('serves editable definitions and patches body/known keys without exposing or losing unknown frontmatter', async () => { + const f = await fixture() + const unknown = 'tools:\n - Read\npermissions:\n shell: deny\n' + fs.writeFileSync(f.file('helper'), `---\nmodel: 5.6 Sol\ndescription: Bounded work.\n${unknown}---\n# Delegate\n`) + const get = await f.request('/api/agents') + expect(get.status).toBe(200) + const read: AgentsResponse = await get.json() + expect(read).toMatchObject({ version: 1, issues: [] }) + expect(read.agents.find(a => a.name === 'helper')).toEqual({ + name: 'helper', + model: '5.6 Sol', + description: 'Bounded work.', + preamble: '# Delegate\n' + }) + expect(JSON.stringify(read)).not.toContain('permissions') + const patch = await f.request('/api/agents', 'PATCH', { + version: 1, + agents: [{ ...read.agents.find(a => a.name === 'helper'), model: '5.6 Terra', preamble: '\n# Changed body\n\n' }] + }) + expect(patch.status).toBe(200) + const result: UpdateAgentsResult = await patch.json() + expect(result).toEqual({ + ok: true, + config: { + version: 1, + agents: [ + { name: 'helper', model: '5.6 Terra', description: 'Bounded work.', preamble: '\n# Changed body\n\n' } + ], + issues: [] + } + }) + expect(fs.readFileSync(f.file('helper'), 'utf8')).toContain(unknown) + expect(fs.existsSync(f.file('extra'))).toBe(false) + const roles: RolesResponse = await (await f.request('/api/roles')).json() + expect(roles.roles.helper).toEqual({ model: '5.6 Terra', preamble: '\n# Changed body\n\n' }) + }) + + test('rejects every invalid agent before writing and returns role-compatible 409 issues keyed by agent', async () => { + const f = await fixture() + const before = fs.readFileSync(f.file('helper'), 'utf8') + for (const raw of [ + '{bad', + { version: 2, agents: f.seed }, + { version: 1, agents: [f.seed[0], f.seed[0]] }, + { version: 1, agents: [{ ...f.seed[0], tools: ['Read'] }] } + ]) { + const result = await f.request('/api/agents', 'PATCH', raw) + expect(result.status).toBe(400) + expect(await result.json()).toMatchObject({ ok: false, error: { code: 'invalid_request', retryable: false } }) + } + for (const agent of [ + { ...f.seed[0], model: 'no such model' }, + { ...f.seed[0], model: 'opencode-go/muse-spark-1.3-contributor', effort: 'high' } + ]) { + const result = await f.request('/api/agents', 'PATCH', { version: 1, agents: [agent] }) + expect(result.status).toBe(409) + const body = await result.json() + expect(body).toEqual({ ok: false, error: body.issues[0].error, issues: [{ agent: 'helper', error: body.error }] }) + } + expect(fs.readFileSync(f.file('helper'), 'utf8')).toBe(before) + expect(fs.existsSync(f.file('extra'))).toBe(true) + }) + + test('exposes invalid-file warnings and refuses a destructive whole-roster save', async () => { + const f = await fixture() + const bad = '---\nmodel: 5.6 Sol\nfast: maybe\n---\nKeep this.' + fs.writeFileSync(f.file('bad'), bad) + const get = await (await f.request('/api/agents')).json() + expect(get.warning).toContain('bad.md') + expect(get.agents).toHaveLength(2) + const patch = await f.request('/api/agents', 'PATCH', { version: 1, agents: f.seed }) + expect(patch.status).toBe(500) + expect(await patch.json()).toMatchObject({ ok: false, error: { code: 'state_invalid' } }) + expect(fs.readFileSync(f.file('bad'), 'utf8')).toBe(bad) + }) + + test('roles compatibility writes preserve description and unknown lines while updating tuples and delegation body', async () => { + const f = await fixture() + fs.appendFileSync(f.file('helper'), 'Additional body.\n') + const patch = await f.request('/api/roles', 'PATCH', { + version: 1, + roles: { helper: { model: '5.6 Terra', fast: false, preamble: 'Replacement body.' } } + }) + expect(patch.status).toBe(200) + expect(await patch.json()).toEqual({ + ok: true, + config: { version: 1, roles: { helper: { model: '5.6 Terra', fast: false, preamble: 'Replacement body.' } } } + }) + expect(f.store.read().agents).toEqual([ + { name: 'helper', model: '5.6 Terra', fast: false, description: 'Bounded work.', preamble: 'Replacement body.' } + ]) + const auto = await (await f.request('/api/auto-model')).json() + expect(auto.config.profiles).toEqual([ + { id: 'helper', model: '5.6 Terra', fast: false, description: 'Bounded work.' } + ]) + }) + + test('Auto compatibility writes update shared tuples, preserve bodies and retain removed profile files', async () => { + const f = await fixture() + const config = { + ...f.store.autoModel.read(), + defaultAuto: true, + fallback: 'new', + profiles: [ + { id: 'helper', model: '5.6 Terra', description: 'New description.' }, + { id: 'new', model: '5.6 Sol', description: 'New profile.' } + ] + } + const result = await f.request('/api/auto-model', 'PATCH', config) + expect(result.status).toBe(200) + expect(await result.json()).toEqual({ config, issues: [] }) + expect(f.store.roles.read().config.roles.helper).toEqual({ model: '5.6 Terra', preamble: '# Delegate\n' }) + expect(f.store.read().agents.find(a => a.name === 'extra')?.description).toBeUndefined() + expect(fs.existsSync(f.file('extra'))).toBe(true) + expect((await (await f.request('/api/routing')).json()).config).toEqual(routingGlobals(config)) + }) + + test('routing endpoints validate globals, router and fallback without blocking on optional profile models', async () => { + const f = await fixture() + fs.writeFileSync(f.file('extra'), '---\nmodel: missing\ndescription: Optional.\n---\n') + const read = await (await f.request('/api/routing')).json() + expect(read).toEqual({ config: f.store.routing.read(), issues: [] }) + expect(read.config).not.toHaveProperty('profiles') + const config = { ...read.config, rules: 'Updated rules.', defaultAuto: true, timeoutMs: 8000 } + const patch = await f.request('/api/routing', 'PATCH', config) + expect(patch.status).toBe(200) + expect(await patch.json()).toEqual({ config, issues: [] }) + for (const invalid of [ + { ...config, fallback: 'absent' }, + { ...config, fallback: 'extra' }, + { ...config, router: { model: 'Fable 5' } }, + { ...config, profiles: [] }, + { ...config, timeoutMs: 1 } + ]) + expect((await f.request('/api/routing', 'PATCH', invalid)).status).toBe(400) + expect(f.store.routing.read()).toEqual(config) + fs.unlinkSync(f.file('helper')) + expect((await (await f.request('/api/routing')).json()).issues).toContain('Choose an existing fallback profile.') + }) +}) diff --git a/tests/http/router.test.ts b/tests/http/router.test.ts index c216ec65..f4dfa280 100644 --- a/tests/http/router.test.ts +++ b/tests/http/router.test.ts @@ -5,7 +5,8 @@ import type { AddressInfo } from 'node:net' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, test, vi } from 'vitest' -import { AutoModelConfigStore, DEFAULT_AUTO_MODEL_CONFIG } from '../../src/agents/auto-model/config.ts' +import { AgentStore } from '../../src/agents/agent-store.ts' +import { DEFAULT_AUTO_MODEL_CONFIG } from '../../src/agents/auto-model/config.ts' import { AutoModelQueue } from '../../src/agents/auto-model/queue.ts' import type { Config } from '../../src/config.ts' import { SendOnce } from '../../src/delivery/sendonce.ts' @@ -133,7 +134,11 @@ async function autoFixture() { const f = fixture() const directory = await mkdtemp(path.join(os.tmpdir(), 'auto-http-test-')) temporaryDirectories.push(directory) - f.services.autoModelConfig = new AutoModelConfigStore(path.join(directory, 'config.json')) + const agents = new AgentStore(path.join(directory, 'agents')) + f.services.agentStore = agents + f.services.roleStore = agents.roles + f.services.routingConfig = agents.routing + f.services.autoModelConfig = agents.autoModel f.services.modelCache = { list: () => [{ agentType: 'codex', updatedAt: 1, models: DEFAULT_AUTO_MODEL_CONFIG.profiles.map(p => p.model) }] } as RelayServices['modelCache'] diff --git a/tests/http/routes.test.ts b/tests/http/routes.test.ts index 212c3472..45ad6638 100644 --- a/tests/http/routes.test.ts +++ b/tests/http/routes.test.ts @@ -6,6 +6,20 @@ const isParam = (route: Route0 | Route1): route is Route1 => 're' in route const samples = ['9008e4f4-9d58-4dbf-8c8e-6df0b618c2d0', 'conductor-remote', 'my repo', 'a/b', 'Ünicode name'] describe('route table', () => { + test('pins canonical agent/routing and legacy configuration endpoints', () => { + for (const [name, method, pattern] of [ + ['agents', 'GET', '/api/agents'], + ['updateAgents', 'PATCH', '/api/agents'], + ['routing', 'GET', '/api/routing'], + ['updateRouting', 'PATCH', '/api/routing'], + ['roles', 'GET', '/api/roles'], + ['updateRoles', 'PATCH', '/api/roles'], + ['autoModelConfig', 'GET', '/api/auto-model'], + ['updateAutoModelConfig', 'PATCH', '/api/auto-model'] + ] as const) + expect(routes[name]).toMatchObject({ method, pattern }) + }) + test('is populated entirely by API paths', () => { expect(entries.length).toBeGreaterThan(20) expect(entries.every(([, route]) => route.pattern.startsWith('/api/'))).toBe(true) From f4e54fd341d1768868a667cb979aedd85453c5ce Mon Sep 17 00:00:00 2001 From: Eivind Hyldmo <3465788+hyldmo@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:43:41 +0200 Subject: [PATCH 2/3] feat(agents): unified agents and routing editor --- tests/agents/agents-settings.test.tsx | 318 ++++++++++++++ tests/agents/model-picker.test.tsx | 2 +- .../delegation/delegation-ui.test.tsx | 23 +- web/src/components/agents/AgentControls.tsx | 6 +- web/src/components/agents/AgentEditorCard.tsx | 71 ++++ web/src/components/agents/AgentsSettings.tsx | 304 +++++++++++++ .../components/agents/AutoModelSettings.tsx | 191 --------- web/src/components/agents/ModelPicker.tsx | 2 +- web/src/components/agents/RoleEditorCard.tsx | 135 ++++++ .../agents/RoutingSettingsSection.tsx | 96 +++++ .../orchestration/DelegationBubbles.tsx | 2 +- web/src/components/orchestration/RoleChip.tsx | 8 + .../orchestration/RolesSettings.tsx | 399 ------------------ web/src/components/session/SessionTabs.tsx | 2 +- web/src/components/session/SessionView.tsx | 12 +- .../components/workspaces/WorkspaceList.tsx | 18 +- web/src/hooks/agents.ts | 16 + web/src/lib/agents-settings.ts | 122 ++++++ web/src/lib/api.ts | 22 +- web/src/lib/role-editor.ts | 65 +++ 20 files changed, 1186 insertions(+), 628 deletions(-) create mode 100644 tests/agents/agents-settings.test.tsx create mode 100644 web/src/components/agents/AgentEditorCard.tsx create mode 100644 web/src/components/agents/AgentsSettings.tsx delete mode 100644 web/src/components/agents/AutoModelSettings.tsx create mode 100644 web/src/components/agents/RoleEditorCard.tsx create mode 100644 web/src/components/agents/RoutingSettingsSection.tsx create mode 100644 web/src/components/orchestration/RoleChip.tsx delete mode 100644 web/src/components/orchestration/RolesSettings.tsx create mode 100644 web/src/lib/agents-settings.ts create mode 100644 web/src/lib/role-editor.ts diff --git a/tests/agents/agents-settings.test.tsx b/tests/agents/agents-settings.test.tsx new file mode 100644 index 00000000..4db180fc --- /dev/null +++ b/tests/agents/agents-settings.test.tsx @@ -0,0 +1,318 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import type { AgentDefinition, AgentsResponse, CachedModelGroup, RoutingConfig } from '../../src/wire.ts' +import { + agentRoutingLock, + copyAgents, + isRouterModel, + newAgentProblem, + normalizeAgentName, + routerModelProblem, + routingDraftProblems, + saveAgentsSettings +} from '../../web/src/lib/agents-settings.ts' + +// Keep the existing server-rendered component-test style: no Mac UI, relay or browser process. +vi.mock('react-dom', async original => ({ + ...(await original()), + createPortal: (children: ReactNode) => children +})) +vi.stubGlobal('document', { body: {} }) +vi.stubGlobal('location', { hash: '', pathname: '/', search: '' }) +vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => {}, removeItem: () => {} }) +vi.stubGlobal('history', { replaceState: () => {} }) + +const { AgentsSettings } = await import('../../web/src/components/agents/AgentsSettings.tsx') +const clients: QueryClient[] = [] +const groups: CachedModelGroup[] = [ + { + agentType: 'codex', + models: ['5.6 Sol', '5.6 Luna', 'Fable 5.1', 'opencode-go/muse-spark-1.3-contributor'], + updatedAt: 1 + } +] + +function roster(): AgentsResponse { + return { + version: 1, + agents: [ + { + name: 'exploration', + model: '5.6 Sol', + description: 'Read-only code searches.', + preamble: 'Inspect the repository.' + }, + { name: 'implementation', model: '5.6 Sol', description: '' }, + { name: 'manual', model: 'Fable 5.1', description: 'Manual delegation only.', routing: false } + ], + issues: [] + } +} + +function routing(): RoutingConfig { + return { + version: 1, + defaultAuto: false, + router: { model: '5.6 Luna', effort: 'low' }, + fallback: 'exploration', + rules: 'Choose the suitable agent.', + timeoutMs: 15000 + } +} + +function queryClient(agents = roster(), config = routing(), issues: string[] = []) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }) + client.setQueryData(['agents'], agents) + client.setQueryData(['routing'], { config, issues }) + client.setQueryData(['model-catalog'], { groups }) + client.setQueryData(['roles'], { cached: true }) + client.setQueryData(['auto-model-config'], { cached: true }) + clients.push(client) + return client +} + +function renderSheet(client = queryClient(), initial: 'agents' | 'routing' = 'agents') { + return renderToStaticMarkup( + + + + ) +} + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new Error('Unexpected network request'))) + ) +}) +afterEach(() => { + for (const client of clients.splice(0)) client.clear() + vi.restoreAllMocks() +}) +afterAll(() => vi.unstubAllGlobals()) + +describe('unified Agents sheet', () => { + test.each([ + '', + ' ' + ])('omits Auto participation and fallback eligibility for an empty description (%j)', description => { + const agents = roster() + agents.agents[1].description = description + const html = renderSheet(queryClient(agents)) + expect(html).toContain('Description — Auto routing reads this to pick') + expect(html).toContain('implementation') + expect(html).not.toContain('Use implementation in Auto routing') + expect(html).not.toContain('