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-import.ts b/src/agents/agent-import.ts new file mode 100644 index 00000000..27a689f2 --- /dev/null +++ b/src/agents/agent-import.ts @@ -0,0 +1,95 @@ +import { isUtf8 } from 'node:buffer' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { z } from 'zod' +import type { AgentImportScanResponse, ImportAgentsRequest } from '../wire.ts' +import { AGENT_NAME, decodeAgent, parseAgentFile } from './agent-file.ts' +import type { AgentStore } from './agent-store.ts' + +export const MAX_IMPORT_FILES = 64 +export const MAX_IMPORT_BYTES = 256 * 1024 + +const requestSchema = z + .object({ + names: z.array(z.string().min(1).max(256)).min(1).max(MAX_IMPORT_FILES), + overwrite: z.boolean().optional() + }) + .strict() + +export function decodeImportAgents(raw: unknown): ImportAgentsRequest { + return requestSchema.parse(raw) +} + +/** Resolve and open the same regular file, rejecting links and bounding even a growing file's read. */ +function readCandidate(directory: string, filename: string): Buffer { + const file = path.join(directory, filename) + const resolved = fs.realpathSync(file) + if (path.dirname(resolved) !== directory) throw new Error('Symlinks outside the agents directory are not imported.') + const stat = fs.lstatSync(file) + if (!stat.isFile()) throw new Error('Agent definitions must be regular files, not symlinks or directories.') + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK) + try { + const opened = fs.fstatSync(fd) + if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev) + throw new Error('The agent file changed while opening it. Refresh and try again.') + if (opened.size > MAX_IMPORT_BYTES) throw new Error(`Agent files must be at most ${MAX_IMPORT_BYTES / 1024} KiB.`) + const buffer = Buffer.alloc(MAX_IMPORT_BYTES + 1) + let size = 0 + while (size < buffer.length) { + const count = fs.readSync(fd, buffer, size, buffer.length - size, null) + if (!count) break + size += count + } + if (size > MAX_IMPORT_BYTES) throw new Error(`Agent files must be at most ${MAX_IMPORT_BYTES / 1024} KiB.`) + return buffer.subarray(0, size) + } finally { + fs.closeSync(fd) + } +} + +/** No recursive/repository discovery. POST re-scans so a changed or rejected file is never copied blind. */ +export function scanClaudeAgents( + store: AgentStore, + directory = path.join(os.homedir(), '.claude', 'agents') +): { response: AgentImportScanResponse; sources: Map } { + const response: AgentImportScanResponse = { candidates: [], skipped: [], truncated: false, limit: MAX_IMPORT_FILES } + const sources = new Map() + let root: string + try { + root = fs.realpathSync(directory) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { response, sources } + throw error + } + const names = fs + .readdirSync(root) + .filter(name => name.endsWith('.md')) + .sort() + response.truncated = names.length > MAX_IMPORT_FILES + const existing = new Set(store.names()) + for (const filename of names.slice(0, MAX_IMPORT_FILES)) { + const name = filename.slice(0, -3) + try { + if (!AGENT_NAME.test(name)) + throw new Error( + 'Use a filename starting with a letter and up to 64 lowercase letters, numbers, dashes or underscores.' + ) + const bytes = readCandidate(root, filename) + if (!isUtf8(bytes)) throw new Error('Agent definitions must be valid UTF-8.') + const parsed = parseAgentFile(bytes.toString('utf8')) + const agent = decodeAgent({ name, ...parsed.fields, preamble: parsed.body }) + response.candidates.push({ + name, + description: agent.description, + model: parsed.fields.model!, + hasBody: !!parsed.body.trim(), + collision: existing.has(name) + }) + sources.set(name, bytes) + } catch (error) { + response.skipped.push({ name, reason: error instanceof Error ? error.message : String(error) }) + } + } + return { response, sources } +} 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..7e2371c3 --- /dev/null +++ b/src/agents/agent-store.ts @@ -0,0 +1,302 @@ +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, AgentImportOutcome, 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 } : {}) } + } + + /** Include unreadable files: neither collision checks nor the cap may silently omit them. */ + names(): string[] { + return this.inspect().names.map(filename => filename.slice(0, -3)) + } + + /** Import validated bytes directly; serializing would change foreign frontmatter. */ + importFile(name: string, bytes: Buffer, overwrite = false): AgentImportOutcome { + let temporary: string | undefined + try { + if (!isUtf8(bytes)) throw new Error('agent definitions must be valid UTF-8') + const parsed = parseAgentFile(bytes.toString('utf8')) + decodeAgent({ name, ...parsed.fields, preamble: parsed.body }) + const names = this.names() + const exists = names.includes(name) + if (exists && !overwrite) + throw new Error('An agent with this name already exists. Enable overwrite to replace it.') + if (!exists && names.length >= MAX_AGENTS) throw new Error(`Keep at most ${MAX_AGENTS} agents.`) + const file = path.join(this.directory, `${name}.md`) + if (exists && !fs.lstatSync(file).isFile()) throw new Error('The existing agent must be a regular file.') + temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp` + fs.writeFileSync(temporary, bytes, { mode: 0o600, flag: 'wx' }) + if (exists) fs.renameSync(temporary, file) + else { + // Atomic no-clobber publication, including a file created after the collision check. + try { + fs.linkSync(temporary, file) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') + throw new Error('An agent with this name appeared during import. Refresh and try again.') + throw error + } + } + return { name, ok: true, overwritten: exists } + } catch (error) { + return { name, ok: false, error: message(error) } + } finally { + this.cache = undefined + this.cacheStamp = undefined + if (temporary) fs.rmSync(temporary, { force: true }) + } + } + + 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/agent-import.ts b/src/http/routes/agent-import.ts new file mode 100644 index 00000000..74bbf736 --- /dev/null +++ b/src/http/routes/agent-import.ts @@ -0,0 +1,38 @@ +import { decodeImportAgents, scanClaudeAgents } from '../../agents/agent-import.ts' +import { isRoute, routes } from '../../routes.ts' +import type { AgentImportOutcome, AgentsResponse, ImportAgentsRequest, ImportAgentsResult } from '../../wire.ts' +import { NOT_HANDLED, type RouteHandler } from '../router-types.ts' +import type { RelayServices } from '../services.ts' + +export function createAgentImportRoutes( + services: Pick, + agentsResponse: () => AgentsResponse +): RouteHandler { + const { agentStore, readBody, json } = services + return async (req, res, url) => { + if (isRoute(routes.agentImportCandidates, req.method, url.pathname)) + return json(req, res, 200, scanClaudeAgents(agentStore).response) + if (!isRoute(routes.importAgents, req.method, url.pathname)) return NOT_HANDLED + let request: ImportAgentsRequest + try { + request = decodeImportAgents(JSON.parse(await readBody(req))) + } catch (error) { + return json(req, res, 400, { error: error instanceof Error ? error.message : 'Invalid import request.' }) + } + const { response, sources } = scanClaudeAgents(agentStore) + const skipped = new Map(response.skipped.map(entry => [entry.name, entry.reason])) + const results = request.names.map((name): AgentImportOutcome => { + if (request.names.indexOf(name) !== request.names.lastIndexOf(name)) + return { name, ok: false, error: 'This name was requested more than once. Select each agent only once.' } + const bytes = sources.get(name) + if (!bytes) + return { + name, + ok: false, + error: skipped.get(name) ?? 'No importable file with this name was found in the scan. Refresh the list.' + } + return agentStore.importFile(name, bytes, request.overwrite) + }) + return json(req, res, 200, { results, config: agentsResponse() } satisfies ImportAgentsResult) + } +} diff --git a/src/http/routes/agents.ts b/src/http/routes/agents.ts new file mode 100644 index 00000000..a8dbeccf --- /dev/null +++ b/src/http/routes/agents.ts @@ -0,0 +1,67 @@ +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 type { RouteHandler } from '../router-types.ts' +import type { RelayServices } from '../services.ts' +import { createAgentImportRoutes } from './agent-import.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) } + } + const importRoutes = createAgentImportRoutes(services, response) + 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 importRoutes(req, res, url) + } +} 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..e07b45ff 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -126,7 +126,14 @@ 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'), + agentImportCandidates: flat('GET', '/api/agents/import'), + importAgents: flat('POST', '/api/agents/import'), + 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..67728fa3 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,46 @@ 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 }> } + +/** User-scoped ~/.claude/agents imports; filenames, not opaque name: lines, are identity. */ +export interface AgentImportCandidate { + name: string + description?: string + /** Original scalar, which may be a native alias absent from the picker catalog. */ + model: string + hasBody: boolean + collision: boolean +} + +export interface AgentImportScanResponse { + candidates: AgentImportCandidate[] + skipped: Array<{ name: string; reason: string }> + truncated: boolean + limit: number +} + +export interface ImportAgentsRequest { + names: string[] + overwrite?: boolean +} + +export type AgentImportOutcome = + | { name: string; ok: true; overwritten: boolean } + | { name: string; ok: false; error: string } + +export interface ImportAgentsResult { + results: AgentImportOutcome[] + config: AgentsResponse +} + export interface DelegationsResponse { delegations: DelegationProjection[] } diff --git a/tests/agents/agent-import.test.tsx b/tests/agents/agent-import.test.tsx new file mode 100644 index 00000000..0f9adb31 --- /dev/null +++ b/tests/agents/agent-import.test.tsx @@ -0,0 +1,180 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import type { AgentImportScanResponse, AgentsConfig, ImportAgentsResult } from '../../src/wire.ts' +import { importAgentDefinitions, mergeImportedAgents } from '../../web/src/lib/agent-import.ts' +import { roleModelProblem } from '../../web/src/lib/role-editor.ts' + +vi.stubGlobal('location', { hash: '', pathname: '/', search: '' }) +vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => {}, removeItem: () => {} }) +vi.stubGlobal('history', { replaceState: () => {} }) + +const { AgentEditorCard } = await import('../../web/src/components/agents/AgentEditorCard.tsx') +const { AgentImportChoices, AgentsImport } = await import('../../web/src/components/agents/AgentsImport.tsx') + +const clients: QueryClient[] = [] +function queryClient() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }) + clients.push(client) + return client +} +beforeEach(() => + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new Error('Unexpected request'))) + ) +) +afterEach(() => { + for (const client of clients.splice(0)) client.clear() + vi.restoreAllMocks() +}) +afterAll(() => vi.unstubAllGlobals()) + +const scan: AgentImportScanResponse = { + candidates: [ + { name: 'helper', model: 'haiku', description: 'Fast file searches.', hasBody: true, collision: true }, + { name: 'new-agent', model: 'sonnet', description: 'Imported instructions.', hasBody: false, collision: false } + ], + skipped: [{ name: 'broken', reason: 'description must be a flat scalar' }], + truncated: true, + limit: 64 +} +const before: AgentsConfig = { + version: 1, + agents: [ + { + name: 'helper', + model: '5.6 Sol', + description: 'Original description.', + preamble: 'Original body.', + effort: 'high' + }, + { name: 'removed', model: '5.6 Sol' } + ] +} +function receipt(): ImportAgentsResult { + return { + results: [ + { name: 'helper', ok: true, overwritten: true }, + { name: 'new-agent', ok: true, overwritten: false }, + { name: 'broken', ok: false, error: 'description must be a flat scalar' } + ], + config: { + version: 1, + agents: [ + { + name: 'helper', + model: 'haiku', + description: 'Imported description.', + preamble: 'Imported body.', + effort: 'low' + }, + before.agents[1], + { name: 'new-agent', model: 'sonnet', preamble: 'New body.' } + ], + issues: [ + { agent: 'new-agent', error: { code: 'model_missing', message: 'Choose a picker model.', retryable: false } } + ] + } + } +} + +describe('inline agent import flow', () => { + test('keeps discovery closed until opened and renders previews, skipped reasons and explicit overwrite controls', () => { + const closed = renderToStaticMarkup( + + + + ) + expect(closed).toContain('aria-expanded="false"') + expect(closed).toContain('Import from ~/.claude/agents') + expect(closed).not.toContain('Import agent definitions') + expect(fetch).not.toHaveBeenCalled() + const renderChoices = (overwrite: boolean) => + renderToStaticMarkup( + + ) + const html = renderChoices(false) + expect(html).toContain('Fast file searches.') + expect(html).toContain('haiku') + expect(html).toContain('Already exists') + expect(html).toContain('Has instructions') + expect(html).toContain('No instructions') + expect(html).toContain('broken: skipped — description must be a flat scalar') + expect(html).toContain('Only the first 64 Markdown files were scanned.') + expect(html.match(/]*aria-label="Import helper"[^>]*>/)?.[0]).toContain('disabled') + expect(renderChoices(true).match(/]*aria-label="Import helper"[^>]*>/)?.[0]).not.toContain('disabled') + expect(html.match(/]*aria-label="Import new-agent"[^>]*>/)?.[0]).toContain('checked') + }) + + test('POSTs selected names, refreshes readers, and merges imported cards without losing dirty drafts', async () => { + const result = receipt() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(result))) + const client = queryClient() + const invalidate = vi.spyOn(client, 'invalidateQueries') + const imported = await importAgentDefinitions(client, { names: ['helper', 'new-agent', 'broken'], overwrite: true }) + expect(fetch).toHaveBeenCalledTimes(1) + expect(vi.mocked(fetch).mock.calls[0][0]).toBe('/api/agents/import') + const request = vi.mocked(fetch).mock.calls[0][1]! + expect(request.method).toBe('POST') + expect(JSON.parse(request.body as string)).toEqual({ names: ['helper', 'new-agent', 'broken'], overwrite: true }) + expect(client.getQueryData(['agents'])).toEqual(result.config) + expect(invalidate.mock.calls.map(([options]) => options?.queryKey)).toEqual([ + ['agents'], + ['routing'], + ['roles'], + ['auto-model-config'], + ['agent-import-candidates'] + ]) + const draft: AgentsConfig = { + version: 1, + agents: [ + { ...before.agents[0], description: 'Unsaved description.', preamble: 'Unsaved body.', effort: undefined }, + { name: 'local-only', model: '5.6 Sol', preamble: 'Unsaved new agent.' } + ] + } + const merged = mergeImportedAgents(draft, before, imported) + expect(merged.agents.map(agent => agent.name)).toEqual(['helper', 'local-only', 'new-agent']) + expect(merged.agents[0]).toEqual({ + name: 'helper', + model: 'haiku', + description: 'Unsaved description.', + preamble: 'Unsaved body.', + effort: undefined + }) + expect(merged.agents[1]).toEqual(draft.agents[1]) + expect(draft.agents).toHaveLength(2) + const agent = merged.agents[2] + const groups = [{ agentType: 'codex', models: ['5.6 Sol'], updatedAt: 1 }] + const card = renderToStaticMarkup( + + ) + expect(card).toContain('new-agent') + expect(card).toContain('Choose an exact model from Conductor’s current picker catalog.') + }) + + test('retains local additions with the same imported name and intentional local removals on overwrite', () => { + const result = receipt() + result.results.push({ name: 'removed', ok: true, overwritten: true }) + const local = { name: 'new-agent', model: '5.6 Sol', preamble: 'Local new definition.' } + const draft: AgentsConfig = { version: 1, agents: [before.agents[0], local] } + const merged = mergeImportedAgents(draft, before, result) + expect(merged.agents).toEqual([result.config.agents[0], local]) + const refused = { ...result, results: [{ name: 'helper', ok: false as const, error: 'Collision' }] } + expect(mergeImportedAgents(draft, before, refused)).toEqual(draft) + }) +}) 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/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('