diff --git a/scripts/control-omb.ts b/scripts/control-omb.ts index acc7a433c4..b46d04eb6e 100644 --- a/scripts/control-omb.ts +++ b/scripts/control-omb.ts @@ -8,8 +8,9 @@ import { delimiter, dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs, type ParseArgsOptionsConfig } from "node:util"; -import { handleToolCall, request, validateBaseUrl } from "./mcp-server.ts"; +import { handleToolCall, request } from "./mcp-server.ts"; import { launchUi, runControlOmbUi } from "./testing/control-omb-ui.ts"; +import { configuredServerUrl, validateBaseUrl } from "../shared/server-endpoint.ts"; import { removeTempDir, waitForExit } from "../server/testing/cleanup.ts"; import { freePortBlock } from "../server/testing/ports.ts"; @@ -123,7 +124,7 @@ function positiveInteger(value: unknown, name: string, fallback: number, maximum function configuredUrl(raw: unknown, env: NodeJS.ProcessEnv, requiredForMutation: boolean): string | undefined { const explicit = typeof raw === "string" && raw.trim() ? raw.trim() - : env.OPENMAUSBOT_URL?.trim() || (env.OMB_PORT ? `http://127.0.0.1:${env.OMB_PORT}` : ""); + : configuredServerUrl(env) ?? ""; if (!explicit) { if (requiredForMutation) { throw new ControlOmbError( @@ -133,7 +134,7 @@ function configuredUrl(raw: unknown, env: NodeJS.ProcessEnv, requiredForMutation } return undefined; } - return validateBaseUrl(explicit); + return validateBaseUrl(explicit, env); } function target(values: Record): { type: "bot" | "channel"; id: string } { diff --git a/scripts/mcp-server.ts b/scripts/mcp-server.ts index b60ecd5deb..176c5871a6 100644 --- a/scripts/mcp-server.ts +++ b/scripts/mcp-server.ts @@ -3,35 +3,14 @@ // Standard JSON-RPC 2.0 stdio transport for external agent orchestration (Hermes, Claude Desktop, Cursor, etc.). import readline from "node:readline"; -export function validateBaseUrl(url: string): string { - const trimmed = url.replace(/\/+$/, ""); - let parsed: URL; - try { - parsed = new URL(trimmed); - } catch { - throw new Error(`Invalid OpenMausBot URL: '${url}'`); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("OpenMausBot URL must use http:// or https://"); - } - if (parsed.username || parsed.password) { - throw new Error("OpenMausBot URL must not contain credentials; use OPENMAUSBOT_TOKEN instead"); - } - if ((parsed.pathname !== "/" && parsed.pathname !== "") || parsed.search || parsed.hash) { - throw new Error("OpenMausBot URL must be an origin without a path, query, or fragment"); - } - const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ""); - const isLoopback = hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1"; - if (parsed.protocol === "http:" && !isLoopback && process.env.ALLOW_INSECURE_HTTP !== "true") { - throw new Error( - `Insecure cleartext HTTP origin '${parsed.origin}' is rejected. Use https:// or set ALLOW_INSECURE_HTTP=true.`, - ); - } - return parsed.origin; -} +import { configuredServerUrl, validateBaseUrl } from "../shared/server-endpoint.ts"; + +import { isRecord, ToolInputError, type ToolHandler } from "./mcp-server/context.ts"; +import { TOOL_HANDLERS, type ToolName } from "./mcp-server/registry.ts"; + +export { ToolInputError }; -const configuredUrl = process.env.OPENMAUSBOT_URL || - (process.env.OMB_PORT ? `http://127.0.0.1:${process.env.OMB_PORT}` : undefined); +const configuredUrl = configuredServerUrl(process.env); export const OMB_BASE_URL = validateBaseUrl(configuredUrl || "http://127.0.0.1:8799"); const DISCOVERY_URLS = configuredUrl @@ -118,12 +97,12 @@ export async function request(path: string, options: RequestInit = {}, baseUrl?: } export interface McpToolDefinition { - name: string; + name: ToolName; description: string; inputSchema: { type: "object"; properties: Record; - required?: string[]; + required?: readonly string[]; additionalProperties?: boolean; }; annotations?: { @@ -141,7 +120,7 @@ const MUTATING = { readOnlyHint: false, destructiveHint: false, idempotentHint: const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false } as const; const AGENT_ACTION = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true } as const; -export const TOOLS: McpToolDefinition[] = [ +export const TOOLS = [ { name: "get_system_health", description: "Check whether the OpenMausBot server is reachable.", @@ -454,16 +433,16 @@ export const TOOLS: McpToolDefinition[] = [ }, annotations: DESTRUCTIVE, }, -]; +] as const satisfies readonly McpToolDefinition[]; -function parsePositiveLimit(raw: unknown, fallback = 30, maximum = 200): number { - const parsed = Math.floor(Number(raw)); - return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback; -} +// McpToolDefinition keys the list above by the registry's ToolName, so an +// entry without a handler fails to typecheck; this constant fails the build +// the other way round — the two records cannot drift apart. +export const toolsCoverHandlers: Exclude extends never + ? true + : "every tool handler needs an entry in the tool list" + = true; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} function valueHasType(value: unknown, type: string): boolean { if (type === "null") return value === null; @@ -519,8 +498,6 @@ function schemaError(schema: Record, value: unknown, path: string): return null; } -export class ToolInputError extends Error {} - export function validateToolArguments(name: unknown, args: unknown): asserts args is Record { if (typeof name !== "string" || !name) throw new ToolInputError("tool name must be a non-empty string"); const tool = TOOLS.find((candidate) => candidate.name === name); @@ -530,255 +507,6 @@ export function validateToolArguments(name: unknown, args: unknown): asserts arg if (error) throw new ToolInputError(error); } -function stringArg(args: Record, key: string, options: { trim?: boolean; allowEmpty?: boolean; max?: number } = {}): string { - const raw = args[key]; - if (typeof raw !== "string") throw new ToolInputError(`${key} must be a string`); - const value = options.trim === false ? raw : raw.trim(); - if (!options.allowEmpty && !value) throw new ToolInputError(`${key} must not be empty`); - if (options.max && value.length > options.max) throw new ToolInputError(`${key} must be at most ${options.max} characters`); - return value; -} - -function optionalStringArg( - args: Record, - key: string, - options: { trim?: boolean; allowEmpty?: boolean; max?: number } = {}, -): string | undefined { - if (!(key in args)) return undefined; - return stringArg(args, key, options); -} - -function idArg(args: Record, key: string): string { - const value = stringArg(args, key); - if (!/^[\w-]+$/.test(value)) throw new ToolInputError(`${key} is not a valid OpenMausBot ID`); - return value; -} - -function stringArrayArg(args: Record, key: string): string[] { - const value = args[key]; - if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || !item.trim())) { - throw new ToolInputError(`${key} must be a non-empty list of IDs`); - } - return [...new Set(value.map((item) => item.trim()))]; -} - -function records(value: unknown): Array> { - return Array.isArray(value) ? value.filter(isRecord) : []; -} - -function projectTask(task: Record, activeThreadId: unknown) { - return { - taskId: task.threadId, - title: task.title, - createdAt: task.createdAt, - ...(typeof task.busy === "boolean" ? { busy: task.busy } : {}), - ...(task.activity ? { activity: task.activity } : {}), - ...(task.modelSelection ? { modelSelection: task.modelSelection } : {}), - ...(typeof activeThreadId === "string" ? { active: task.threadId === activeThreadId } : {}), - ...(task.usage ? { usage: task.usage } : {}), - }; -} - -function botTaskState(bot: Record, taskId: string) { - const task = records(bot.tasks).find((candidate) => candidate.threadId === taskId); - if (task && (typeof task.busy === "boolean" || typeof task.activity === "string")) return task; - // Older servers cannot run non-selected tasks and expose only bot activity. - return bot.threadId === taskId ? bot : { busy: false, activity: "idle" }; -} - -function projectBot(bot: Record) { - return { - id: bot.id, - name: bot.name, - title: bot.title, - description: bot.description, - section: bot.section ?? null, - chiefOfStaff: Boolean(bot.chiefOfStaff), - modelSelection: bot.modelSelection, - busy: Boolean(bot.busy), - activity: bot.activity, - unread: Boolean(bot.unread), - activeTaskId: bot.threadId, - tasks: records(bot.tasks).map((task) => projectTask(task, bot.threadId)), - }; -} - -function projectChannel(channel: Record) { - return { - id: channel.id, - name: channel.name, - memberIds: channel.memberIds, - bulletin: channel.bulletin, - defaultResponder: channel.defaultResponder, - section: channel.section ?? null, - directMessage: Boolean(channel.dm), - working: Boolean(channel.working), - busyBotId: channel.busyBotId ?? null, - activeTaskId: channel.threadId, - tasks: records(channel.tasks).map((task) => projectTask(task, channel.threadId)), - }; -} - -function projectMessage(message: Record) { - const card = isRecord(message.card) - ? { - title: message.card.title, - subtitle: message.card.subtitle, - options: message.card.options, - answered: message.card.answered, - dismissed: message.card.dismissed, - } - : undefined; - const tool = isRecord(message.tool) - ? { name: message.tool.name, ok: message.tool.ok, spoken: message.tool.spoken, setup: message.tool.setup, - ...(message.tool.terminal === true ? { terminal: true } : {}), - } - : undefined; - const connector = isRecord(message.connector) - ? { - slug: message.connector.slug, - label: message.connector.label, - description: message.connector.description, - status: message.connector.status, - dismissed: message.connector.dismissed, - resumed: message.connector.resumed, - } - : undefined; - const secret = isRecord(message.secret) - ? { - target: message.secret.target, - label: message.secret.label, - description: message.secret.description, - placeholder: message.secret.placeholder, - helpUrl: message.secret.helpUrl, - provided: message.secret.provided, - dismissed: message.secret.dismissed, - resumed: message.secret.resumed, - } - : undefined; - return { - id: message.id, - at: message.at, - role: message.role, - kind: message.kind, - text: message.text, - from: message.from, - replyToId: message.replyToId, - reactions: message.reactions, - steered: message.steered, - queued: message.queued, - ...(tool ? { tool } : {}), - ...(card ? { card } : {}), - ...(connector ? { connector } : {}), - ...(secret ? { secret } : {}), - ...(message.kind === "screen" ? { hasImage: Boolean(message.hasImage || message.png) } : {}), - }; -} - -async function fleet(fetcher: (path: string, options?: RequestInit) => Promise) { - return fetcher("/api/bots?messages=0"); -} - -function taskBelongsTo(owner: Record, taskId: string): boolean { - return owner.threadId === taskId || records(owner.tasks).some((task) => task.threadId === taskId); -} - -function messageNeedsInput(message: Record): boolean { - const card = isRecord(message.card) && message.card.requestId && !message.card.answered && !message.card.dismissed; - const connector = isRecord(message.connector) && - !message.connector.dismissed && - !message.connector.resumed && - message.connector.status !== "connected"; - const secret = isRecord(message.secret) && !message.secret.provided && !message.secret.dismissed; - return Boolean(card || connector || secret); -} - -function dispatchFailedAfterLatestUser(messages: Array>): boolean { - const lastUser = messages.findLastIndex((message) => message.role === "user"); - const turnMessages = messages.slice(lastUser + 1); - // Only an explicit terminal receipt overrides prose. Existing providers - // also emit diagnostics on intentional cancellation, which remain settled. - if (turnMessages.some((message) => message.tool?.terminal === true && message.tool.ok === false)) return true; - if (turnMessages.some((message) => message.role === "bot" && message.kind === "text" && message.text?.trim())) { - return false; - } - return turnMessages.some( - (message) => - message.kind === "activity" && - message.tool?.ok === false && - typeof message.tool?.name === "string" && - /^error:/i.test(message.tool.name.trim()), - ); -} - -async function conversationTail( - fetcher: (path: string, options?: RequestInit) => Promise, - taskId: string, - limit = 10, -) { - const page = await fetcher(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); - const raw = records(page.messages); - return { - raw, - messages: raw.map(projectMessage), - hasMore: Boolean(page.hasMore), - }; -} - -function normalizeResponder(value: unknown): Record | undefined { - if (value === undefined) return undefined; - if (!isRecord(value)) throw new ToolInputError("default_responder must be an object"); - if (value.kind === "everyone" || value.kind === "mentions") return { kind: value.kind }; - if (value.kind === "member" && typeof value.bot_id === "string" && value.bot_id.trim()) { - return { kind: "member", botId: value.bot_id.trim() }; - } - throw new ToolInputError("default_responder is invalid"); -} - -async function checkedModelSelection( - args: Record, - fetcher: (path: string, options?: RequestInit) => Promise, -) { - const instanceId = stringArg(args, "instance_id"); - const model = stringArg(args, "model"); - const effort = optionalStringArg(args, "effort"); - const described = await fetcher("/api/instances"); - const instance = records(described.instances).find((candidate) => candidate.instanceId === instanceId); - if (!instance) throw new ToolInputError(`model instance not found: ${instanceId}`); - if (instance.snapshot?.state !== "available") throw new ToolInputError(`model instance is unavailable: ${instanceId}`); - const models = isRecord(instance.models) ? instance.models : {}; - const offered = records(models.options).map((option) => option.id).filter((id) => typeof id === "string"); - if (models.default !== model && !offered.includes(model)) { - throw new ToolInputError(`model '${model}' is not offered by instance '${instanceId}'`); - } - const efforts = Array.isArray(instance.capabilities?.effortLevels) ? instance.capabilities.effortLevels : []; - if (effort && !efforts.includes(effort)) { - throw new ToolInputError(`effort '${effort}' is not offered by instance '${instanceId}'`); - } - return { instanceId, model, ...(effort ? { effort } : {}) }; -} - -function taskRoute(targetType: unknown, targetId: string): string { - if (targetType === "bot") return `/api/bots/${encodeURIComponent(targetId)}/tasks`; - if (targetType === "channel") return `/api/groups/${encodeURIComponent(targetId)}/tasks`; - throw new ToolInputError("target_type must be bot or channel"); -} - -function sleep(ms: number, signal?: AbortSignal) { - return new Promise((resolve, reject) => { - if (signal?.aborted) return reject(signal.reason ?? new Error("Request cancelled")); - const onAbort = () => { - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - reject(signal?.reason ?? new Error("Request cancelled")); - }; - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} export async function handleToolCall( name: string, @@ -790,444 +518,13 @@ export async function handleToolCall( ? (path: string, options: RequestInit = {}) => baseFetcher(path, { ...options, signal: options.signal ?? signal }) : baseFetcher; validateToolArguments(name, args); - switch (name) { - case "get_system_health": { - const res = await fetcher("/api/health"); - if (res?.app !== "openmausbot") throw new Error("The configured endpoint is not an OpenMausBot server"); - return { - status: "connected", - endpoint: discoveredBaseUrl ?? OMB_BASE_URL, - app: "openmausbot", - packaged: Boolean(res.static), - }; - } - - case "list_bots": { - const res = await fleet(fetcher); - return { bots: records(res.bots).map(projectBot) }; - } - - case "get_bot_messages": { - const botId = idArg(args, "bot_id"); - const res = await fleet(fetcher); - const bot = records(res.bots).find((candidate) => candidate.id === botId); - if (!bot) throw new Error(`Bot not found: ${botId}`); - const taskId = args.task_id === undefined ? String(bot.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); - const limit = parsePositiveLimit(args.limit, 30, 200); - const page = await fetcher(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); - return { - bot: projectBot(bot), - taskId, - messages: records(page.messages).map(projectMessage), - hasMore: Boolean(page.hasMore), - }; - } - - case "send_bot_message": { - const botId = idArg(args, "bot_id"); - const text = stringArg(args, "text", { trim: true, max: 100_000 }); - const state = await fleet(fetcher); - const bot = records(state.bots).find((candidate) => candidate.id === botId); - if (!bot) throw new Error(`Bot not found: ${botId}`); - const taskId = args.task_id === undefined ? String(bot.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); - const busyChannel = records(state.groups).find((channel) => channel.busyBotId === botId); - if (busyChannel) { - throw new Error(`Bot '${botId}' is working in channel '${busyChannel.id}'; send to or interrupt that channel instead`); - } - await fetcher(`/api/bots/${encodeURIComponent(botId)}/messages`, { - method: "POST", - body: JSON.stringify({ text, threadId: taskId }), - }); - return { success: true, botId, taskId }; - } - - case "create_bot": { - const name = stringArg(args, "name", { max: 100 }); - const title = optionalStringArg(args, "title", { trim: false, allowEmpty: true, max: 200 }); - const description = optionalStringArg(args, "description", { trim: false, allowEmpty: true, max: 4_000 }); - const section = optionalStringArg(args, "section", { max: 60 }); - const wantsModel = args.instance_id !== undefined || args.model !== undefined || args.effort !== undefined; - if (wantsModel && (args.instance_id === undefined || args.model === undefined)) { - throw new ToolInputError("instance_id and model must be provided together"); - } - const selection = wantsModel ? await checkedModelSelection(args, fetcher) : undefined; - const created = await fetcher("/api/bots", { - method: "POST", - body: JSON.stringify({ - name, - ...(title !== undefined ? { title } : {}), - ...(description !== undefined ? { description } : {}), - ...(section !== undefined ? { section } : {}), - ...(selection ? { modelSelection: selection, requireAvailableModel: true } : {}), - }), - }); - if (!isRecord(created?.bot) || typeof created.bot.id !== "string") { - throw new Error("OpenMausBot did not return the created bot"); - } - return { success: true, bot: projectBot(created.bot) }; - } - - case "update_bot_profile": { - const botId = idArg(args, "bot_id"); - const patch: Record = {}; - if (args.name !== undefined) patch.name = stringArg(args, "name", { max: 100 }); - if (args.title !== undefined) patch.title = stringArg(args, "title", { trim: false, allowEmpty: true, max: 200 }); - if (args.description !== undefined) patch.description = stringArg(args, "description", { trim: false, allowEmpty: true, max: 4_000 }); - if ("section" in args) patch.section = args.section === null ? null : stringArg(args, "section", { max: 60 }); - if (!Object.keys(patch).length) throw new ToolInputError("provide at least one profile field to update"); - const result = await fetcher(`/api/bots/${encodeURIComponent(botId)}`, { - method: "PATCH", - body: JSON.stringify(patch), - }); - if (!isRecord(result?.bot)) { - throw new Error("OpenMausBot did not return the updated bot"); - } - return { success: true, bot: projectBot(result.bot) }; - } - - case "list_channels": { - const res = await fleet(fetcher); - return { channels: records(res.groups).map(projectChannel) }; - } - - case "get_channel_messages": { - const channelId = idArg(args, "channel_id"); - const res = await fleet(fetcher); - const channel = records(res.groups).find((candidate) => candidate.id === channelId); - if (!channel) throw new Error(`Channel not found: ${channelId}`); - const taskId = args.task_id === undefined ? String(channel.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(channel, taskId)) throw new Error(`Task '${taskId}' does not belong to channel '${channelId}'`); - const limit = parsePositiveLimit(args.limit, 30, 200); - const page = await fetcher(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); - return { - channel: projectChannel(channel), - taskId, - messages: records(page.messages).map(projectMessage), - hasMore: Boolean(page.hasMore), - }; - } - - case "send_channel_message": { - const channelId = idArg(args, "channel_id"); - const text = stringArg(args, "text", { trim: true, max: 100_000 }); - const state = await fleet(fetcher); - const channel = records(state.groups).find((candidate) => candidate.id === channelId); - if (!channel) throw new Error(`Channel not found: ${channelId}`); - const taskId = args.task_id === undefined ? String(channel.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(channel, taskId)) { - throw new Error(`Task '${taskId}' does not belong to channel '${channelId}'`); - } - if (channel.threadId !== taskId) { - throw new Error(`Task '${taskId}' is not active for channel '${channelId}'; switch to it before sending`); - } - await fetcher(`/api/groups/${encodeURIComponent(channelId)}/messages`, { - method: "POST", - body: JSON.stringify({ text, threadId: taskId }), - }); - return { success: true, channelId, taskId }; - } - - case "create_channel": { - const name = stringArg(args, "name", { max: 100 }); - const memberIds = stringArrayArg(args, "member_ids"); - const section = optionalStringArg(args, "section", { max: 60 }); - const bulletin = optionalStringArg(args, "bulletin", { trim: false, allowEmpty: true, max: 12_000 }) ?? ""; - const requestedResponder = normalizeResponder(args.default_responder); - if (requestedResponder?.kind === "member" && !memberIds.includes(requestedResponder.botId)) { - throw new ToolInputError("default_responder bot must be a channel member"); - } - const responder = requestedResponder ?? { kind: "member", botId: memberIds[0] }; - const created = await fetcher("/api/groups", { - method: "POST", - body: JSON.stringify({ - name, - memberIds, - ...(section ? { section } : {}), - setup: { bulletin, defaultResponder: responder }, - }), - }); - if (!isRecord(created?.group) || typeof created.group.id !== "string") { - throw new Error("OpenMausBot did not return the created channel"); - } - return { success: true, channel: projectChannel(created.group) }; - } - - case "update_channel": { - const channelId = idArg(args, "channel_id"); - const patch: Record = {}; - if (args.name !== undefined) patch.name = stringArg(args, "name", { max: 100 }); - if (args.member_ids !== undefined) patch.memberIds = stringArrayArg(args, "member_ids"); - if (args.section !== undefined) patch.section = args.section === null ? null : stringArg(args, "section", { max: 60 }); - if (args.bulletin !== undefined) patch.bulletin = stringArg(args, "bulletin", { trim: false, allowEmpty: true, max: 12_000 }); - if (args.default_responder !== undefined) patch.defaultResponder = normalizeResponder(args.default_responder); - if (Object.keys(patch).length === 0) throw new ToolInputError("provide at least one channel field to update"); - const memberIds = patch.memberIds as string[] | undefined; - const responder = patch.defaultResponder as Record | undefined; - if (memberIds && responder?.kind === "member" && !memberIds.includes(responder.botId)) { - throw new ToolInputError("default_responder bot must be a channel member"); - } - const result = await fetcher(`/api/groups/${encodeURIComponent(channelId)}`, { - method: "PATCH", - body: JSON.stringify(patch), - }); - if (!isRecord(result?.group)) { - throw new Error("OpenMausBot did not return the updated channel"); - } - return { success: true, channel: projectChannel(result.group) }; - } - - case "create_task": { - const targetId = idArg(args, "target_id"); - const title = optionalStringArg(args, "title", { max: 80 }); - const route = taskRoute(args.target_type, targetId); - const result = await fetcher(route, { method: "POST", body: JSON.stringify(title ? { title } : {}) }); - if (!isRecord(result?.task) || typeof result.task.threadId !== "string") { - throw new Error("OpenMausBot did not return the created task"); - } - const activeTaskId = result.bot?.threadId ?? result.group?.threadId ?? result.task?.threadId; - return { - success: true, - targetType: args.target_type, - targetId, - task: projectTask(result.task, activeTaskId), - }; - } - - case "switch_task": { - const targetId = idArg(args, "target_id"); - const taskId = idArg(args, "task_id"); - const route = taskRoute(args.target_type, targetId); - const result = await fetcher(`${route}/${encodeURIComponent(taskId)}?messages=0`, { method: "POST", body: "{}" }); - const target = args.target_type === "bot" ? result.bot : result.group; - return { - success: true, - targetType: args.target_type, - targetId, - taskId, - ...(isRecord(target) - ? { target: args.target_type === "bot" ? projectBot(target) : projectChannel(target) } - : {}), - }; - } - - case "rename_task": { - const targetId = idArg(args, "target_id"); - const taskId = idArg(args, "task_id"); - const title = stringArg(args, "title", { max: 80 }); - const route = taskRoute(args.target_type, targetId); - const result = await fetcher(`${route}/${encodeURIComponent(taskId)}`, { - method: "PATCH", - body: JSON.stringify({ title }), - }); - if (!isRecord(result?.task)) { - throw new Error("OpenMausBot did not return the renamed task"); - } - return { - success: true, - targetType: args.target_type, - targetId, - task: projectTask(result.task, undefined), - }; - } - - case "search_messages": { - const query = stringArg(args, "query", { max: 500 }); - const limit = parsePositiveLimit(args.limit, 40, 100); - const params = new URLSearchParams({ q: query, limit: String(limit) }); - if (args.task_id !== undefined) params.set("threadId", idArg(args, "task_id")); - const result = await fetcher(`/api/search?${params.toString()}`); - return { hits: records(result.hits) }; - } - - case "wait_for_conversation": { - const targetType = args.target_type; - if (targetType !== "bot" && targetType !== "channel") { - throw new ToolInputError("target_type must be bot or channel"); - } - const targetId = idArg(args, "target_id"); - const timeoutSeconds = parsePositiveLimit(args.timeout_seconds, 30, 120); - const deadline = Date.now() + timeoutSeconds * 1_000; - const startupGraceDeadline = Math.min(deadline, Date.now() + 750); - let state = await fleet(fetcher); - const collection = targetType === "bot" ? records(state.bots) : records(state.groups); - let target = collection.find((candidate) => candidate.id === targetId); - if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); - const taskId = args.task_id === undefined ? String(target.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(target, taskId)) { - throw new Error(`Task '${taskId}' does not belong to ${targetType} '${targetId}'`); - } - let sawBusy = false; - while (true) { - const liveCollection = targetType === "bot" ? records(state.bots) : records(state.groups); - target = liveCollection.find((candidate) => candidate.id === targetId); - if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); - if (!taskBelongsTo(target, taskId)) { - throw new Error(`Task '${taskId}' no longer belongs to ${targetType} '${targetId}'`); - } - const projectedTarget = targetType === "bot" ? projectBot(target) : projectChannel(target); - const terminal = async (status: string, existingTail?: Awaited>) => { - const tail = existingTail ?? await conversationTail(fetcher, taskId); - const needsInput = tail.raw.some(messageNeedsInput); - const terminalStatus = status === "settled" && dispatchFailedAfterLatestUser(tail.raw) - ? "failed" - : status; - return { - status: needsInput ? "needs-user" : terminalStatus, - targetType, - targetId, - taskId, - target: projectedTarget, - messages: tail.messages, - hasMore: tail.hasMore, - }; - }; - - if (targetType === "bot") { - const task = botTaskState(target, taskId); - const busyChannel = task === target && records(state.groups).find((channel) => channel.busyBotId === targetId); - if (busyChannel) { - throw new Error(`Bot '${targetId}' is working in channel '${busyChannel.id}'; wait on that channel instead`); - } - if (task.activity === "waiting-on-you") return terminal("needs-user"); - if (task.activity === "dead") return terminal("failed"); - if (task.activity === "no-signal") return terminal("stalled"); - if (!task.busy) return terminal("settled"); - sawBusy = true; - } else { - if (target.threadId !== taskId) return terminal("settled"); - const tail = await conversationTail(fetcher, taskId); - if (tail.raw.some(messageNeedsInput)) { - return terminal("needs-user", tail); - } - const channelWorking = target.working === true || Boolean(target.busyBotId); - if (channelWorking) { - sawBusy = true; - const busyBotId = target.busyBotId; - if (busyBotId) { - const speaker = records(state.bots).find((bot) => bot.id === busyBotId); - if (!speaker) return terminal("stalled"); - if (speaker.activity === "waiting-on-you") return terminal("needs-user"); - if (speaker.activity === "dead") return terminal("failed"); - if (speaker.activity === "no-signal") return terminal("stalled"); - } - } else { - const latest = tail.raw.at(-1); - // New servers expose `working` synchronously before returning a - // channel send. The short grace remains only for older servers - // that have no operation-level field and report a user message - // just before their first speaker becomes busy. - if (sawBusy || target.working === false || latest?.role !== "user") { - return terminal("settled", tail); - } - if (Date.now() >= startupGraceDeadline) { - return terminal("settled", tail); - } - } - } - - if (Date.now() >= deadline) return terminal("timed-out"); - await sleep(Math.min(500, Math.max(0, deadline - Date.now())), signal); - state = await fleet(fetcher); - } - } - - case "set_bot_model": { - const botId = idArg(args, "bot_id"); - const current = await fleet(fetcher); - const bot = records(current.bots).find((candidate) => candidate.id === botId); - if (!bot) throw new Error(`Bot not found: ${botId}`); - if (args.task_id !== undefined) { - const taskId = idArg(args, "task_id"); - if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); - if (botTaskState(bot, taskId).busy) throw new Error("Interrupt the task or let it finish before changing its model"); - const selection = await checkedModelSelection(args, fetcher); - const res = await fetcher(`${taskRoute("bot", botId)}/${encodeURIComponent(taskId)}`, { - method: "PATCH", - body: JSON.stringify({ modelSelection: selection, requireAvailableModel: true }), - }); - if (!isRecord(res?.task)) throw new Error("OpenMausBot did not return the updated task"); - return { success: true, botId, task: projectTask(res.task, bot.threadId) }; - } - if (bot.busy) throw new Error("Interrupt the bot or let it finish before changing its model"); - const selection = await checkedModelSelection(args, fetcher); - const res = await fetcher(`/api/bots/${encodeURIComponent(botId)}`, { - method: "PATCH", - body: JSON.stringify({ modelSelection: selection, requireAvailableModel: true }), - }); - return { success: true, bot: projectBot(res.bot) }; - } - - case "edit_bot_message": { - const botId = idArg(args, "bot_id"); - const messageId = idArg(args, "message_id"); - const text = String(args.text ?? "").trim(); - if (!text) throw new Error("text is required"); - const current = await fleet(fetcher); - const bot = records(current.bots).find((candidate) => candidate.id === botId); - if (!bot) throw new Error(`Bot not found: ${botId}`); - let threadId: string | undefined; - if (args.task_id !== undefined) { - threadId = idArg(args, "task_id"); - if (!taskBelongsTo(bot, threadId)) throw new Error(`Task '${threadId}' does not belong to bot '${botId}'`); - if (botTaskState(bot, threadId).busy) throw new Error("Interrupt the task or let it finish before editing a message"); - } else if (bot.busy) { - // the server refuses a rewind under a live turn — branching beneath - // a dying turn is how a thread ends up with two tails - throw new Error("Interrupt the bot or let it finish before editing a message"); - } - const res = await fetcher( - `/api/bots/${encodeURIComponent(botId)}/messages/${encodeURIComponent(messageId)}/edit`, - { method: "POST", body: JSON.stringify({ text, ...(threadId ? { threadId } : {}) }) }, - ); - return { success: true, botId, message: res?.message ?? null }; - } - - case "list_available_models": { - const res = await fetcher("/api/instances"); - return { - instances: records(res.instances).map((instance) => ({ - instanceId: instance.instanceId, - driverKind: instance.driverKind, - displayName: instance.displayName, - snapshot: { state: instance.snapshot?.state }, - models: instance.models, - capabilities: instance.capabilities, - access: instance.access, - })), - }; - } - - case "interrupt_conversation": { - const targetType = args.target_type; - if (targetType !== "bot" && targetType !== "channel") { - throw new ToolInputError("target_type must be bot or channel"); - } - const targetId = idArg(args, "target_id"); - const current = await fleet(fetcher); - const target = (targetType === "bot" ? records(current.bots) : records(current.groups)) - .find((candidate) => candidate.id === targetId); - if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); - const taskId = args.task_id === undefined ? String(target.threadId) : idArg(args, "task_id"); - if (!taskBelongsTo(target, taskId)) throw new Error(`Task '${taskId}' does not belong to ${targetType} '${targetId}'`); - if (targetType === "bot") { - const busyChannel = botTaskState(target, taskId) === target && records(current.groups).find((channel) => channel.busyBotId === targetId); - if (busyChannel) { - throw new Error(`Bot '${targetId}' is working in channel '${busyChannel.id}'; interrupt that channel instead`); - } - } - const route = targetType === "bot" ? "bots" : "groups"; - await fetcher(`/api/${route}/${encodeURIComponent(targetId)}/interrupt`, { - method: "POST", - body: JSON.stringify({ threadId: taskId }), - }); - return { success: true, targetType, targetId, taskId }; - } - - default: - throw new ToolInputError(`Unknown tool: ${name}`); - } + const handler = (TOOL_HANDLERS as Record)[name]; + if (!handler) throw new ToolInputError(`Unknown tool: ${name}`); + return handler(args, { + fetch: fetcher, + signal, + endpoint: () => discoveredBaseUrl ?? OMB_BASE_URL, + }); } export function formatResponse(id: string | number | null, result?: unknown, error?: { code?: number; message?: string }) { diff --git a/scripts/mcp-server/bots.ts b/scripts/mcp-server/bots.ts new file mode 100644 index 0000000000..7ff0a95640 --- /dev/null +++ b/scripts/mcp-server/bots.ts @@ -0,0 +1,111 @@ +// Bot tools: profiles plus the per-bot task and message surface. + +import { idArg, isRecord, optionalStringArg, parsePositiveLimit, stringArg, ToolInputError, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { botTaskState, checkedModelSelection, fleet, projectBot, projectMessage, records, taskBelongsTo } from "./shared.ts"; + +export const handlers = { + async list_bots(_args: Json, ctx: ToolContext): Promise { + const res = await fleet(ctx.fetch); + return { bots: records(res.bots).map(projectBot) }; + }, + async get_bot_messages(args: Json, ctx: ToolContext): Promise { + const botId = idArg(args, "bot_id"); + const res = await fleet(ctx.fetch); + const bot = records(res.bots).find((candidate) => candidate.id === botId); + if (!bot) throw new Error(`Bot not found: ${botId}`); + const taskId = args.task_id === undefined ? String(bot.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); + const limit = parsePositiveLimit(args.limit, 30, 200); + const page = await ctx.fetch(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); + return { + bot: projectBot(bot), + taskId, + messages: records(page.messages).map(projectMessage), + hasMore: Boolean(page.hasMore), + }; + }, + async send_bot_message(args: Json, ctx: ToolContext): Promise { + const botId = idArg(args, "bot_id"); + const text = stringArg(args, "text", { trim: true, max: 100_000 }); + const state = await fleet(ctx.fetch); + const bot = records(state.bots).find((candidate) => candidate.id === botId); + if (!bot) throw new Error(`Bot not found: ${botId}`); + const taskId = args.task_id === undefined ? String(bot.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); + const busyChannel = records(state.groups).find((channel) => channel.busyBotId === botId); + if (busyChannel) { + throw new Error(`Bot '${botId}' is working in channel '${busyChannel.id}'; send to or interrupt that channel instead`); + } + await ctx.fetch(`/api/bots/${encodeURIComponent(botId)}/messages`, { + method: "POST", + body: JSON.stringify({ text, threadId: taskId }), + }); + return { success: true, botId, taskId }; + }, + async create_bot(args: Json, ctx: ToolContext): Promise { + const name = stringArg(args, "name", { max: 100 }); + const title = optionalStringArg(args, "title", { trim: false, allowEmpty: true, max: 200 }); + const description = optionalStringArg(args, "description", { trim: false, allowEmpty: true, max: 4_000 }); + const section = optionalStringArg(args, "section", { max: 60 }); + const wantsModel = args.instance_id !== undefined || args.model !== undefined || args.effort !== undefined; + if (wantsModel && (args.instance_id === undefined || args.model === undefined)) { + throw new ToolInputError("instance_id and model must be provided together"); + } + const selection = wantsModel ? await checkedModelSelection(args, ctx.fetch) : undefined; + const created = await ctx.fetch("/api/bots", { + method: "POST", + body: JSON.stringify({ + name, + ...(title !== undefined ? { title } : {}), + ...(description !== undefined ? { description } : {}), + ...(section !== undefined ? { section } : {}), + ...(selection ? { modelSelection: selection, requireAvailableModel: true } : {}), + }), + }); + if (!isRecord(created?.bot) || typeof created.bot.id !== "string") { + throw new Error("OpenMausBot did not return the created bot"); + } + return { success: true, bot: projectBot(created.bot) }; + }, + async update_bot_profile(args: Json, ctx: ToolContext): Promise { + const botId = idArg(args, "bot_id"); + const patch: Record = {}; + if (args.name !== undefined) patch.name = stringArg(args, "name", { max: 100 }); + if (args.title !== undefined) patch.title = stringArg(args, "title", { trim: false, allowEmpty: true, max: 200 }); + if (args.description !== undefined) patch.description = stringArg(args, "description", { trim: false, allowEmpty: true, max: 4_000 }); + if ("section" in args) patch.section = args.section === null ? null : stringArg(args, "section", { max: 60 }); + if (!Object.keys(patch).length) throw new ToolInputError("provide at least one profile field to update"); + const result = await ctx.fetch(`/api/bots/${encodeURIComponent(botId)}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); + if (!isRecord(result?.bot)) { + throw new Error("OpenMausBot did not return the updated bot"); + } + return { success: true, bot: projectBot(result.bot) }; + }, + async edit_bot_message(args: Json, ctx: ToolContext): Promise { + const botId = idArg(args, "bot_id"); + const messageId = idArg(args, "message_id"); + const text = String(args.text ?? "").trim(); + if (!text) throw new Error("text is required"); + const current = await fleet(ctx.fetch); + const bot = records(current.bots).find((candidate) => candidate.id === botId); + if (!bot) throw new Error(`Bot not found: ${botId}`); + let threadId: string | undefined; + if (args.task_id !== undefined) { + threadId = idArg(args, "task_id"); + if (!taskBelongsTo(bot, threadId)) throw new Error(`Task '${threadId}' does not belong to bot '${botId}'`); + if (botTaskState(bot, threadId).busy) throw new Error("Interrupt the task or let it finish before editing a message"); + } else if (bot.busy) { + // the server refuses a rewind under a live turn — branching beneath + // a dying turn is how a thread ends up with two tails + throw new Error("Interrupt the bot or let it finish before editing a message"); + } + const res = await ctx.fetch( + `/api/bots/${encodeURIComponent(botId)}/messages/${encodeURIComponent(messageId)}/edit`, + { method: "POST", body: JSON.stringify({ text, ...(threadId ? { threadId } : {}) }) }, + ); + return { success: true, botId, message: res?.message ?? null }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/channels.ts b/scripts/mcp-server/channels.ts new file mode 100644 index 0000000000..3e54aafe25 --- /dev/null +++ b/scripts/mcp-server/channels.ts @@ -0,0 +1,103 @@ +// Channel tools: multi-agent groups, their members, and message surface. + +import { idArg, isRecord, optionalStringArg, parsePositiveLimit, stringArg, stringArrayArg, ToolInputError, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { fleet, projectChannel, projectMessage, records, taskBelongsTo } from "./shared.ts"; + +function normalizeResponder(value: unknown): Record | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) throw new ToolInputError("default_responder must be an object"); + if (value.kind === "everyone" || value.kind === "mentions") return { kind: value.kind }; + if (value.kind === "member" && typeof value.bot_id === "string" && value.bot_id.trim()) { + return { kind: "member", botId: value.bot_id.trim() }; + } + throw new ToolInputError("default_responder is invalid"); +} + +export const handlers = { + async list_channels(_args: Json, ctx: ToolContext): Promise { + const res = await fleet(ctx.fetch); + return { channels: records(res.groups).map(projectChannel) }; + }, + async get_channel_messages(args: Json, ctx: ToolContext): Promise { + const channelId = idArg(args, "channel_id"); + const res = await fleet(ctx.fetch); + const channel = records(res.groups).find((candidate) => candidate.id === channelId); + if (!channel) throw new Error(`Channel not found: ${channelId}`); + const taskId = args.task_id === undefined ? String(channel.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(channel, taskId)) throw new Error(`Task '${taskId}' does not belong to channel '${channelId}'`); + const limit = parsePositiveLimit(args.limit, 30, 200); + const page = await ctx.fetch(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); + return { + channel: projectChannel(channel), + taskId, + messages: records(page.messages).map(projectMessage), + hasMore: Boolean(page.hasMore), + }; + }, + async send_channel_message(args: Json, ctx: ToolContext): Promise { + const channelId = idArg(args, "channel_id"); + const text = stringArg(args, "text", { trim: true, max: 100_000 }); + const state = await fleet(ctx.fetch); + const channel = records(state.groups).find((candidate) => candidate.id === channelId); + if (!channel) throw new Error(`Channel not found: ${channelId}`); + const taskId = args.task_id === undefined ? String(channel.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(channel, taskId)) { + throw new Error(`Task '${taskId}' does not belong to channel '${channelId}'`); + } + if (channel.threadId !== taskId) { + throw new Error(`Task '${taskId}' is not active for channel '${channelId}'; switch to it before sending`); + } + await ctx.fetch(`/api/groups/${encodeURIComponent(channelId)}/messages`, { + method: "POST", + body: JSON.stringify({ text, threadId: taskId }), + }); + return { success: true, channelId, taskId }; + }, + async create_channel(args: Json, ctx: ToolContext): Promise { + const name = stringArg(args, "name", { max: 100 }); + const memberIds = stringArrayArg(args, "member_ids"); + const section = optionalStringArg(args, "section", { max: 60 }); + const bulletin = optionalStringArg(args, "bulletin", { trim: false, allowEmpty: true, max: 12_000 }) ?? ""; + const requestedResponder = normalizeResponder(args.default_responder); + if (requestedResponder?.kind === "member" && !memberIds.includes(requestedResponder.botId)) { + throw new ToolInputError("default_responder bot must be a channel member"); + } + const responder = requestedResponder ?? { kind: "member", botId: memberIds[0] }; + const created = await ctx.fetch("/api/groups", { + method: "POST", + body: JSON.stringify({ + name, + memberIds, + ...(section ? { section } : {}), + setup: { bulletin, defaultResponder: responder }, + }), + }); + if (!isRecord(created?.group) || typeof created.group.id !== "string") { + throw new Error("OpenMausBot did not return the created channel"); + } + return { success: true, channel: projectChannel(created.group) }; + }, + async update_channel(args: Json, ctx: ToolContext): Promise { + const channelId = idArg(args, "channel_id"); + const patch: Record = {}; + if (args.name !== undefined) patch.name = stringArg(args, "name", { max: 100 }); + if (args.member_ids !== undefined) patch.memberIds = stringArrayArg(args, "member_ids"); + if (args.section !== undefined) patch.section = args.section === null ? null : stringArg(args, "section", { max: 60 }); + if (args.bulletin !== undefined) patch.bulletin = stringArg(args, "bulletin", { trim: false, allowEmpty: true, max: 12_000 }); + if (args.default_responder !== undefined) patch.defaultResponder = normalizeResponder(args.default_responder); + if (Object.keys(patch).length === 0) throw new ToolInputError("provide at least one channel field to update"); + const memberIds = patch.memberIds as string[] | undefined; + const responder = patch.defaultResponder as Record | undefined; + if (memberIds && responder?.kind === "member" && !memberIds.includes(responder.botId)) { + throw new ToolInputError("default_responder bot must be a channel member"); + } + const result = await ctx.fetch(`/api/groups/${encodeURIComponent(channelId)}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); + if (!isRecord(result?.group)) { + throw new Error("OpenMausBot did not return the updated channel"); + } + return { success: true, channel: projectChannel(result.group) }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/context.ts b/scripts/mcp-server/context.ts new file mode 100644 index 0000000000..fd99ed6144 --- /dev/null +++ b/scripts/mcp-server/context.ts @@ -0,0 +1,66 @@ +// The contract between the MCP entry (scripts/mcp-server.ts) and its handler +// modules. Every tool is a plain (args, ctx) function returning the raw +// result payload; process-level state (endpoint discovery) and the +// per-request abort wiring arrive through ToolContext explicitly instead of +// module globals. Argument validation keeps the same ToolInputError the +// JSON-RPC layer maps to invalid-params responses. + +/** Tool arguments as they arrive from the JSON-RPC client. */ +export type Json = Record; + +/** A handler's answer: the raw result payload, stringified by the caller. */ +export type ToolHandler = (args: Json, ctx: ToolContext) => Promise; + +export interface ToolContext { + /** The request helper with the call's abort signal already bound in. */ + fetch(path: string, options?: RequestInit): Promise; + /** The call's abort signal, when the client provided one. */ + readonly signal?: AbortSignal; + /** The endpoint the health check reports: the discovered URL, else the configured one. */ + endpoint(): string; +} + +/** Invalid tool input: the JSON-RPC layer reports these as invalid params. */ +export class ToolInputError extends Error {} + +export function parsePositiveLimit(raw: unknown, fallback = 30, maximum = 200): number { + if (raw === undefined) return fallback; + if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) throw new ToolInputError("limit must be a positive number"); + return Math.min(Math.floor(raw), maximum); +} + +export function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function stringArg(args: Record, key: string, options: { trim?: boolean; allowEmpty?: boolean; max?: number } = {}): string { + const raw = args[key]; + if (typeof raw !== "string") throw new ToolInputError(`${key} must be a string`); + const value = options.trim === false ? raw : raw.trim(); + if (!options.allowEmpty && !value) throw new ToolInputError(`${key} must not be empty`); + if (options.max && value.length > options.max) throw new ToolInputError(`${key} must be at most ${options.max} characters`); + return value; +} + +export function optionalStringArg( + args: Record, + key: string, + options: { trim?: boolean; allowEmpty?: boolean; max?: number } = {}, +): string | undefined { + if (!(key in args)) return undefined; + return stringArg(args, key, options); +} + +export function idArg(args: Record, key: string): string { + const value = stringArg(args, key); + if (!/^[\w-]+$/.test(value)) throw new ToolInputError(`${key} is not a valid OpenMausBot ID`); + return value; +} + +export function stringArrayArg(args: Record, key: string): string[] { + const value = args[key]; + if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || !item.trim())) { + throw new ToolInputError(`${key} must be a non-empty list of IDs`); + } + return [...new Set(value.map((item) => item.trim()))]; +} diff --git a/scripts/mcp-server/conversations.ts b/scripts/mcp-server/conversations.ts new file mode 100644 index 0000000000..cfca351654 --- /dev/null +++ b/scripts/mcp-server/conversations.ts @@ -0,0 +1,181 @@ +// Conversation tools: bounded waits and interruption across bots and channels. + +import { idArg, isRecord, parsePositiveLimit, ToolInputError, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { botTaskState, fleet, projectBot, projectChannel, projectMessage, records, taskBelongsTo } from "./shared.ts"; + +function messageNeedsInput(message: Record): boolean { + const card = isRecord(message.card) && message.card.requestId && !message.card.answered && !message.card.dismissed; + const connector = isRecord(message.connector) && + !message.connector.dismissed && + !message.connector.resumed && + message.connector.status !== "connected"; + const secret = isRecord(message.secret) && !message.secret.provided && !message.secret.dismissed; + return Boolean(card || connector || secret); +} + +function dispatchFailedAfterLatestUser(messages: Array>): boolean { + const lastUser = messages.findLastIndex((message) => message.role === "user"); + const turnMessages = messages.slice(lastUser + 1); + // Only an explicit terminal receipt overrides prose. Existing providers + // also emit diagnostics on intentional cancellation, which remain settled. + if (turnMessages.some((message) => message.tool?.terminal === true && message.tool.ok === false)) return true; + if (turnMessages.some((message) => message.role === "bot" && message.kind === "text" && message.text?.trim())) { + return false; + } + return turnMessages.some( + (message) => + message.kind === "activity" && + message.tool?.ok === false && + typeof message.tool?.name === "string" && + /^error:/i.test(message.tool.name.trim()), + ); +} + +async function conversationTail( + fetcher: (path: string, options?: RequestInit) => Promise, + taskId: string, + limit = 10, +) { + const page = await fetcher(`/api/threads/${encodeURIComponent(taskId)}/messages?limit=${limit}`); + const raw = records(page.messages); + return { + raw, + messages: raw.map(projectMessage), + hasMore: Boolean(page.hasMore), + }; +} + +function sleep(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason ?? new Error("Request cancelled")); + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + reject(signal?.reason ?? new Error("Request cancelled")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export const handlers = { + async wait_for_conversation(args: Json, ctx: ToolContext): Promise { + const targetType = args.target_type; + if (targetType !== "bot" && targetType !== "channel") { + throw new ToolInputError("target_type must be bot or channel"); + } + const targetId = idArg(args, "target_id"); + const timeoutSeconds = parsePositiveLimit(args.timeout_seconds, 30, 120); + const deadline = Date.now() + timeoutSeconds * 1_000; + const startupGraceDeadline = Math.min(deadline, Date.now() + 750); + let state = await fleet(ctx.fetch); + const collection = targetType === "bot" ? records(state.bots) : records(state.groups); + let target = collection.find((candidate) => candidate.id === targetId); + if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); + const taskId = args.task_id === undefined ? String(target.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(target, taskId)) { + throw new Error(`Task '${taskId}' does not belong to ${targetType} '${targetId}'`); + } + let sawBusy = false; + while (true) { + const liveCollection = targetType === "bot" ? records(state.bots) : records(state.groups); + target = liveCollection.find((candidate) => candidate.id === targetId); + if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); + if (!taskBelongsTo(target, taskId)) { + throw new Error(`Task '${taskId}' no longer belongs to ${targetType} '${targetId}'`); + } + const projectedTarget = targetType === "bot" ? projectBot(target) : projectChannel(target); + const terminal = async (status: string, existingTail?: Awaited>) => { + const tail = existingTail ?? await conversationTail(ctx.fetch, taskId); + const needsInput = tail.raw.some(messageNeedsInput); + const terminalStatus = status === "settled" && dispatchFailedAfterLatestUser(tail.raw) + ? "failed" + : status; + return { + status: needsInput ? "needs-user" : terminalStatus, + targetType, + targetId, + taskId, + target: projectedTarget, + messages: tail.messages, + hasMore: tail.hasMore, + }; + }; + + if (targetType === "bot") { + const task = botTaskState(target, taskId); + const busyChannel = task === target && records(state.groups).find((channel) => channel.busyBotId === targetId); + if (busyChannel) { + throw new Error(`Bot '${targetId}' is working in channel '${busyChannel.id}'; wait on that channel instead`); + } + if (task.activity === "waiting-on-you") return terminal("needs-user"); + if (task.activity === "dead") return terminal("failed"); + if (task.activity === "no-signal") return terminal("stalled"); + if (!task.busy) return terminal("settled"); + sawBusy = true; + } else { + if (target.threadId !== taskId) return terminal("settled"); + const tail = await conversationTail(ctx.fetch, taskId); + if (tail.raw.some(messageNeedsInput)) { + return terminal("needs-user", tail); + } + const channelWorking = target.working === true || Boolean(target.busyBotId); + if (channelWorking) { + sawBusy = true; + const busyBotId = target.busyBotId; + if (busyBotId) { + const speaker = records(state.bots).find((bot) => bot.id === busyBotId); + if (!speaker) return terminal("stalled"); + if (speaker.activity === "waiting-on-you") return terminal("needs-user"); + if (speaker.activity === "dead") return terminal("failed"); + if (speaker.activity === "no-signal") return terminal("stalled"); + } + } else { + const latest = tail.raw.at(-1); + // New servers expose `working` synchronously before returning a + // channel send. The short grace remains only for older servers + // that have no operation-level field and report a user message + // just before their first speaker becomes busy. + if (sawBusy || target.working === false || latest?.role !== "user") { + return terminal("settled", tail); + } + if (Date.now() >= startupGraceDeadline) { + return terminal("settled", tail); + } + } + } + + if (Date.now() >= deadline) return terminal("timed-out"); + await sleep(Math.min(500, Math.max(0, deadline - Date.now())), ctx.signal); + state = await fleet(ctx.fetch); + } + }, + async interrupt_conversation(args: Json, ctx: ToolContext): Promise { + const targetType = args.target_type; + if (targetType !== "bot" && targetType !== "channel") { + throw new ToolInputError("target_type must be bot or channel"); + } + const targetId = idArg(args, "target_id"); + const current = await fleet(ctx.fetch); + const target = (targetType === "bot" ? records(current.bots) : records(current.groups)) + .find((candidate) => candidate.id === targetId); + if (!target) throw new Error(`${targetType === "bot" ? "Bot" : "Channel"} not found: ${targetId}`); + const taskId = args.task_id === undefined ? String(target.threadId) : idArg(args, "task_id"); + if (!taskBelongsTo(target, taskId)) throw new Error(`Task '${taskId}' does not belong to ${targetType} '${targetId}'`); + if (targetType === "bot") { + const busyChannel = botTaskState(target, taskId) === target && records(current.groups).find((channel) => channel.busyBotId === targetId); + if (busyChannel) { + throw new Error(`Bot '${targetId}' is working in channel '${busyChannel.id}'; interrupt that channel instead`); + } + } + const route = targetType === "bot" ? "bots" : "groups"; + await ctx.fetch(`/api/${route}/${encodeURIComponent(targetId)}/interrupt`, { + method: "POST", + body: JSON.stringify({ threadId: taskId }), + }); + return { success: true, targetType, targetId, taskId }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/models.ts b/scripts/mcp-server/models.ts new file mode 100644 index 0000000000..a0f6c41c96 --- /dev/null +++ b/scripts/mcp-server/models.ts @@ -0,0 +1,47 @@ +// Model tools: listing instances and switching an idle bot or task selection. + +import { idArg, isRecord, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { botTaskState, checkedModelSelection, fleet, projectBot, projectTask, records, taskBelongsTo, taskRoute } from "./shared.ts"; + +export const handlers = { + async list_available_models(_args: Json, ctx: ToolContext): Promise { + const res = await ctx.fetch("/api/instances"); + return { + instances: records(res.instances).map((instance) => ({ + instanceId: instance.instanceId, + driverKind: instance.driverKind, + displayName: instance.displayName, + snapshot: { state: instance.snapshot?.state }, + models: instance.models, + capabilities: instance.capabilities, + access: instance.access, + })), + }; + }, + async set_bot_model(args: Json, ctx: ToolContext): Promise { + const botId = idArg(args, "bot_id"); + const current = await fleet(ctx.fetch); + const bot = records(current.bots).find((candidate) => candidate.id === botId); + if (!bot) throw new Error(`Bot not found: ${botId}`); + if (args.task_id !== undefined) { + const taskId = idArg(args, "task_id"); + if (!taskBelongsTo(bot, taskId)) throw new Error(`Task '${taskId}' does not belong to bot '${botId}'`); + if (botTaskState(bot, taskId).busy) throw new Error("Interrupt the task or let it finish before changing its model"); + const selection = await checkedModelSelection(args, ctx.fetch); + const res = await ctx.fetch(`${taskRoute("bot", botId)}/${encodeURIComponent(taskId)}`, { + method: "PATCH", + body: JSON.stringify({ modelSelection: selection, requireAvailableModel: true }), + }); + if (!isRecord(res?.task)) throw new Error("OpenMausBot did not return the updated task"); + return { success: true, botId, task: projectTask(res.task, bot.threadId) }; + } + if (bot.busy) throw new Error("Interrupt the bot or let it finish before changing its model"); + const selection = await checkedModelSelection(args, ctx.fetch); + const res = await ctx.fetch(`/api/bots/${encodeURIComponent(botId)}`, { + method: "PATCH", + body: JSON.stringify({ modelSelection: selection, requireAvailableModel: true }), + }); + if (!isRecord(res?.bot)) throw new Error("OpenMausBot did not return the updated bot"); + return { success: true, bot: projectBot(res.bot) }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/registry.ts b/scripts/mcp-server/registry.ts new file mode 100644 index 0000000000..f163edef16 --- /dev/null +++ b/scripts/mcp-server/registry.ts @@ -0,0 +1,26 @@ +// One handler per tool name, assembled from the domain modules beside this +// file. mcp-server.ts keeps the matching TOOLS advertisement list; the +// ToolName type below keys both records, so a list entry without a handler +// fails to typecheck there, and a handler without a list entry fails the +// exhaustiveness check declared beside the list. +import type { ToolHandler } from "./context.ts"; +import * as bots from "./bots.ts"; +import * as channels from "./channels.ts"; +import * as conversations from "./conversations.ts"; +import * as models from "./models.ts"; +import * as search from "./search.ts"; +import * as system from "./system.ts"; +import * as tasks from "./tasks.ts"; + +export const TOOL_HANDLERS = { + ...system.handlers, + ...bots.handlers, + ...channels.handlers, + ...tasks.handlers, + ...conversations.handlers, + ...models.handlers, + ...search.handlers, +} satisfies Record; + +/** Every tool name the MCP server can dispatch. */ +export type ToolName = keyof typeof TOOL_HANDLERS; diff --git a/scripts/mcp-server/search.ts b/scripts/mcp-server/search.ts new file mode 100644 index 0000000000..11837c2a75 --- /dev/null +++ b/scripts/mcp-server/search.ts @@ -0,0 +1,15 @@ +// Search tools: bounded transcript search across or within tasks. + +import { idArg, parsePositiveLimit, stringArg, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { records } from "./shared.ts"; + +export const handlers = { + async search_messages(args: Json, ctx: ToolContext): Promise { + const query = stringArg(args, "query", { max: 500 }); + const limit = parsePositiveLimit(args.limit, 40, 100); + const params = new URLSearchParams({ q: query, limit: String(limit) }); + if (args.task_id !== undefined) params.set("threadId", idArg(args, "task_id")); + const result = await ctx.fetch(`/api/search?${params.toString()}`); + return { hits: records(result.hits) }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/shared.ts b/scripts/mcp-server/shared.ts new file mode 100644 index 0000000000..92aa673ae4 --- /dev/null +++ b/scripts/mcp-server/shared.ts @@ -0,0 +1,154 @@ +// Helpers shared by several domain handlers: the API projections and the +// owner/task checks every tool performs the same way. Bodies are unchanged +// from the old dispatch switch in mcp-server.ts. +import { isRecord, optionalStringArg, stringArg, ToolInputError } from "./context.ts"; + +export function records(value: unknown): Array> { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +export async function fleet(fetcher: (path: string, options?: RequestInit) => Promise) { + return fetcher("/api/bots?messages=0"); +} + +export function projectTask(task: Record, activeThreadId: unknown) { + return { + taskId: task.threadId, + title: task.title, + createdAt: task.createdAt, + ...(typeof task.busy === "boolean" ? { busy: task.busy } : {}), + ...(task.activity ? { activity: task.activity } : {}), + ...(task.modelSelection ? { modelSelection: task.modelSelection } : {}), + ...(typeof activeThreadId === "string" ? { active: task.threadId === activeThreadId } : {}), + ...(task.usage ? { usage: task.usage } : {}), + }; +} + +export function botTaskState(bot: Record, taskId: string) { + const task = records(bot.tasks).find((candidate) => candidate.threadId === taskId); + if (task && (typeof task.busy === "boolean" || typeof task.activity === "string")) return task; + // Older servers cannot run non-selected tasks and expose only bot activity. + return bot.threadId === taskId ? bot : { busy: false, activity: "idle" }; +} + +export function projectBot(bot: Record) { + return { + id: bot.id, + name: bot.name, + title: bot.title, + description: bot.description, + section: bot.section ?? null, + chiefOfStaff: Boolean(bot.chiefOfStaff), + modelSelection: bot.modelSelection, + busy: Boolean(bot.busy), + activity: bot.activity, + unread: Boolean(bot.unread), + activeTaskId: bot.threadId, + tasks: records(bot.tasks).map((task) => projectTask(task, bot.threadId)), + }; +} + +export function projectChannel(channel: Record) { + return { + id: channel.id, + name: channel.name, + memberIds: channel.memberIds, + bulletin: channel.bulletin, + defaultResponder: channel.defaultResponder, + section: channel.section ?? null, + directMessage: Boolean(channel.dm), + working: Boolean(channel.working), + busyBotId: channel.busyBotId ?? null, + activeTaskId: channel.threadId, + tasks: records(channel.tasks).map((task) => projectTask(task, channel.threadId)), + }; +} + +export function projectMessage(message: Record) { + const card = isRecord(message.card) + ? { + title: message.card.title, + subtitle: message.card.subtitle, + options: message.card.options, + answered: message.card.answered, + dismissed: message.card.dismissed, + } + : undefined; + const tool = isRecord(message.tool) + ? { name: message.tool.name, ok: message.tool.ok, spoken: message.tool.spoken, setup: message.tool.setup, + ...(message.tool.terminal === true ? { terminal: true } : {}), + } + : undefined; + const connector = isRecord(message.connector) + ? { + slug: message.connector.slug, + label: message.connector.label, + description: message.connector.description, + status: message.connector.status, + dismissed: message.connector.dismissed, + resumed: message.connector.resumed, + } + : undefined; + const secret = isRecord(message.secret) + ? { + target: message.secret.target, + label: message.secret.label, + description: message.secret.description, + placeholder: message.secret.placeholder, + helpUrl: message.secret.helpUrl, + provided: message.secret.provided, + dismissed: message.secret.dismissed, + resumed: message.secret.resumed, + } + : undefined; + return { + id: message.id, + at: message.at, + role: message.role, + kind: message.kind, + text: message.text, + from: message.from, + replyToId: message.replyToId, + reactions: message.reactions, + steered: message.steered, + queued: message.queued, + ...(tool ? { tool } : {}), + ...(card ? { card } : {}), + ...(connector ? { connector } : {}), + ...(secret ? { secret } : {}), + ...(message.kind === "screen" ? { hasImage: Boolean(message.hasImage || message.png) } : {}), + }; +} + +export function taskBelongsTo(owner: Record, taskId: string): boolean { + return owner.threadId === taskId || records(owner.tasks).some((task) => task.threadId === taskId); +} + +export function taskRoute(targetType: unknown, targetId: string): string { + if (targetType === "bot") return `/api/bots/${encodeURIComponent(targetId)}/tasks`; + if (targetType === "channel") return `/api/groups/${encodeURIComponent(targetId)}/tasks`; + throw new ToolInputError("target_type must be bot or channel"); +} + +export async function checkedModelSelection( + args: Record, + fetcher: (path: string, options?: RequestInit) => Promise, +) { + const instanceId = stringArg(args, "instance_id"); + const model = stringArg(args, "model"); + const effort = optionalStringArg(args, "effort"); + const described = await fetcher("/api/instances"); + const instance = records(described.instances).find((candidate) => candidate.instanceId === instanceId); + if (!instance) throw new ToolInputError(`model instance not found: ${instanceId}`); + if (instance.snapshot?.state !== "available") throw new ToolInputError(`model instance is unavailable: ${instanceId}`); + const models = isRecord(instance.models) ? instance.models : {}; + const offered = records(models.options).map((option) => option.id).filter((id) => typeof id === "string"); + if (models.default !== model && !offered.includes(model)) { + throw new ToolInputError(`model '${model}' is not offered by instance '${instanceId}'`); + } + const efforts = Array.isArray(instance.capabilities?.effortLevels) ? instance.capabilities.effortLevels : []; + if (effort && !efforts.includes(effort)) { + throw new ToolInputError(`effort '${effort}' is not offered by instance '${instanceId}'`); + } + return { instanceId, model, ...(effort ? { effort } : {}) }; +} diff --git a/scripts/mcp-server/system.ts b/scripts/mcp-server/system.ts new file mode 100644 index 0000000000..22e26a53c7 --- /dev/null +++ b/scripts/mcp-server/system.ts @@ -0,0 +1,16 @@ +// System tools: connectivity to the configured or discovered server. + +import type { Json, ToolContext, ToolHandler } from "./context.ts"; + +export const handlers = { + async get_system_health(_args: Json, ctx: ToolContext): Promise { + const res = await ctx.fetch("/api/health"); + if (res?.app !== "openmausbot") throw new Error("The configured endpoint is not an OpenMausBot server"); + return { + status: "connected", + endpoint: ctx.endpoint(), + app: "openmausbot", + packaged: Boolean(res.static), + }; + }, +} satisfies Record; diff --git a/scripts/mcp-server/tasks.ts b/scripts/mcp-server/tasks.ts new file mode 100644 index 0000000000..4d1d0da332 --- /dev/null +++ b/scripts/mcp-server/tasks.ts @@ -0,0 +1,58 @@ +// Task tools: creating, selecting, and renaming bot/channel tasks. + +import { idArg, isRecord, optionalStringArg, stringArg, type Json, type ToolContext, type ToolHandler } from "./context.ts"; +import { projectBot, projectChannel, projectTask, taskRoute } from "./shared.ts"; + +export const handlers = { + async create_task(args: Json, ctx: ToolContext): Promise { + const targetId = idArg(args, "target_id"); + const title = optionalStringArg(args, "title", { max: 80 }); + const route = taskRoute(args.target_type, targetId); + const result = await ctx.fetch(route, { method: "POST", body: JSON.stringify(title ? { title } : {}) }); + if (!isRecord(result?.task) || typeof result.task.threadId !== "string") { + throw new Error("OpenMausBot did not return the created task"); + } + const activeTaskId = result.bot?.threadId ?? result.group?.threadId ?? result.task?.threadId; + return { + success: true, + targetType: args.target_type, + targetId, + task: projectTask(result.task, activeTaskId), + }; + }, + async switch_task(args: Json, ctx: ToolContext): Promise { + const targetId = idArg(args, "target_id"); + const taskId = idArg(args, "task_id"); + const route = taskRoute(args.target_type, targetId); + const result = await ctx.fetch(`${route}/${encodeURIComponent(taskId)}?messages=0`, { method: "POST", body: "{}" }); + const target = args.target_type === "bot" ? result?.bot : result?.group; + return { + success: true, + targetType: args.target_type, + targetId, + taskId, + ...(isRecord(target) + ? { target: args.target_type === "bot" ? projectBot(target) : projectChannel(target) } + : {}), + }; + }, + async rename_task(args: Json, ctx: ToolContext): Promise { + const targetId = idArg(args, "target_id"); + const taskId = idArg(args, "task_id"); + const title = stringArg(args, "title", { max: 80 }); + const route = taskRoute(args.target_type, targetId); + const result = await ctx.fetch(`${route}/${encodeURIComponent(taskId)}`, { + method: "PATCH", + body: JSON.stringify({ title }), + }); + if (!isRecord(result?.task)) { + throw new Error("OpenMausBot did not return the renamed task"); + } + return { + success: true, + targetType: args.target_type, + targetId, + task: projectTask(result.task, undefined), + }; + }, +} satisfies Record; diff --git a/scripts/testing/bot-tools-ui.e2e.test.ts b/scripts/testing/bot-tools-ui.e2e.test.ts index 800f3d2389..095a36f099 100644 --- a/scripts/testing/bot-tools-ui.e2e.test.ts +++ b/scripts/testing/bot-tools-ui.e2e.test.ts @@ -40,7 +40,7 @@ describe("bot setup and tools in the real renderer", () => { await expect.poll(() => { if (child!.exitCode !== null || child!.signalCode !== null) throw new Error(`UI launcher exited: ${stderr}`); try { info = JSON.parse(stdout); return Boolean(info.ui); } catch { return false; } - }, { timeout: LAUNCH_TIMEOUT_MS, interval: 250 }).toBe(true); + }, { timeout: LAUNCH_TIMEOUT_MS + 120_000, interval: 250 }).toBe(true); const ui = (verb: string, ...args: string[]) => runControlOmb(["ui", verb, "--ui", info.ui, ...args]) as Promise>; const evaluate = async (js: string) => (await ui("eval", "--js", js)).result; const click = (name: string) => ui("click", "--name", name); @@ -214,5 +214,5 @@ describe("bot setup and tools in the real renderer", () => { expect(child.exitCode).toBe(0); expect(existsSync(info.dataDir)).toBe(false); expect(existsSync(info.logPath)).toBe(true); - }, LAUNCH_TIMEOUT_MS + 180_000); + }, LAUNCH_TIMEOUT_MS + 300_000); }); diff --git a/scripts/testing/cloud-preview.tsx b/scripts/testing/cloud-preview.tsx index bc21810036..58c52dc979 100644 --- a/scripts/testing/cloud-preview.tsx +++ b/scripts/testing/cloud-preview.tsx @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"; import { ComputerPanel } from "../../src/components/ComputerPanel"; import { BotSettingsDialog } from "../../src/components/BotSettingsDialog"; import { RemoteDesktopPanel } from "../../src/components/remote-desktop-panel"; -import { StoreProvider, useStore, type Bot } from "../../src/state/store"; +import { StoreProvider, useStore, overlayOpen, type Bot } from "../../src/state/store"; import { applySkin, readSkin } from "../../src/lib/skins"; import { CLOUD_COMPUTER_BUSY_ERROR } from "../../shared/computer-contention"; import "../../src/styles.css"; @@ -147,7 +147,7 @@ function Fixture() { if (bot) { dispatch({ type: "screenFrame", botId: bot.id, png: blank, mime: "image/png" }); dispatch({ type: "updateBot", botId: bot.id, patch: { computer: "cloud", cloudBackend: "box" } }); - dispatch({ type: "toggleComputer", open: true }); + dispatch({ type: "openOverlay", kind: "computer", open: true }); } }, [bot?.id, dispatch]); useEffect(() => { @@ -194,11 +194,11 @@ function Fixture() { - {state.settingsOpen && bot && } - {state.computerOpen && fixtureBot ? panel === "computer" + {overlayOpen(state, "settings") && bot && } + {overlayOpen(state, "computer") && fixtureBot ? panel === "computer" ? : - : !state.settingsOpen && } + : !overlayOpen(state, "settings") && } ; } applySkin(readSkin()); diff --git a/scripts/testing/cron-routines-ui.e2e.test.ts b/scripts/testing/cron-routines-ui.e2e.test.ts index c59d4a3633..4883351475 100644 --- a/scripts/testing/cron-routines-ui.e2e.test.ts +++ b/scripts/testing/cron-routines-ui.e2e.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { afterAll, expect, it } from "vitest"; import { resolveAgentBrowserBinary } from "../../server/browser-engine.ts"; import { waitForExit } from "../../server/testing/cleanup.ts"; -import type { Routine } from "../../src/lib/routines.ts"; +import type { Routine } from "../../shared/routines.ts"; import { runControlOmb } from "../control-omb.ts"; import { UI_TOOLS_DIR } from "./control-omb-ui.ts"; diff --git a/scripts/testing/engines-preview.tsx b/scripts/testing/engines-preview.tsx index 20f19d7258..3f4dcb5d75 100644 --- a/scripts/testing/engines-preview.tsx +++ b/scripts/testing/engines-preview.tsx @@ -3,20 +3,20 @@ import { createRoot } from "react-dom/client"; import { WelcomeFlow } from "../../src/components/onboarding/WelcomeFlow"; import { SettingsModal } from "../../src/components/SettingsModal"; import { DesktopCapabilitiesProvider } from "../../src/components/DesktopCapabilities"; -import { StoreProvider, useStore } from "../../src/state/store"; +import { StoreProvider, useStore, overlayOpen } from "../../src/state/store"; import { applySkin } from "../../src/lib/skins"; import "../../src/styles.css"; function Preview() { const { state, dispatch, refreshInstances } = useStore(); const [onboarding, setOnboarding] = useState(false); - useEffect(() => { dispatch({ type: "toggleAppSettings", open: true, section: "engines" }); }, [dispatch]); + useEffect(() => { dispatch({ type: "openOverlay", kind: "appSettings", open: true, section: "engines" }); }, [dispatch]); return <>
Isolated preview · sample engines, no real accounts
- {onboarding ? setOnboarding(false)} /> : state.appSettingsOpen && } + {onboarding ? setOnboarding(false)} /> : overlayOpen(state, "appSettings") && }