diff --git a/electron/diagnostics.mjs b/electron/diagnostics.mjs new file mode 100644 index 000000000..a5fc8b32b --- /dev/null +++ b/electron/diagnostics.mjs @@ -0,0 +1,150 @@ +// One-click bug-report bundle: app facts, a safe config summary and the +// server.log tail, formatted for pasting into a public issue. Pure string +// work lives here so redaction stays unit-testable without Electron; main.mjs +// owns the fs and dialog plumbing. Safety is layered: the collector never +// reads secret fields at all (only the server's booleans-only config status), +// and everything that does get in is scrubbed again here before it lands on +// disk — so a future collector mistake still cannot leak a credential. +// +// CREDENTIAL_ENV_NAMES mirrors WORKSPACE_CREDENTIAL_ENV (server/config.ts). +// Duplicated because the desktop shell cannot import TypeScript; a test +// asserts the two lists never drift apart. +export const CREDENTIAL_ENV_NAMES = [ + "XAI_API_KEY", + "BOX_TOKEN", + "OPENCODE_API_KEY", + "OMB_TTS_KEY", + "OMB_OPENAI_IMAGE_KEY", + "COMPOSIO_API_KEY", + "OMB_COMPOSIO_BROKER_TOKEN", +]; + +// Credential-shaped tokens (server/redact.ts parity): unmistakable formats +// are masked wherever they appear, keyed or not. +const CREDENTIAL_TOKEN_FORMATS = [ + /\bsk-[A-Za-z0-9_-]{16,}/g, + /\bxai-[A-Za-z0-9]{16,}/g, + /\bak_[A-Za-z0-9_-]{16,}/g, + /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g, + /\bgithub_pat_[A-Za-z0-9_]{20,}/g, + /\bxox[abposr]-[A-Za-z0-9-]{20,}/g, + /\bAKIA[0-9A-Z]{16}\b/g, + /\bAIza[0-9A-Za-z_-]{30,}/g, + /\bnpm_[A-Za-z0-9]{20,}/g, +]; +const KEY_VALUE_PAIR = + /\b([A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|password|passwd|authorization|auth[_-]?token|access[_-]?key|private[_-]?key)s?)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s"',;)\]}]+)/gi; +const AUTHORIZATION = + /\b(authorization)\s*[:=]\s*(?:"|')?([A-Za-z][A-Za-z0-9_-]*\s+[A-Za-z0-9._~+/=-]+)(?:"|')?/gi; +const BEARER = /(\bbearer\s+)([A-Za-z0-9._~+/=-]{8,})/gi; +const PEM_BLOCK = + /(-----BEGIN [A-Z ]*PRIVATE KEY-----)[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----)/g; + +const mask = (value) => `«redacted ${value.length} chars»`; +const unquote = (value) => value.replace(/^["']|["']$/g, ""); + +// Shared value grammar; String.raw keeps \s and \] intact when the pattern +// is embedded into the dynamically built env-name regexes below. +const VALUE_PART = String.raw`("[^"]*"|'[^']*'|[^\s"',;)\]}]+)`; + +export function redactSecretsInLine(line) { + let out = String(line ?? ""); + const alreadyMasked = (value) => String(value).includes("«redacted"); + for (const name of CREDENTIAL_ENV_NAMES) { + out = out.replace( + new RegExp(`\\b(${name})\\s*[:=]\\s*${VALUE_PART}`, "gi"), + (_match, key, value) => `${key}=${mask(unquote(value))}`, + ); + } + out = out.replace(AUTHORIZATION, (_match, key, value) => `${key}=${mask(value)}`); + out = out.replace(BEARER, (_match, lead, token) => `${lead}${mask(token)}`); + out = out.replace(PEM_BLOCK, (_match, open, close) => `${open}«redacted private key»${close}`); + out = out.replace(KEY_VALUE_PAIR, (_match, key, value) => + alreadyMasked(value) ? _match : `${key}=${mask(unquote(value))}`, + ); + for (const format of CREDENTIAL_TOKEN_FORMATS) out = out.replace(format, (found) => mask(found)); + return out; +} + +/** Decode a bounded log buffer without exporting the partial first line that + * may begin before the read boundary. */ +export function decodeLogTail(buffer, truncated = false) { + if (!Buffer.isBuffer(buffer)) return { tail: "", bytes: 0 }; + let complete = buffer; + if (truncated) { + const newline = buffer.indexOf(0x0a); + complete = newline < 0 ? buffer.subarray(0, 0) : buffer.subarray(newline + 1); + } + return { tail: complete.toString("utf8"), bytes: complete.length }; +} + +const APP_INFO_KEYS = ["version", "platform", "arch", "electron", "node", "packaged", "uptimeSeconds"]; + +// A config summary entry is publishable only when it carries no credential: +// Only booleans and finite numbers pass. Strings can contain names, paths, +// account identifiers, or other personal data even when the field name is +// not credential-shaped, so they never reach the file. Everything else +// (objects beyond flattening, arrays, nulls) is dropped too. +const isFiniteNumber = (value) => Number.isFinite(value) && Object.prototype.toString.call(value) === "[object Number]"; + +function summaryAllows(value) { + if (Object.prototype.toString.call(value) === "[object Boolean]") return true; + return isFiniteNumber(value); +} + +function flattenSummary(input, prefix = "", depth = 0, out = {}) { + if (!input || Object.prototype.toString.call(input) !== "[object Object]") return out; + for (const [key, value] of Object.entries(input)) { + const path = prefix ? `${prefix}.${key}` : key; + if (Array.isArray(value)) continue; + if (value && Object.prototype.toString.call(value) === "[object Object]" && depth < 3) + flattenSummary(value, path, depth + 1, out); + else out[path] = value; + } + return out; +} + +export function buildDiagnosticsReport({ + appInfo = {}, + configSummary = {}, + logTail, + now = new Date().toISOString(), +} = {}) { + const lines = []; + lines.push("OpenMausBot diagnostics"); + lines.push(`Generated: ${now}`); + lines.push(""); + lines.push("## App"); + for (const key of APP_INFO_KEYS) { + if (appInfo[key] === undefined || appInfo[key] === null) continue; + lines.push(`${key}=${String(appInfo[key])}`); + } + lines.push(""); + lines.push("## Configuration"); + lines.push("# presence/count/mode only — credentials stay OS-encrypted and are never read"); + const summary = flattenSummary(configSummary); + let shown = 0; + for (const key of Object.keys(summary).sort()) { + if (!summaryAllows(summary[key])) continue; + lines.push(`${key}=${summary[key]}`); + shown += 1; + } + if (!shown) lines.push("(no configuration summary available)"); + lines.push(""); + lines.push(logTail && logTail.trim() ? "## Server log tail — known credential patterns auto-masked" : "## Server log tail"); + if (logTail && logTail.trim()) { + for (const line of redactSecretsInLine(logTail).split(/\r?\n/)) lines.push(line); + } else { + lines.push("(server log unavailable)"); + } + lines.push(""); + return lines.join("\n"); +} + +export function diagnosticsFileName(date = new Date()) { + const pad = (n) => String(n).padStart(2, "0"); + return ( + `openmausbot-diagnostics-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` + + `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}.txt` + ); +} diff --git a/electron/diagnostics.test.mjs b/electron/diagnostics.test.mjs new file mode 100644 index 000000000..6d8d6f400 --- /dev/null +++ b/electron/diagnostics.test.mjs @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; + +const require = createRequire(import.meta.url); +const { + buildDiagnosticsReport, + decodeLogTail, + diagnosticsFileName, + redactSecretsInLine, + CREDENTIAL_ENV_NAMES, +} = require("./diagnostics.mjs"); + +// The desktop shell cannot import TypeScript, so its credential list is a +// hand copy of server/config.ts WORKSPACE_CREDENTIAL_ENV. This test is the +// drift alarm: a name added server-side without updating the copy here would +// otherwise ship an unredacted export path. +describe("credential env parity with server/config.ts", () => { + it("matches WORKSPACE_CREDENTIAL_ENV exactly", () => { + const config = readFileSync(new URL("../server/config.ts", import.meta.url), "utf8"); + const match = config.match(/WORKSPACE_CREDENTIAL_ENV = \[([\s\S]*?)\] as const/); + expect(match).not.toBeNull(); + const names = [...match[1].matchAll(/"([A-Z0-9_]+)"/g)].map((m) => m[1]); + expect(CREDENTIAL_ENV_NAMES).toEqual(names); + }); +}); + +describe("buildDiagnosticsReport", () => { + const appInfo = { + version: "0.1.27", + platform: "darwin", + arch: "arm64", + electron: "43.4.0", + node: "24.0.0", + packaged: true, + uptimeSeconds: 42, + }; + + it("renders app facts and a sorted config summary", () => { + const report = buildDiagnosticsReport({ + appInfo, + configSummary: { + xai: { configured: true }, + box: { configured: false }, + rooms: { turnTimeoutMinutes: 5 }, + }, + logTail: "", + }); + expect(report).toContain("version=0.1.27"); + expect(report).toContain("platform=darwin"); + expect(report).toContain("arch=arm64"); + expect(report).toContain("xai.configured=true"); + expect(report).toContain("box.configured=false"); + expect(report).toContain("rooms.turnTimeoutMinutes=5"); + expect(report).toContain("(server log unavailable)"); + }); + + it("drops strings, non-scalars and credential-shaped summary values", () => { + const report = buildDiagnosticsReport({ + appInfo, + configSummary: { + xai: { key: "xai-real-secret" }, + composio: { apiKey: "ak_live_abcdef123456789" }, + vps: { sshAlias: "" }, + profile: { name: "Ada" }, + instances: [{ driver: "claudeAgent", environment: { TOKEN: "hunter2" } }], + note: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz", + }, + logTail: "", + }); + expect(report).not.toContain("xai-real-secret"); + expect(report).not.toContain("ak_live_abcdef123456789"); + expect(report).not.toContain("hunter2"); + expect(report).not.toContain("driver"); + expect(report).not.toContain("environment"); + expect(report).not.toContain("profile.name="); + expect(report).not.toContain("Ada"); + expect(report).not.toContain("note="); + }); + + it("never includes an absolute log path in the report heading", () => { + const report = buildDiagnosticsReport({ + appInfo, + configSummary: {}, + logTail: "server ready", + logPath: "/Users/ada/Library/Logs/OpenMausBot/server.log", + }); + expect(report).toContain("## Server log tail"); + expect(report).not.toContain("/Users/ada"); + }); + + it.each(CREDENTIAL_ENV_NAMES)("masks any value riding on %s in the log tail", (name) => { + const value = "s3cr3t-value-123456"; + const line = `spawn env ${name}=${value} ready`; + const report = buildDiagnosticsReport({ appInfo, configSummary: {}, logTail: line }); + expect(report).not.toContain(value); + expect(redactSecretsInLine(line)).toBe(`spawn env ${name}=«redacted ${value.length} chars» ready`); + }); + + it("masks generic key=value secrets and content-shaped tokens in the log tail", () => { + const report = buildDiagnosticsReport({ + appInfo, + configSummary: {}, + logTail: [ + 'config {"apiKey":"sk-proj-abcdefghijklmnop"}', + "Authorization: Bearer abcdefghijklmnop", + "password=hunter2000", + ].join("\n"), + }); + expect(report).not.toContain("sk-proj-abcdefghijklmnop"); + expect(report).not.toContain("abcdefghijklmnop"); + expect(report).not.toContain("hunter2000"); + expect(report).toContain("«redacted"); + }); + + it.each(["Bearer abcdefghijklmnop", "Basic dXNlcjpwYXNzd29yZA=="])( + "masks the full Authorization credential for %s", + (authorization) => { + const report = buildDiagnosticsReport({ + appInfo, + configSummary: {}, + logTail: `request Authorization: ${authorization}`, + }); + expect(report).not.toContain(authorization); + expect(report).not.toContain(authorization.split(" ")[1]); + expect(report).toContain("Authorization=«redacted"); + }, + ); + + it("masks a multiline PEM private key as one value", () => { + const report = buildDiagnosticsReport({ + appInfo, + configSummary: {}, + logTail: [ + "loading credential", + "-----BEGIN PRIVATE KEY-----", + "super-secret-line-one", + "super-secret-line-two", + "-----END PRIVATE KEY-----", + "ready", + ].join("\n"), + }); + expect(report).not.toContain("super-secret-line-one"); + expect(report).not.toContain("super-secret-line-two"); + expect(report).toContain("«redacted private key»"); + }); + + it("leaves ordinary log lines untouched", () => { + const line = "[2026-08-22T20:00:00.000Z] [out] fork server/index.js port=8799 spawned pid=4242"; + expect(redactSecretsInLine(line)).toBe(line); + }); + + it("handles an empty or missing log gracefully", () => { + for (const logTail of ["", null, undefined]) { + const report = buildDiagnosticsReport({ appInfo, configSummary: {}, logTail }); + expect(report).toContain("(server log unavailable)"); + expect(report.endsWith("\n")).toBe(true); + } + }); + + it("keeps long prose lines that merely mention a key by name", () => { + const line = "user asked whether the XAI_API_KEY variable needs to be set manually"; + expect(redactSecretsInLine(line)).toBe(line); + }); +}); + +describe("decodeLogTail", () => { + it("preserves the full buffer when the read starts at the beginning", () => { + expect(decodeLogTail(Buffer.from("first\nsecond"), false)).toEqual({ tail: "first\nsecond", bytes: 12 }); + }); + + it("drops a credential assignment split by a bounded tail read", () => { + const decoded = decodeLogTail(Buffer.from("RET=split-secret\nserver ready\n"), true); + expect(decoded).toEqual({ tail: "server ready\n", bytes: 13 }); + expect(decoded.tail).not.toContain("split-secret"); + }); + + it("returns an empty tail when a truncated buffer has no complete line", () => { + expect(decodeLogTail(Buffer.from("partial-secret"), true)).toEqual({ tail: "", bytes: 0 }); + }); +}); + +describe("diagnosticsFileName", () => { + it("uses openmausbot-diagnostics-YYYYMMDD-HHmmss.txt", () => { + expect(diagnosticsFileName(new Date(2026, 7, 22, 16, 5, 9))).toBe( + "openmausbot-diagnostics-20260822-160509.txt", + ); + }); +}); diff --git a/electron/main.mjs b/electron/main.mjs index cd2ffed6f..08ea3411a 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -9,7 +9,9 @@ import { createAndroidDeviceController } from "./android-device.mjs"; import { finishSpeech, startSpeech, stopSpeech } from "./speech.mjs"; import { openBlankTerminal } from "./terminal-launch.mjs"; import { startUpdater, registerUpdaterIpc } from "./updater.mjs"; +import { buildDiagnosticsReport, decodeLogTail, diagnosticsFileName } from "./diagnostics.mjs"; import { migrateWorkspaceCredentials, workspaceCredentialEnv } from "./workspace-credentials.mjs"; +import { activateExistingWindow } from "./single-instance.mjs"; import capabilitiesModule from "./capabilities.cjs"; const { desktopCapabilities, nativeDesktopActions } = capabilitiesModule; @@ -40,6 +42,17 @@ let mainWindow = null; // identities match. This must run before Electron becomes ready. if (process.platform === "linux") app.setDesktopName("com.openmausbot.app.desktop"); +// One instance per user: without this lock a second launch forks a second +// harness server on a fallback port and splits data dirs in two. The loser +// exits before any child or window exists; the winner surfaces itself. +if (!app.requestSingleInstanceLock()) { + console.log("[desktop] OpenMausBot is already running — focusing that window"); + process.exit(0); +} +app.on("second-instance", () => { + activateExistingWindow(BrowserWindow.getAllWindows()); +}); + // Packaged: the harness server ships in Resources (compiled JS, zero deps) // and runs on Electron's own Node via utilityProcess. It serves the built // UI too, so the window talks to one origin and there is no dev proxy. @@ -214,6 +227,53 @@ function slog(line) { } } +const LOG_TAIL_BYTES = 256 * 1024; + +function readLogTail(logPath) { + try { + const size = fs.statSync(logPath).size; + const start = Math.max(0, size - LOG_TAIL_BYTES); + const handle = fs.openSync(logPath, "r"); + try { + const buffer = Buffer.alloc(size - start); + fs.readSync(handle, buffer, 0, buffer.length, start); + return decodeLogTail(buffer, start > 0); + } finally { + fs.closeSync(handle); + } + } catch { + return null; + } +} + +// Everything the bug-report bundle needs. The config summary comes from the +// server's own booleans-only /api/config status (credentials are never +// echoed), and the log goes through the redactor in diagnostics.mjs — so the +// file is safe to paste into a public issue even if a future log line ever +// carried a secret. +async function gatherDiagnostics() { + const serverStatus = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/config`, { + signal: AbortSignal.timeout(3_000), + }) + .then((res) => (res.ok ? res.json() : null)) + .catch(() => null); + const logPath = path.join(LOG_DIR, "server.log"); + const log = readLogTail(logPath); + return buildDiagnosticsReport({ + appInfo: { + version: app.getVersion(), + platform: process.platform, + arch: process.arch, + electron: process.versions.electron, + node: process.versions.node, + packaged: app.isPackaged, + uptimeSeconds: Math.round(process.uptime()), + }, + configSummary: serverStatus ?? {}, + logTail: log?.tail ?? "", + }); +} + async function startServerOn(port) { const entry = path.join(process.resourcesPath, "server", "index.js"); slog(`fork ${entry} port=${port}`); @@ -450,9 +510,6 @@ function ensureDesktopWorkspace(owner) { }); desktopWorkspaceManager = manager; - // Native child views outlive the renderer DOM unless we explicitly tear - // them down. Reloads, renderer crashes and owner destruction all close both - // panes without retaining their secret-bearing noVNC URLs. owner.webContents.on("did-start-navigation", (_event, _url, isInPlace, isMainFrame) => { if (isMainFrame && !isInPlace) manager.closeAll(); }); @@ -681,6 +738,33 @@ ipcMain.handle("desktop:pick-folder", async (event, current) => { return result.canceled ? null : (result.filePaths[0] ?? null); }); +// One-click bug-report bundle. Secrets are never read; the report is +// redacted again on the way out (diagnostics.mjs). null means the user +// cancelled the save dialog. +ipcMain.handle("desktop:export-diagnostics", async (event) => { + const owner = BrowserWindow.fromWebContents(event.sender) ?? undefined; + const report = await gatherDiagnostics(); + const result = await dialog.showSaveDialog(owner, { + title: "Export diagnostics", + defaultPath: diagnosticsFileName(), + filters: [{ name: "Text", extensions: ["txt"] }], + }); + if (result.canceled || !result.filePath) return null; + if (process.platform === "win32") { + fs.writeFileSync(result.filePath, report, { mode: 0o600 }); + } else { + const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW; + const handle = fs.openSync(result.filePath, flags, 0o600); + try { + fs.fchmodSync(handle, 0o600); + fs.writeFileSync(handle, report, "utf8"); + } finally { + fs.closeSync(handle); + } + } + return result.filePath; +}); + ipcMain.handle("desktop:open-external", async (_event, rawUrl) => { if (typeof rawUrl !== "string") throw new Error("A web address is required"); let url; @@ -704,9 +788,6 @@ ipcMain.handle("desktop-viewer:open", (event, rawUrl, title, contextId) => { return openDesktopViewer(owner, rawUrl, title, contextId); }); -// Two Local VM desktops share the existing app BrowserWindow. The renderer -// supplies only layout and intent; URL validation, sandboxing, session -// isolation and the one-interactive-pane invariant stay in the main process. ipcMain.handle("desktop-workspace:open", (event, input) => desktopWorkspaceForEvent(event, true).open(input), ); diff --git a/electron/preload.cjs b/electron/preload.cjs index cee8b3fd6..840657dee 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -100,6 +100,9 @@ contextBridge.exposeInMainWorld("ogb", { }, /** Native folder picker for a bot's working folder; null when cancelled. */ pickFolder: (current) => ipcRenderer.invoke("desktop:pick-folder", current), + /** Writes the redacted diagnostics report to a user-chosen file; resolves + * the path, or null when the save dialog was cancelled. */ + exportDiagnostics: () => ipcRenderer.invoke("desktop:export-diagnostics"), /** Store a provider credential with OS-backed encryption. */ setCredential: (name, value) => ipcRenderer.invoke("credential:set", name, value), diff --git a/electron/single-instance.mjs b/electron/single-instance.mjs new file mode 100644 index 000000000..af83a8851 --- /dev/null +++ b/electron/single-instance.mjs @@ -0,0 +1,16 @@ +// Single-instance policy for the desktop shell, kept Electron-free so the +// activation rules stay unit-testable with plain object fakes. + +// Surface the existing app when a second launch gets absorbed: restore a +// minimized window, then show and focus. Prefer whatever window currently +// holds focus so a future multi-window layout lands predictably; otherwise +// the first living window wins. +export function activateExistingWindow(windows) { + const alive = windows.filter((win) => win && !win.isDestroyed()); + if (alive.length === 0) return false; + const target = alive.findLast((win) => win.isFocused()) ?? alive[0]; + if (target.isMinimized()) target.restore(); + target.show(); + target.focus(); + return true; +} diff --git a/electron/single-instance.node-test.mjs b/electron/single-instance.node-test.mjs new file mode 100644 index 000000000..4fa5bfcc1 --- /dev/null +++ b/electron/single-instance.node-test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { activateExistingWindow } from "./single-instance.mjs"; + +function fakeWindow({ destroyed = false, minimized = false, focused = false } = {}) { + const calls = []; + return { + calls, + isDestroyed: () => destroyed, + isMinimized: () => minimized, + isFocused: () => focused, + restore: () => calls.push("restore"), + show: () => calls.push("show"), + focus: () => calls.push("focus"), + }; +} + +test("shows and focuses the only living window", () => { + const win = fakeWindow(); + assert.equal(activateExistingWindow([win]), true); + assert.deepEqual(win.calls, ["show", "focus"]); +}); + +test("restores a minimized window before showing it", () => { + const win = fakeWindow({ minimized: true }); + assert.equal(activateExistingWindow([win]), true); + assert.deepEqual(win.calls, ["restore", "show", "focus"]); +}); + +test("skips destroyed windows without touching them", () => { + const dead = fakeWindow({ destroyed: true }); + const alive = fakeWindow(); + assert.equal(activateExistingWindow([dead, alive]), true); + assert.deepEqual(dead.calls, []); + assert.deepEqual(alive.calls, ["show", "focus"]); +}); + +test("prefers the focused window among several", () => { + const first = fakeWindow(); + const second = fakeWindow({ focused: true }); + assert.equal(activateExistingWindow([first, second]), true); + assert.deepEqual(first.calls, []); + assert.deepEqual(second.calls, ["show", "focus"]); +}); + +test("reports failure when no window can be activated", () => { + const dead = fakeWindow({ destroyed: true }); + assert.equal(activateExistingWindow([]), false); + assert.equal(activateExistingWindow([dead]), false); + assert.deepEqual(dead.calls, []); +}); diff --git a/package.json b/package.json index c594d4d36..29d66525e 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "typecheck": "tsc -b && tsc -p tsconfig.server.json", "test": "node scripts/test-floor.mjs && pnpm broker:test && pnpm test:updater && pnpm test:desktop-viewer && pnpm test:packaged-server", "test:updater": "node --test electron/updater-coordinator.node-test.mjs", - "test:desktop-viewer": "node --test electron/desktop-viewer.node-test.mjs electron/desktop-workspace.node-test.mjs", + "test:desktop-viewer": "node --test electron/desktop-viewer.node-test.mjs", "bench:observation": "node --experimental-strip-types scripts/bench-observation.ts", "test:watch": "vitest", "test:cua": "pnpm build:cua && node scripts/smoke-cua.mjs", diff --git a/scripts/check-skin-contrast.mjs b/scripts/check-skin-contrast.mjs index 05f71bbb2..235baab2b 100644 --- a/scripts/check-skin-contrast.mjs +++ b/scripts/check-skin-contrast.mjs @@ -110,6 +110,25 @@ const PAIRS = [ ["--color-focus", "--color-app", 3], ["--color-focus", "--color-panel", 3], ["--color-focus", "--color-card", 3], + // Surface against surface. Text contrast alone will not catch a skin that + // gives two surfaces the same value: Atelier and Lagoon both defined + // `raised` as the pure white they use for a card, so every chip, hover fill + // and answered row painted in `raised` on a card was invisible while this + // file stayed green. A surface is not text — it only has to be seen at all — + // so the bar is a just-perceptible step rather than a WCAG ratio. + // + // `control` is measured against every surface it can land on, which is the + // whole list: a tone chosen to clear the card and the panel drifted into + // `inset` instead, and the badges inside an inset row went invisible again. + ["--color-control", "--color-card", 1.06], + ["--color-control", "--color-panel", 1.06], + ["--color-control", "--color-app", 1.04], + ["--color-control", "--color-inset", 1.04], + ["--color-control", "--color-raised-hover", 1.04], + ["--color-raised-hover", "--color-card", 1.04], + ["--color-inset", "--color-card", 1.04], + ["--color-card", "--color-app", 1.04], + ["--color-panel", "--color-app", 1.03], ]; const skins = parseSkins(css); diff --git a/server/bot-directory.test.ts b/server/bot-directory.test.ts new file mode 100644 index 000000000..a8d0ca95b --- /dev/null +++ b/server/bot-directory.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + BOT_DIRECTORY_API_URL, + fetchBotDirectory, + matchDirectoryBots, + parseBotDirectory, + type DirectoryBot, +} from "./bot-directory.ts"; +import type { ProjectProfile } from "./project-scout.ts"; + +const entry = (over: Partial> = {}) => ({ + slug: "release-scribe", + name: "Release Scribe", + category: "Docs", + integrations: ["GitHub"], + prompt: "Set up a bot that drafts release notes.", + detailUrl: "https://botdirectory.ai/bots/release-scribe/", + ...over, +}); + +describe("parseBotDirectory", () => { + it("accepts the published shape and keeps only the fields we use", () => { + const bots = parseBotDirectory({ version: 1, bots: [entry({ contributor: "someone", addedAt: "2026" })] }); + expect(bots).toEqual([ + { + slug: "release-scribe", + name: "Release Scribe", + category: "Docs", + integrations: ["GitHub"], + prompt: "Set up a bot that drafts release notes.", + detailUrl: "https://botdirectory.ai/bots/release-scribe/", + }, + ]); + }); + + it("drops malformed entries instead of failing the whole directory", () => { + const bots = parseBotDirectory({ + version: 1, + bots: [ + entry(), + entry({ slug: "UPPER CASE" }), + entry({ slug: "no-prompt", prompt: "" }), + entry({ slug: "elsewhere", detailUrl: "https://evil.example/bots/x/" }), + entry(), // duplicate slug + "not an object", + ], + }); + expect(bots.map((bot) => bot.slug)).toEqual(["release-scribe"]); + }); + + it("rejects a response that is not the directory", () => { + expect(() => parseBotDirectory({ version: 2, bots: [] })).toThrow("not supported"); + expect(() => parseBotDirectory([])).toThrow("not supported"); + }); +}); + +describe("fetchBotDirectory", () => { + it("fetches, validates, and passes errors through", async () => { + const ok = vi.fn(async () => new Response(JSON.stringify({ version: 1, bots: [entry()] }))); + await expect(fetchBotDirectory(ok as unknown as typeof fetch)).resolves.toHaveLength(1); + expect(ok).toHaveBeenCalledWith(BOT_DIRECTORY_API_URL, expect.objectContaining({ redirect: "error" })); + + const down = vi.fn(async () => new Response("nope", { status: 503 })); + await expect(fetchBotDirectory(down as unknown as typeof fetch)).rejects.toThrow("HTTP 503"); + }); + + it("rejects an oversized response while reading, not after buffering it whole", async () => { + // no content-length header on purpose: the announced-size shortcut must + // not be the only guard. The stream never ends on its own — the fetch + // has to bail the moment the cap is crossed, or this test times out. + const chunk = new TextEncoder().encode("x".repeat(64 * 1024)); + let sent = 0; + let cancelled = false; + const endless = new ReadableStream({ + pull(controller) { + sent += chunk.byteLength; + controller.enqueue(chunk); + }, + cancel() { + cancelled = true; + }, + }); + const big = vi.fn(async () => new Response(endless)); + await expect(fetchBotDirectory(big as unknown as typeof fetch)).rejects.toThrow("too large"); + expect(cancelled).toBe(true); + // barely past the 1 MB cap — nowhere near what an unbounded read would take + expect(sent).toBeLessThan(2_000_000); + + const announced = vi.fn(async () => + new Response("{}", { headers: { "content-length": String(50_000_000) } }), + ); + await expect(fetchBotDirectory(announced as unknown as typeof fetch)).rejects.toThrow("too large"); + }); +}); + +describe("matchDirectoryBots", () => { + const profile: ProjectProfile = { + name: "Shop", + summary: "A storefront with payments.", + stacks: ["TypeScript", "React"], + signals: [{ role: "frontend", evidence: ["react"] }], + }; + const bots: DirectoryBot[] = [ + { ...entry(), slug: "react-reviewer", name: "React Reviewer", category: "Engineering", integrations: ["GitHub"] } as DirectoryBot, + { ...entry(), slug: "payments-auditor", name: "Payments Auditor", category: "Finance", integrations: ["Stripe"] } as DirectoryBot, + { ...entry(), slug: "gig-closer", name: "Gig Closer", category: "Ops", integrations: ["QuickBooks"] } as DirectoryBot, + ]; + + it("returns only bots that overlap the profile, best match first, with the matched terms", () => { + const matched = matchDirectoryBots(profile, bots); + expect(matched.map((bot) => bot.slug)).toEqual(["react-reviewer", "payments-auditor"]); + expect(matched[0]!.matched).toContain("react"); + expect(matched[1]!.matched).toContain("payments"); + }); + + it("honors the limit", () => { + expect(matchDirectoryBots(profile, bots, 1)).toHaveLength(1); + }); + + it("ignores stack names too short to mean anything as substrings", () => { + const goProfile: ProjectProfile = { name: "svc", summary: "", stacks: ["Go"], signals: [] }; + const google = { ...entry(), slug: "google-helper", name: "Google Helper", category: "Ops", integrations: [] } as DirectoryBot; + expect(matchDirectoryBots(goProfile, [google])).toEqual([]); + }); +}); diff --git a/server/bot-directory.ts b/server/bot-directory.ts new file mode 100644 index 000000000..8408a18d8 --- /dev/null +++ b/server/bot-directory.ts @@ -0,0 +1,150 @@ +import { parseJson } from "./schema.ts"; +import type { ProjectProfile } from "./project-scout.ts"; + +export const BOT_DIRECTORY_URL = "https://botdirectory.ai"; +export const BOT_DIRECTORY_API_URL = "https://api.botdirectory.ai/api/bots"; + +const MAX_DIRECTORY_BYTES = 1_000_000; +const MAX_DIRECTORY_BOTS = 200; + +export interface DirectoryBot { + slug: string; + name: string; + category: string; + integrations: string[]; + /** the community-written setup prompt — shown to the human, and only ever + * imported as a bot description through the same persona-only boundary as + * any shared team file */ + prompt: string; + detailUrl: string; +} + +export interface MatchedDirectoryBot extends DirectoryBot { + /** the profile terms this bot matched on, for the human reviewing it */ + matched: string[]; +} + +type Fetcher = typeof fetch; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +function text(value: unknown, max: number): string | null { + if (typeof value !== "string" || !value.trim()) return null; + const normalized = value.trim(); + return normalized.length > max ? null : normalized; +} + +/** Validate the community-maintained index before any of it reaches the + * renderer. Entries that do not parse are dropped, not fatal — one bad + * community submission must not blank the whole directory. */ +export function parseBotDirectory(value: unknown): DirectoryBot[] { + if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.bots)) { + throw new Error("The bot directory response is not supported"); + } + const bots: DirectoryBot[] = []; + const seen = new Set(); + for (const raw of value.bots.slice(0, MAX_DIRECTORY_BOTS)) { + if (!isRecord(raw)) continue; + const slug = text(raw.slug, 100); + if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug) || seen.has(slug)) continue; + const name = text(raw.name, 100); + const prompt = text(raw.prompt, 4_000); + if (!name || !prompt) continue; + const detailUrl = text(raw.detailUrl, 300); + if (!detailUrl || !detailUrl.startsWith(`${BOT_DIRECTORY_URL}/`)) continue; + seen.add(slug); + bots.push({ + slug, + name, + category: text(raw.category, 80) ?? "", + integrations: Array.isArray(raw.integrations) + ? raw.integrations.flatMap((item) => text(item, 80) ?? []).slice(0, 20) + : [], + prompt, + detailUrl, + }); + } + return bots; +} + +/** Read the body in bounded chunks: an oversized or endless response is + * rejected the moment it crosses the cap, never buffered whole first. */ +async function readBounded(response: Response, maxBytes: number): Promise { + const oversized = () => new Error("The bot directory response is too large"); + const announced = Number(response.headers.get("content-length") ?? 0); + if (announced > maxBytes) throw oversized(); + if (!response.body) { + const raw = await response.text(); + if (Buffer.byteLength(raw) > maxBytes) throw oversized(); + return raw; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => {}); + throw oversized(); + } + chunks.push(value); + } + return Buffer.concat(chunks).toString("utf8"); +} + +export async function fetchBotDirectory(fetcher: Fetcher = fetch): Promise { + const response = await fetcher(BOT_DIRECTORY_API_URL, { + headers: { accept: "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) throw new Error(`The bot directory returned HTTP ${response.status}`); + return parseBotDirectory(parseJson(await readBounded(response, MAX_DIRECTORY_BYTES))); +} + +/** Rank directory bots against a scouted project: overlap between the + * project's stacks/summary words and a bot's name, category and + * integrations. Purely lexical on purpose — deterministic, explainable, + * and honest about being a hint rather than a verdict. */ +// Words that appear in nearly every project blurb and would match nearly +// every directory entry. A match on one of these is noise, not affinity — +// measured live: "Your own team of AI bots" matched a home-value tracker +// purely on "your". +const STOPWORDS = new Set([ + "your", "with", "that", "this", "from", "have", "what", "when", "where", "will", + "them", "then", "than", "only", "also", "into", "over", "about", "after", "before", + "every", "some", "most", "much", "many", "very", "just", "like", "each", "other", + "their", "there", "these", "those", "using", "based", "open", "free", "fast", + "simple", "easy", "apps", "tool", "tools", "project", "team", "chat", "bots", +]); + +export function matchDirectoryBots( + profile: ProjectProfile, + bots: DirectoryBot[], + limit = 5, +): MatchedDirectoryBot[] { + const terms = new Set(); + for (const stack of profile.stacks) { + const term = stack.toLowerCase(); + // a two- or three-letter stack ("go", "php") substring-matches half the + // directory — "google", "django", "logo" are not Go affinity + if (term.length >= 4) terms.add(term); + } + for (const signal of profile.signals) terms.add(signal.role); + // "." and "#" stay word-internal for the likes of next.js and C#, but a + // sentence-final "payments." must still match "Payments" + for (const raw of `${profile.name} ${profile.summary}`.toLowerCase().split(/[^a-z0-9+.#-]+/)) { + const word = raw.replace(/^[.#+-]+|[.#+-]+$/g, ""); + if (word.length >= 4 && !STOPWORDS.has(word)) terms.add(word); + } + + const scored = bots.flatMap((bot) => { + const haystackParts = [bot.name, bot.category, ...bot.integrations].map((part) => part.toLowerCase()); + const matched = [...terms].filter((term) => haystackParts.some((part) => part.includes(term))); + return matched.length > 0 ? [{ ...bot, matched }] : []; + }); + return scored.sort((a, b) => b.matched.length - a.matched.length).slice(0, limit); +} diff --git a/server/config.test.ts b/server/config.test.ts index 0d40215d9..0f68edd0f 100644 --- a/server/config.test.ts +++ b/server/config.test.ts @@ -89,10 +89,22 @@ describe("configuration boundaries", () => { }); describe("default fleet", () => { - it("ships Qwen and Hermes as custom-only engines", () => { + it("ships Qwen, Hermes, and direct Mac/Windows models as custom-only engines", () => { const map = instanceConfigs({}); expect(map.qwen).toEqual({ driver: "qwenAgent", environment: {} }); expect(map.hermes).toEqual({ driver: "hermesAgent", environment: {} }); + expect(map.localMac).toEqual({ + driver: "local", + displayName: "Mac M5 models", + config: { host: "ollama", fleetHost: "mac" }, + environment: {}, + }); + expect(map.localWindows).toEqual({ + driver: "local", + displayName: "Windows models", + config: { host: "custom", url: "http://127.0.0.1:18134/v1", fleetHost: "windows" }, + environment: {}, + }); }); it("ships Cursor as a default-fleet subscription engine", () => { @@ -105,7 +117,10 @@ describe("default fleet", () => { expect(map.claude.driver).toBe("claudeAgent"); expect(map.qwen?.driver).toBe("qwenAgent"); expect(map.hermes?.driver).toBe("hermesAgent"); + expect(map.localMac?.driver).toBe("local"); + expect(map.localWindows?.driver).toBe("local"); expect(map.cursor?.driver).toBe("cursorAgent"); + expect(map.openaiCompat?.driver).toBe("openai-compat"); }); it("does not expand a one-off shadow fleet", () => { diff --git a/server/config.ts b/server/config.ts index d20beddcf..4241d6865 100644 --- a/server/config.ts +++ b/server/config.ts @@ -71,6 +71,7 @@ const instanceConfigSchema = z.object({ const instanceConfigMapSchema = z.record(z.string(), instanceConfigSchema); const appConfigSchema = z.object({ xai: z.object({ key: optionalText, url: optionalText }).optional(), + openaiCompat: z.object({ key: optionalText, url: optionalText }).optional(), /** Project key used for Sessions, catalog and agent tools. userId/sessionId * are non-secret local identifiers used to reuse one Composio Session. */ composio: z.object({ apiKey: optionalText, userId: optionalText, sessionId: optionalText }).optional(), @@ -93,6 +94,7 @@ const jsonObjectSchema = z.record(z.string(), z.json()); export interface AppConfig { xai?: { key?: string; url?: string }; + openaiCompat?: { key?: string; url?: string }; composio?: { apiKey?: string; userId?: string; sessionId?: string }; box?: { token?: string }; /** A named host from the user's SSH config. Authentication stays with SSH. */ @@ -344,6 +346,10 @@ interface InstanceCliUpdate { function injectedEnvironment(cfg: AppConfig, driver: string): Map { const environment = new Map(); if (driver === "grok" && cfg.xai?.key) environment.set("XAI_API_KEY", cfg.xai.key); + if (driver === "openai-compat" && cfg.openaiCompat?.key) + environment.set("OPENAI_COMPAT_API_KEY", cfg.openaiCompat.key); + if (driver === "openai-compat" && cfg.openaiCompat?.url) + environment.set("OPENAI_COMPAT_URL", cfg.openaiCompat.url); if (driver === "boxAgent" && cfg.box?.token) environment.set("BOX_TOKEN", cfg.box.token); if (driver === "opencodeGo" && cfg.opencodeGo?.apiKey) environment.set("OPENCODE_API_KEY", cfg.opencodeGo.apiKey); return environment; @@ -378,20 +384,34 @@ export function instanceConfigs(cfg: AppConfig): InstanceConfigMap { antigravity: { driver: "antigravityAgent" }, opencodeGo: { driver: "opencodeGo" }, computer: { driver: "boxAgent" }, + openaiCompat: { driver: "openai-compat" }, qwen: { driver: "qwenAgent" }, hermes: { driver: "hermesAgent" }, pi: { driver: "piAgent" }, + localMac: { driver: "local", displayName: "Mac M5 models", config: { host: "ollama", fleetHost: "mac" } }, + localWindows: { + driver: "local", + displayName: "Windows models", + config: { host: "custom", url: "http://127.0.0.1:18134/v1", fleetHost: "windows" }, + }, }; const CUSTOM_ONLY = { qwen: { driver: "qwenAgent" }, hermes: { driver: "hermesAgent" }, pi: { driver: "piAgent" }, + localMac: { driver: "local", displayName: "Mac M5 models", config: { host: "ollama", fleetHost: "mac" } }, + localWindows: { + driver: "local", + displayName: "Windows models", + config: { host: "custom", url: "http://127.0.0.1:18134/v1", fleetHost: "windows" }, + }, } as const; // New default-fleet engines that existing product configs would otherwise // never see. Custom-only engines stay in CUSTOM_ONLY so a one-off test map // is not expanded, matching the claude/grok/codex product-fleet probe. const PRODUCT_FLEET_ADDITIONS = { cursor: { driver: "cursorAgent" }, + openaiCompat: { driver: "openai-compat" }, ...CUSTOM_ONLY, } as const; const configured = cfg.instances && Object.keys(cfg.instances).length ? cfg.instances : null; diff --git a/server/contracts.ts b/server/contracts.ts index 63a051fa2..1b3a5e94d 100644 --- a/server/contracts.ts +++ b/server/contracts.ts @@ -290,18 +290,47 @@ export interface EngineInstall { // `create` owns ALL per-instance state; two create calls share nothing. // Failures must reject, never throw synchronously — the registry downgrades // a rejection to an unavailable shadow snapshot. +export type ModelCostClass = "free" | "paid" | "paid_subscription" | "paid_metered" | "local" | "unknown"; + +export interface ModelRuntimeStatus { + configured: boolean; + reachable: boolean; + verified: boolean; + admitted: boolean; + busy: boolean; +} + +export interface ModelOption { + /** The model id understood by this concrete OpenMausBot driver. */ + id: string; + label: string; + custom?: boolean; + loaded?: boolean; + /** Fleet-wide stable id. Present only for rows projected by the guarded + * secret-free AOS model catalog. */ + canonicalId?: string; + provider?: string; + host?: string; + costClass?: ModelCostClass; + manualOnly?: boolean; + isDefault?: boolean; + capabilities?: string[]; + status?: ModelRuntimeStatus; + /** False means the row stays visible for inventory/truth, but cannot be + * selected until a fresh catalog refresh marks it admitted and idle. */ + selectable?: boolean; + reason?: string; + lastVerified?: string; + verificationReceipt?: string; + /** total context window in tokens, when the driver knows it — sizes + * the model-facing rebuild (server/context-rebuild.ts). Unknown falls + * back to a pattern table over the model id, then a conservative default. */ + contextWindow?: number; +} + export interface ModelCatalog { default: string; - options: Array<{ - id: string; - label: string; - custom?: boolean; - loaded?: boolean; - /** total context window in tokens, when the driver knows it — sizes - * the model-facing rebuild (server/context-rebuild.ts). Unknown falls - * back to a pattern table over the model id, then a conservative default. */ - contextWindow?: number; - }>; + options: ModelOption[]; } export interface DriverCreateInput { diff --git a/server/drivers/acp/acp.test.ts b/server/drivers/acp/acp.test.ts index ca3526597..4d7019b1b 100644 --- a/server/drivers/acp/acp.test.ts +++ b/server/drivers/acp/acp.test.ts @@ -15,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ensureDirs } from "../../config.ts"; import type { ProviderInstance } from "../../contracts.ts"; import { recordEvents, type EventRecorder } from "../../testing/events.ts"; -import { createAcpDriver, type AcpSupport } from "./core.ts"; +import { createAcpDriver, skipSubscriptionAuthForLocalInject, type AcpSupport } from "./core.ts"; import { GrokAgentDriver } from "./grok.ts"; import { GeminiAgentDriver } from "./gemini.ts"; import { KimiAgentDriver } from "./kimi.ts"; @@ -73,6 +73,15 @@ const ClassifiedErrorDriver = createAcpDriver({ : undefined, }); +describe("skipSubscriptionAuthForLocalInject", () => { + it("is true only for a host:: inject id", () => { + expect(skipSubscriptionAuthForLocalInject("omlx::MiniMax-M3-4bit")).toBe(true); + expect(skipSubscriptionAuthForLocalInject("unsloth::orcarouter/Qwen3.8-27B-Uncensored-GGUF")).toBe(true); + expect(skipSubscriptionAuthForLocalInject("grok-4.6")).toBe(false); + expect(skipSubscriptionAuthForLocalInject(undefined)).toBe(false); + }); +}); + describe("ACP decodeConfig", () => { it("resolves a dynamic model catalog when a support provides one", async () => { const support: AcpSupport = { @@ -225,10 +234,10 @@ describe("ACP turns (fake CLI)", () => { "turn.started", "session.started", "content.delta", + "item.completed", // assistant_text before the tool, not summed on settle "item.started", // tool tc-1 "item.completed", // tool tc-1 done "thread.token-usage.updated", - "item.completed", // assistant_text (summed) on settle "turn.completed", ]); expect(recorder.events.every((e) => e.turnId === turnId && e.provider === "grokAgent")).toBe(true); @@ -241,6 +250,34 @@ describe("ACP turns (fake CLI)", () => { expect(instance.adapter.hasSession("t-happy")).toBe(false); }); + it("emits each assistant text block before the tool that follows it", async () => { + await create(GrokAgentDriver, "interleave"); + await instance.adapter.sendTurn({ threadId: "t-interleave", text: "go", model: "grok-4.5" }); + await recorder.until((e) => e.type === "turn.completed"); + + const types = recorder.events.map((e) => e.type); + expect(types).toEqual([ + "turn.started", + "session.started", + "content.delta", + "item.completed", // before one + "item.started", // tc-1 + "item.completed", // tc-1 + "content.delta", + "item.completed", // before two + "item.started", // tc-2 + "item.completed", // tc-2 + "content.delta", + "thread.token-usage.updated", + "item.completed", // after — no following tool, so settle flushes + "turn.completed", + ]); + const texts = recorder.events + .filter((e) => e.type === "item.completed" && (e as { itemType?: string }).itemType === "assistant_text") + .map((e) => (e as { text: string }).text); + expect(texts).toEqual(["before one", "before two", "after"]); + }); + it("reads token usage from the root of the prompt result", async () => { process.env.FAKE_ACP_USAGE_ROOT = "1"; await create(); @@ -456,6 +493,27 @@ describe("ACP turns (fake CLI)", () => { expect(err.message).toMatch(/not signed in/); }); + it("grok local inject does not require grok.com login", async () => { + process.env.FAKE_ACP_MODE = "no-auth"; + mkdirSync(join(scratch, ".grok"), { recursive: true }); + instance = await GrokAgentDriver.create({ + instanceId: "acp-test", + displayName: "ACP Test", + environment: { HOME: scratch, GROK_HOME: join(scratch, ".grok") }, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + recorder = recordEvents(instance.adapter); + await instance.adapter.sendTurn({ + threadId: "t-local-auth", + text: "go", + model: "omlx::MiniMax-M3-4bit", + }); + const done = await recorder.until((e) => e.type === "turn.completed"); + expect(done).toMatchObject({ ok: true }); + expect(recorder.events.some((e) => e.type === "runtime.error")).toBe(false); + }); + it("gemini proceeds through a missing auth method (lenient login)", async () => { await create(GeminiAgentDriver, "no-auth"); await instance.adapter.sendTurn({ threadId: "t-lenient", text: "go" }); diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index b77ae4718..ed7c347cd 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -16,8 +16,17 @@ import { homedir } from "node:os"; import { PROVIDER_CREDENTIAL_ENV, WORKSPACE_CREDENTIAL_ENV } from "../../config.ts"; +import { decodeInjectId } from "../local-inject.ts"; import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../../procs.ts"; +/** + * A `host::model` pick talks to a loopback server with its own key. + * Subscription ACP login (grok.com cached_token) must not fail that turn. + */ +export function skipSubscriptionAuthForLocalInject(model: string | undefined): boolean { + return Boolean(decodeInjectId(model)); +} + import type { DriverCreateInput, EffortLevel, @@ -127,6 +136,12 @@ export interface AcpSupport { sessionId: string; config: AcpConfig; turn: SendTurnInput; + /** `session/new` (or `session/load`) advertised model list, verbatim. Some + * CLIs namespace their ACP model ids differently from their argv `--model` + * slugs (Cursor answers `default[]` where the CLI calls it `auto`), so a + * driver that only knows the argv slug cannot form a valid set_model + * without this. Empty when the agent advertised none. */ + sessionModels: Array<{ modelId?: string; name?: string }>; }): Promise; } @@ -146,6 +161,10 @@ function decodeAcpConfig(defaultCli: string) { }; } +/** + * ACP JSON-RPC-over-stdio driver. Harness differences (argv, auth, catalog) + * live in `support`; this is the shared handshake and turn runtime. + */ export function createAcpDriver(support: AcpSupport): ProviderDriver { const DRIVER_KIND = support.driverKind; const SOURCE = support.nativeSource; @@ -317,6 +336,14 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver const stop = () => killCliTree(child); + /** Emit buffered assistant text as its own item, then clear it. */ + const flushAssistantText = () => { + const text = state.text; + state.text = ""; + if (!text.trim()) return; + emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text }); + }; + const settle = (ok: boolean, stopReason: string | null) => { if (state.settled) return; state.settled = true; @@ -328,9 +355,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver } rpcPending.clear(); active.delete(threadId); - if (state.text.trim()) { - emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text: state.text }); - } + flushAssistantText(); emit({ ...base(threadId, turnId), type: "turn.completed", ok, stopReason, cost: null }); stop(); // the agent process does not exit on its own }; @@ -342,6 +367,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver return send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not found" } }); } const params = msg.params ?? {}; + flushAssistantText(); const options: Array<{ optionId?: string; kind?: string }> = Array.isArray(params.options) ? params.options : []; const optionFor = (want: "allow" | "reject") => options.find((o) => String(o.kind ?? "").startsWith(want) && typeof o.optionId === "string")?.optionId ?? null; @@ -428,6 +454,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver break; } case "tool_call": { + flushAssistantText(); emit({ ...base(threadId, turnId), type: "item.started", @@ -530,15 +557,17 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver ); const methods: Array<{ id?: string }> = Array.isArray(init?.authMethods) ? init.authMethods : []; const methodId = support.pickAuthMethod(methods); - if (methodId) { - try { - await request("authenticate", { methodId }, INIT_TIMEOUT); - } catch { - if (support.authFailure === "fail") throw new Error(support.loginNote); - // else: proceed on an ambient login + if (!skipSubscriptionAuthForLocalInject(turn.model)) { + if (methodId) { + try { + await request("authenticate", { methodId }, INIT_TIMEOUT); + } catch { + if (support.authFailure === "fail") throw new Error(support.loginNote); + // else: proceed on an ambient login + } + } else if (support.authFailure === "fail") { + throw new Error(support.loginNote); } - } else if (support.authFailure === "fail") { - throw new Error(support.loginNote); } const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null; @@ -605,6 +634,9 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver sessionId, config, turn: cliTurn, + sessionModels: Array.isArray(sessionResult?.models?.availableModels) + ? sessionResult.models.availableModels + : [], }); // initialize's currentModelId is the CLI default (grok-4.6), // not the model this turn asked for. After a successful pin, diff --git a/server/drivers/acp/cursor.test.ts b/server/drivers/acp/cursor.test.ts index 904584462..905162abf 100644 --- a/server/drivers/acp/cursor.test.ts +++ b/server/drivers/acp/cursor.test.ts @@ -16,6 +16,7 @@ import { decodeCursorModelCatalog, decodeCursorModelText, STATIC_CURSOR_MODELS, + resolveCursorAcpModelId, } from "./cursor.ts"; const FAKE_CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "testing", "fake-acp-cli.ts"); @@ -287,3 +288,111 @@ describe("CursorAgentDriver", () => { } }); }); + +describe("resolveCursorAcpModelId", () => { + // Real payload shape from `session/new` against cursor-agent 2026.08.11. + const ADVERTISED = [ + { modelId: "default[]", name: "Auto" }, + { modelId: "grok-4.6[effort=high,fast=true]", name: "grok-4.6" }, + { modelId: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "gpt-5.3-codex" }, + ]; + + it("maps the argv slug `auto` onto Cursor's `default[]` entry", () => { + // The bug: `auto` is what --model and `cursor-agent models` call it, and + // it earns -32602 over ACP because the session only knows `default[]`. + expect(resolveCursorAcpModelId(ADVERTISED, "auto")).toBe("default[]"); + }); + + it("maps a bare slug onto its parameterised id", () => { + expect(resolveCursorAcpModelId(ADVERTISED, "gpt-5.3-codex")).toBe( + "gpt-5.3-codex[reasoning=medium,fast=false]", + ); + }); + + it("maps a display name onto its parameterised id", () => { + expect( + resolveCursorAcpModelId( + [{ modelId: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" }], + "Codex 5.3", + ), + ).toBe("gpt-5.3-codex[reasoning=medium,fast=false]"); + }); + + it("passes an already-parameterised id straight through", () => { + expect(resolveCursorAcpModelId(ADVERTISED, "grok-4.6[effort=high,fast=true]")).toBe( + "grok-4.6[effort=high,fast=true]", + ); + }); + + it("returns null when the agent advertised no models, so the caller keeps the argv slug", () => { + expect(resolveCursorAcpModelId([], "auto")).toBeNull(); + }); + + it("returns null for a model this session does not offer", () => { + expect(resolveCursorAcpModelId(ADVERTISED, "no-such-model")).toBeNull(); + }); +}); + +describe("cursor ACP model namespace (NS: set_model wiring)", () => { + it("sends the session's parameterised id, not the argv slug", async () => { + ensureDirs(); + chmodSync(FAKE_CLI, 0o755); + const scratch = mkdtempSync(join(tmpdir(), "omb-cursor-acpid-")); + const dump = join(scratch, "dump.json"); + process.env.FAKE_ACP_DUMP = dump; + // What cursor-agent 2026.08.11 really advertises: `auto` is `default[]`. + process.env.FAKE_ACP_SESSION_MODELS = "default[]|Auto,gpt-5.3-codex[reasoning=medium,fast=false]|gpt-5.3-codex"; + + const instance = await CursorAgentDriver.create({ + instanceId: "cursor-acpid", + displayName: "Cursor", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + const recorder = recordEvents(instance.adapter); + try { + await instance.adapter.sendTurn({ threadId: "t-cursor-acpid", text: "hi", model: "auto" }); + await recorder.until((e) => e.type === "turn.completed"); + const applied = JSON.parse(readFileSync(`${dump}.config.json`, "utf8")); + // Before the fix this sent modelId "auto" and Cursor answered -32602. + expect(applied).toEqual([ + { method: "session/set_model", params: { sessionId: "fake-acp-session", modelId: "default[]" } }, + ]); + // argv keeps the CLI slug — the two namespaces stay separate. + expect(JSON.parse(readFileSync(dump, "utf8")).argv).toEqual(["--model", "auto", "acp"]); + } finally { + recorder.stop(); + await instance.dispose(); + delete process.env.FAKE_ACP_SESSION_MODELS; + // the dir goes with it: a stale FAKE_ACP_DUMP makes the *next* test's + // fake CLI die on ENOENT, which reads as an unrelated driver failure. + delete process.env.FAKE_ACP_DUMP; + await removeTempDir(scratch); + } + }); + + it("completes the turn when set_model answers -32602, because argv already pinned the model", async () => { + ensureDirs(); + chmodSync(FAKE_CLI, 0o755); + process.env.FAKE_ACP_MODE = "set-model-invalid-params"; + const instance = await CursorAgentDriver.create({ + instanceId: "cursor-invalid", + displayName: "Cursor", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + const recorder = recordEvents(instance.adapter); + try { + await instance.adapter.sendTurn({ threadId: "t-cursor-invalid", text: "hi", model: "gpt-5.3-codex" }); + const done = await recorder.until((e) => e.type === "turn.completed"); + // Previously this threw and failed a turn that would have run correctly. + expect(done).toMatchObject({ type: "turn.completed", ok: true }); + } finally { + recorder.stop(); + await instance.dispose(); + delete process.env.FAKE_ACP_MODE; + } + }); +}); diff --git a/server/drivers/acp/cursor.ts b/server/drivers/acp/cursor.ts index 599407c75..ca4a4336c 100644 --- a/server/drivers/acp/cursor.ts +++ b/server/drivers/acp/cursor.ts @@ -12,6 +12,50 @@ import type { ModelCatalog, ProviderErrorCode } from "../../contracts.ts"; import { execCli } from "../../procs.ts"; import { createAcpDriver, type AcpSupport } from "./core.ts"; +/** Translate an argv `--model` slug into the id this ACP session will accept. + * + * Cursor keeps two model namespaces and they do not match. `cursor-agent + * models` and the `--model` flag speak flat slugs (`auto`, `gpt-5.3-codex`). + * The ACP session advertises parameterised ids instead + * (`default[]`, `gpt-5.3-codex[reasoning=medium,fast=false]`), and + * `session/set_model` accepts *only* those. Sending the argv slug earns + * `-32602 Invalid params` for every model, not merely unknown ones — which + * read as "this account cannot use that model" and sent people to check their + * subscription over a pure id-format mismatch. + * + * Matching walks from most to least specific, and `auto` is special-cased + * because Cursor calls that entry `default[]` while naming it "Auto". + * + * Returns null when nothing matches, including when the agent advertised no + * models at all. The caller then falls back to sending the slug unchanged, + * which is what older CLIs that ignore the model list still expect. + */ +export function resolveCursorAcpModelId( + available: Array<{ modelId?: string; name?: string }>, + wanted: string, +): string | null { + const want = wanted.trim().toLowerCase(); + if (!want) return null; + const ids = available.filter((m) => typeof m?.modelId === "string" && m.modelId); + if (!ids.length) return null; + const base = (id: string) => id.split("[")[0].trim().toLowerCase(); + + const exact = ids.find((m) => m.modelId!.toLowerCase() === want); + if (exact) return exact.modelId!; + + const byBase = ids.find((m) => base(m.modelId!) === want); + if (byBase) return byBase.modelId!; + + const byName = ids.find((m) => (m.name ?? "").trim().toLowerCase() === want); + if (byName) return byName.modelId!; + + if (want === "auto" || want === "default") { + const dflt = ids.find((m) => base(m.modelId!) === "default"); + if (dflt) return dflt.modelId!; + } + return null; +} + export const STATIC_CURSOR_MODELS: ModelCatalog = { default: "auto", options: [ @@ -325,15 +369,21 @@ const support = (run: typeof execCli): AcpSupport => ({ isAuthenticated: (env, config) => probeCursorAuth(config.cli || "cursor-agent", env, run), classifyError: classifyCursorError, - async configureSession({ request, sessionId, turn }) { + async configureSession({ request, sessionId, turn, sessionModels }) { if (!turn.model) return; + // Prefer the id this session actually advertised; fall back to the argv + // slug so a CLI that advertises nothing behaves exactly as before. + const modelId = resolveCursorAcpModelId(sessionModels ?? [], turn.model) ?? turn.model; try { - await request("session/set_model", { sessionId, modelId: turn.model }); + await request("session/set_model", { sessionId, modelId }); } catch (e) { const err = e as Error & { code?: unknown }; - if (err.code === -32601) return; + // -32601 method missing, -32602 id not in this session's namespace. In + // both cases spawnArgs already pinned `--model`, so the turn runs the + // right model anyway; failing it here would refuse a working request. + if (err.code === -32601 || err.code === -32602) return; throw new Error( - `Cursor rejected model "${turn.model}" via session/set_model: ${err.message}. ` + + `Cursor rejected model "${turn.model}" (sent as "${modelId}") via session/set_model: ${err.message}. ` + `Check that \`cursor-agent\` is current and that this account can use that model.`, ); } diff --git a/server/drivers/acp/hermes.test.ts b/server/drivers/acp/hermes.test.ts index 5e63094aa..297a5e098 100644 --- a/server/drivers/acp/hermes.test.ts +++ b/server/drivers/acp/hermes.test.ts @@ -1,36 +1,108 @@ -import { describe, expect, it } from "vitest"; +import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; -import { - HERMES_OPENMAUS_SCREENSHOT_COMPAT, - HERMES_OPENMAUS_SCREENSHOT_COMPAT_MODEL, - bindHermesScreenshotCompat, -} from "./hermes.ts"; +import { removeTempDir } from "../../testing/cleanup.ts"; +import { HERMES_CONFIG_MODEL_ID, hermesAcpModelId, hermesConfiguredModel } from "./hermes.ts"; -describe("Hermes OpenMaus screenshot compatibility binding", () => { - it("binds the exact leaf model for an injected local picker model", () => { - const env = { - [HERMES_OPENMAUS_SCREENSHOT_COMPAT]: undefined, - [HERMES_OPENMAUS_SCREENSHOT_COMPAT_MODEL]: undefined, - }; +describe("hermesConfiguredModel", () => { + const dirs: string[] = []; + afterEach(async () => { + for (const d of dirs.splice(0)) await removeTempDir(d); + }); + + const home = (env: string, cfg?: string) => { + const root = mkdtempSync(join(tmpdir(), "omb-hermes-")); + dirs.push(root); + const h = join(root, ".hermes"); + mkdirSync(h, { recursive: true }); + writeFileSync(join(h, ".env"), env); + if (cfg !== undefined) writeFileSync(join(h, "config.yaml"), cfg); + return { HERMES_HOME: h }; + }; + + it("offers the configured model when a hosted key is set", () => { + const env = home("OPENROUTER_API_KEY=sk-or-v1-test\n", "model:\n default: anthropic/claude-opus-4.6\n"); + expect(hermesConfiguredModel(env)).toEqual({ + id: HERMES_CONFIG_MODEL_ID, + label: "anthropic/claude-opus-4.6 (Hermes config)", + // ModelPicker shows a custom-only agent ONLY its custom-flagged options. + custom: true, + }); + }); + + it("treats a commented-out key as not configured", () => { + // The shipped .env carries `# OPENROUTER_API_KEY=`; reading that as + // configured would offer a model that cannot authenticate. + const env = home("# OPENROUTER_API_KEY=\n", "model:\n default: anthropic/claude-opus-4.6\n"); + expect(hermesConfiguredModel(env)).toBeNull(); + }); + + it.each([ + "OPENROUTER_API_KEY=\n", + 'OPENROUTER_API_KEY=""\n', + "OPENROUTER_API_KEY='' # intentionally blank\n", + "OPENROUTER_API_KEY= # configured later\n", + ])("does not treat a blank key as configured: %j", (line) => { + expect(hermesConfiguredModel(home(line))).toBeNull(); + }); + + it("returns null when there is no .env at all, leaving local-only setups unchanged", () => { + const root = mkdtempSync(join(tmpdir(), "omb-hermes-bare-")); + dirs.push(root); + expect(hermesConfiguredModel({ HERMES_HOME: join(root, ".hermes") })).toBeNull(); + }); + + it("still offers the model when config.yaml is unreadable, with a generic label", () => { + const env = home("OPENROUTER_API_KEY=sk-or-v1-test\n"); + mkdirSync(join(env.HERMES_HOME, "config.yaml")); + expect(hermesConfiguredModel(env)).toEqual({ + id: HERMES_CONFIG_MODEL_ID, + label: "Hermes default (config)", + custom: true, + }); + }); - bindHermesScreenshotCompat(env, "omlx::gemma-4-31b-it-bf16"); + it("does not map to an ACP model id, so no session/set_model is sent for it", () => { + // This is what makes Hermes fall through to its own configured provider. + expect(hermesAcpModelId(HERMES_CONFIG_MODEL_ID)).toBeNull(); + }); +}); - expect(env[HERMES_OPENMAUS_SCREENSHOT_COMPAT]).toBe("1"); - expect(env[HERMES_OPENMAUS_SCREENSHOT_COMPAT_MODEL]).toBe("gemma-4-31b-it-bf16"); +describe("hermesAcpModelId", () => { + it("forwards Hermes' own provider-scoped ids untouched", () => { + // These are what `session/new` advertises. Returning null for them is what + // confined the picker to locally injected hosts. + expect(hermesAcpModelId("openrouter:qwen/qwen3.8-max")).toBe("openrouter:qwen/qwen3.8-max"); + expect(hermesAcpModelId("openrouter:deepseek/deepseek-v4-flash")).toBe( + "openrouter:deepseek/deepseek-v4-flash", + ); }); - it.each([undefined, "", "anthropic/claude-opus-4.6", "unknown::model"])( - "clears inherited compatibility for an unbound model %s", - (model) => { - const env = { - [HERMES_OPENMAUS_SCREENSHOT_COMPAT]: "1", - [HERMES_OPENMAUS_SCREENSHOT_COMPAT_MODEL]: "stale/model", - }; + it("still maps local inject ids to Hermes' custom:: form", () => { + expect(hermesAcpModelId("ollama::llama3")).toBe("custom:ollama:llama3"); + }); - bindHermesScreenshotCompat(env, model); + it("returns null for the config sentinel, so Hermes keeps its own default", () => { + expect(hermesAcpModelId(HERMES_CONFIG_MODEL_ID)).toBeNull(); + }); - expect(env[HERMES_OPENMAUS_SCREENSHOT_COMPAT]).toBeUndefined(); - expect(env[HERMES_OPENMAUS_SCREENSHOT_COMPAT_MODEL]).toBeUndefined(); - }, - ); + it("returns null for a bare word that names no provider", () => { + expect(hermesAcpModelId("gpt-5")).toBeNull(); + }); +}); + +describe("hermes fleet model translation", () => { + it("passes a guarded Hermes route alias to session/set_model", () => { + expect(hermesAcpModelId("litellm-local:minimax-m3-light")).toBe("litellm-local:minimax-m3-light"); + expect(hermesAcpModelId("litellm-local:MiniMax-M3")).toBe("litellm-local:MiniMax-M3"); + expect(hermesAcpModelId("minimax-m3-light")).toBeNull(); + }); + + it("keeps local host injection syntax and rejects malformed ids", () => { + expect(hermesAcpModelId("ollama::qwen3:14b")).toBe("custom:ollama:qwen3:14b"); + expect(hermesAcpModelId("bad model\nnext")).toBeNull(); + expect(hermesAcpModelId("litellm-local:qwen\n")).toBeNull(); + }); }); diff --git a/server/drivers/acp/hermes.ts b/server/drivers/acp/hermes.ts index 45537382c..d0a18d382 100644 --- a/server/drivers/acp/hermes.ts +++ b/server/drivers/acp/hermes.ts @@ -4,6 +4,7 @@ // without an OpenRouter key — that is the "HTTP 401: Missing Authentication // header" failure. Inject writes providers. and session/set_model // `custom::` instead. +import { spawn } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -13,6 +14,10 @@ import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../loca import { createAcpDriver, type AcpSupport } from "./core.ts"; const EMPTY: ModelCatalog = { default: "", options: [] }; +// Canonical fleet routes use Hermes' provider:model dialect. Keep ordinary +// provider slugs on the existing ACP default path; only a producer-owned +// route alias (or a guarded local inject id below) is sent to set_model. +const HERMES_FLEET_MODEL_ID = /^(?![\s\S]*[\r\n])[\w][\w./+-]*:[\w][\w./:+-]*$/; export const HERMES_OPENMAUS_SCREENSHOT_COMPAT = "HERMES_OPENMAUS_SCREENSHOT_COMPAT"; export const HERMES_OPENMAUS_SCREENSHOT_COMPAT_MODEL = "HERMES_OPENMAUS_SCREENSHOT_COMPAT_MODEL"; @@ -91,16 +96,221 @@ export function ensureHermesInjectProvider( return hermesAcpModelId(modelId) ?? modelId; } -/** ACP session/set_model id. Hermes parse_model_input treats `custom:name:model`. */ +/** ACP session/set_model id. Local inject rows become `custom:name:model`; + * fleet-catalog rows are already Hermes-native aliases and pass through. */ export function hermesAcpModelId(modelId: string | null | undefined): string | null { const inject = decodeInjectId(modelId); - if (!inject) return null; - return `custom:${inject.host}:${inject.model}`; + if (inject) return `custom:${inject.host}:${inject.model}`; + // Hermes' own ACP ids are `:` and fleet aliases use the + // same dialect. Reject whitespace and line breaks instead of trimming them, + // so malformed picker values can never become session/set_model input. + return typeof modelId === "string" && HERMES_FLEET_MODEL_ID.test(modelId) + ? modelId + : null; } -async function resolveModels(env: Record): Promise { +/** The id used when Hermes should run on the provider its own config names. + * + * Deliberately not an inject id: `hermesAcpModelId` returns null for it, so + * `configureSession` sends no `session/set_model` and Hermes falls through to + * the model in its own `config.yaml`. `spawnArgs` passes no `-m` either (ACP + * ignores it), so nothing overrides that choice. + */ +export const HERMES_CONFIG_MODEL_ID = "hermes-default"; + +function nonEmptyDotenvValue(text: string, name: string): string | null { + const match = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${name}[ \\t]*=[ \\t]*([^\\r\\n]*)$`, "m").exec(text); + if (!match) return null; + const raw = match[1].trim(); + if (!raw || raw.startsWith("#")) return null; + const quote = raw[0]; + if (quote === '"' || quote === "'") { + const closing = raw.indexOf(quote, 1); + if (closing < 0) return null; + const trailing = raw.slice(closing + 1).trim(); + if (trailing && !trailing.startsWith("#")) return null; + return raw.slice(1, closing).trim() || null; + } + return raw.replace(/[ \t]+#.*$/, "").trim() || null; +} + +/** Model Hermes' own config will use, when a remote provider is configured. + * + * Hermes is a BYOK harness and OpenMausBot only ever offered it *local* hosts + * (Ollama, LM Studio, EXO...). A user who has configured Hermes with a hosted + * provider — an OpenRouter key in `~/.hermes/.env`, which is how `hermes setup` + * stores it — had no selectable model at all: the picker showed "No local + * models found" and greyed the agent out, despite Hermes being installed, + * authenticated and perfectly able to answer. + * + * Read-only on purpose. `ensureHermesInjectProvider` writes `config.yaml`, and + * doing that from a catalog probe would rewrite the user's real Hermes config + * as a side effect of opening a menu. + * + * Returns null when no hosted key is configured, which leaves the catalog + * exactly as it was for local-only setups. + */ +export function hermesConfiguredModel( + env: Record = process.env, +): { id: string; label: string; custom: true } | null { + const dir = hermesHome(env); + let secrets = ""; + try { + secrets = readFileSync(join(dir, ".env"), "utf8"); + } catch { + return null; + } + // Only an uncommented, non-empty assignment counts; the shipped file has the + // key present but commented out, and that must not read as "configured". + if (!nonEmptyDotenvValue(secrets, "OPENROUTER_API_KEY")) return null; + + let model = ""; + try { + const cfg = readFileSync(join(dir, "config.yaml"), "utf8"); + const m = /^[ \t]*default[ \t]*:[ \t]*["']?([\w./:+-]+)["']?[ \t]*$/m.exec(cfg); + if (m) model = m[1]; + } catch { + /* config unreadable — the id still works, only the label is less specific */ + } + // `custom: true` is not cosmetic. ModelPicker renders a custom-only agent's + // *custom* pane exclusively, and that pane lists only options carrying this + // flag; anything without it lands in the "official" bucket the pane never + // shows. Omitting it puts the option in the API response while leaving the + // picker saying "No local models found" — present, but unselectable. + return { + id: HERMES_CONFIG_MODEL_ID, + label: model ? `${model} (Hermes config)` : "Hermes default (config)", + custom: true as const, + }; +} + +/** Ask a short-lived `hermes acp` session what models it can actually run. + * + * Hermes advertises its full catalog on `session/new` — every model its + * configured providers expose, ids shaped `openrouter:qwen/qwen3.8-max`. There + * is no `hermes models` subcommand, so a throwaway session is the only way to + * read it, and it is worth the spawn: without it the picker can only offer + * locally injected hosts, which is a fraction of what the user is paying for. + * + * Failure is non-fatal and returns [] — a catalog probe must never be the + * reason an agent becomes unselectable. + */ +async function fetchHermesAcpModels( + cli: string, + env: Record, +): Promise<{ id: string; label: string; custom: true }[]> { + return await new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn(cli, ["acp"], { stdio: ["pipe", "pipe", "ignore"], env: env as NodeJS.ProcessEnv }); + } catch { + return resolve([]); + } + let settled = false; + let timer: ReturnType | undefined; + let hardKillTimer: ReturnType | undefined; + const done = (out: { id: string; label: string; custom: true }[]) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + try { + if (child.kill()) { + hardKillTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* already gone */ + } + }, 1_000); + hardKillTimer.unref?.(); + } + } catch { + /* already gone */ + } + resolve(out); + }; + timer = setTimeout(() => done([]), 5_000); + child.once("error", () => done([])); + child.once("close", () => { + if (hardKillTimer) clearTimeout(hardKillTimer); + done([]); + }); + + let buf = ""; + let id = 0; + const send = (method: string, params: unknown) => { + id += 1; + try { + if (!child.stdin?.writable) { + done([]); + return 0; + } + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, (error) => { + if (error) done([]); + }); + } catch { + done([]); + return 0; + } + return id; + }; + let initId = 0; + let sessionId = 0; + child.stdout?.on("data", (chunk) => { + buf += String(chunk); + let nl: number; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + let msg: any; + try { + msg = JSON.parse(line); + } catch { + continue; + } + if (msg?.id === initId) { + if (!msg.result) return done([]); + sessionId = send("session/new", { cwd: env.HOME || env.USERPROFILE || homedir(), mcpServers: [] }); + } else if (sessionId && msg?.id === sessionId) { + const list = Array.isArray(msg.result?.models?.availableModels) + ? msg.result.models.availableModels + : []; + done( + list + .filter((m: any) => typeof m?.modelId === "string" && m.modelId) + .map((m: any) => ({ + id: m.modelId as string, + // Hermes labels these "OpenRouter · "; keep its wording. + label: (typeof m.name === "string" && m.name.trim()) || (m.modelId as string), + custom: true as const, + })), + ); + } + } + }); + initId = send("initialize", { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }, + }); + }); +} + +async function resolveModels( + env: Record, + config?: { cli?: string }, +): Promise { const catalog = await mergeLocalInject(EMPTY, env); - return { default: catalog.options[0]?.id ?? "", options: catalog.options }; + const configured = hermesConfiguredModel(env); + // Only probe when a hosted provider is configured; a local-only install has + // nothing to gain from the spawn. + const remote = configured ? await fetchHermesAcpModels(config?.cli || "hermes", env) : []; + const seen = new Set(); + const options = [...(configured ? [configured] : []), ...remote, ...catalog.options].filter((o) => { + if (seen.has(o.id)) return false; + seen.add(o.id); + return true; + }); + return { default: options[0]?.id ?? "", options }; } async function applySetting( @@ -121,7 +331,7 @@ const support: AcpSupport = { displayName: "Hermes", access: "custom", models: EMPTY, - resolveModels, + resolveModels: (env: Record, config: any) => resolveModels(env, config), resolveTurnModel: (model, env) => { // Never inherit a broad or stale compatibility grant from the parent. // Only this OpenMaus driver binds one concrete local model; Hermes still diff --git a/server/drivers/boxagent.test.ts b/server/drivers/boxagent.test.ts new file mode 100644 index 000000000..000465a8d --- /dev/null +++ b/server/drivers/boxagent.test.ts @@ -0,0 +1,204 @@ +// Box agent contract tests against a scripted fake of ascii.dev's box HTTP +// API. The driver polls events + prompt status; the fake advances one poll +// per GET so we can assert message → tool → message order without sleeping. +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ensureDirs } from "../config.ts"; +import type { ProviderInstance } from "../contracts.ts"; +import { recordEvents, type EventRecorder } from "../testing/events.ts"; +import { BoxAgentDriver } from "./boxagent.ts"; + +const BOX = "box-1"; +const PROMPT = "p1"; + +/** JSON Response helper for the in-process Box HTTP fake. */ +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +type Poll = { events: unknown[]; status?: { promptRun: { status: string; result?: string } } }; + +/** Stub fetch so each GET /events + /prompts pair advances one poll in `script`. */ +function installFakeBox(script: Poll[]) { + let i = 0; + const previous = globalThis.fetch; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + const method = String(init?.method ?? "GET").toUpperCase(); + if (url.endsWith("/me")) return json({ ok: true }); + if (method === "POST" && /\/boxes\/[^/]+\/prompt$/.test(url)) return json({ promptRun: { id: PROMPT } }); + if (method === "POST" && url.includes("/interrupt")) return json({ ok: true }); + if (url.includes("/events")) { + const step = script[Math.min(i, script.length - 1)]!; + i += 1; + return json({ events: step.events }); + } + if (url.includes(`/prompts/${PROMPT}`)) { + const step = script[Math.min(Math.max(i - 1, 0), script.length - 1)]!; + return json(step.status ?? { promptRun: { status: "running" } }); + } + return json({ error: `unexpected ${method} ${url}` }, 404); + }) as typeof fetch; + return () => { + globalThis.fetch = previous; + }; +} + +const computer = { boxId: BOX, token: "box-test-token" }; + +describe("BoxAgentDriver turns (fake API)", () => { + let instance: ProviderInstance; + let recorder: EventRecorder; + let restoreFetch: (() => void) | undefined; + + const create = async () => { + instance = await BoxAgentDriver.create({ + instanceId: "box-test", + displayName: "Box Test", + environment: { BOX_TOKEN: "box-test-token" }, + enabled: true, + config: { pollMs: 0 }, + }); + recorder = recordEvents(instance.adapter); + }; + + beforeEach(() => { + ensureDirs(); + }); + + afterEach(async () => { + recorder?.stop(); + await instance?.dispose(); + restoreFetch?.(); + restoreFetch = undefined; + }); + + it("flushes prefix-grown text before a tool, then the tail at settle", async () => { + restoreFetch = installFakeBox([ + { + events: [{ id: "e1", type: "response", text: "hel" }], + status: { promptRun: { status: "running" } }, + }, + { + events: [ + { id: "e1", type: "response", text: "hel" }, + { id: "e2", type: "tool", title: "run" }, + ], + status: { promptRun: { status: "running" } }, + }, + { + events: [ + { id: "e1", type: "response", text: "hel" }, + { id: "e2", type: "tool", title: "run" }, + { id: "e3", type: "response", text: "hello there" }, + ], + status: { promptRun: { status: "finished", result: "hello there" } }, + }, + ]); + await create(); + await instance.adapter.sendTurn({ threadId: "t-prefix", text: "go", integrations: { computer } }); + await recorder.until((e) => e.type === "turn.completed"); + + const texts = recorder.events + .filter((e) => e.type === "item.completed" && (e as { itemType: string }).itemType === "assistant_text") + .map((e) => (e as { text: string }).text); + expect(texts).toEqual(["hel", "lo there"]); + }); + + it("keeps a non-prefix response after a flush instead of slicing it away", async () => { + restoreFetch = installFakeBox([ + { + events: [{ id: "e1", type: "response", text: "before" }], + status: { promptRun: { status: "running" } }, + }, + { + events: [ + { id: "e1", type: "response", text: "before" }, + { id: "e2", type: "tool", title: "run" }, + ], + status: { promptRun: { status: "running" } }, + }, + { + events: [ + { id: "e1", type: "response", text: "before" }, + { id: "e2", type: "tool", title: "run" }, + { id: "e3", type: "response", text: "after" }, + ], + status: { promptRun: { status: "finished", result: "after" } }, + }, + ]); + await create(); + await instance.adapter.sendTurn({ threadId: "t-nonprefix", text: "go", integrations: { computer } }); + await recorder.until((e) => e.type === "turn.completed"); + + const types = recorder.events.map((e) => e.type); + expect(types).toEqual([ + "turn.started", + "session.started", + "content.delta", + "item.completed", // before + "item.started", + "content.delta", + "item.completed", // after — must not be sliced to "" + "turn.completed", + ]); + const texts = recorder.events + .filter((e) => e.type === "item.completed" && (e as { itemType: string }).itemType === "assistant_text") + .map((e) => (e as { text: string }).text); + expect(texts).toEqual(["before", "after"]); + }); + + it("ingests a non-prefix prompt result when events already set lastText", async () => { + restoreFetch = installFakeBox([ + { + events: [{ id: "e1", type: "response", text: "before" }], + status: { promptRun: { status: "running" } }, + }, + { + events: [ + { id: "e1", type: "response", text: "before" }, + { id: "e2", type: "tool", title: "run" }, + ], + status: { promptRun: { status: "running" } }, + }, + { + events: [ + { id: "e1", type: "response", text: "before" }, + { id: "e2", type: "tool", title: "run" }, + ], + status: { promptRun: { status: "finished", result: "done" } }, + }, + ]); + await create(); + await instance.adapter.sendTurn({ threadId: "t-status", text: "go", integrations: { computer } }); + await recorder.until((e) => e.type === "turn.completed"); + + const texts = recorder.events + .filter((e) => e.type === "item.completed" && (e as { itemType: string }).itemType === "assistant_text") + .map((e) => (e as { text: string }).text); + expect(texts).toEqual(["before", "done"]); + }); + + it("flushes pending assistant text when the turn is interrupted", async () => { + restoreFetch = installFakeBox([ + { + events: [{ id: "e1", type: "response", text: "half" }], + status: { promptRun: { status: "running" } }, + }, + ]); + await create(); + await instance.adapter.sendTurn({ threadId: "t-cancel", text: "go", integrations: { computer } }); + await recorder.until((e) => e.type === "content.delta"); + await instance.adapter.interruptTurn("t-cancel"); + const done = await recorder.until((e) => e.type === "turn.completed"); + expect(done).toMatchObject({ ok: false, stopReason: "interrupted" }); + const assistantIndex = recorder.events.findIndex( + (event) => event.type === "item.completed" && (event as { itemType: string }).itemType === "assistant_text", + ); + expect(assistantIndex).toBeLessThan(recorder.events.indexOf(done)); + const texts = recorder.events + .filter((e) => e.type === "item.completed" && (e as { itemType: string }).itemType === "assistant_text") + .map((e) => (e as { text: string }).text); + expect(texts).toEqual(["half"]); + }); +}); diff --git a/server/drivers/boxagent.ts b/server/drivers/boxagent.ts index 4dadc21ce..0b3969d78 100644 --- a/server/drivers/boxagent.ts +++ b/server/drivers/boxagent.ts @@ -129,6 +129,22 @@ export const BoxAgentDriver: ProviderDriver = { const seen = new Set(); const startedAt = Date.now(); let lastText = ""; + let pendingText = ""; + /** Emit unflushed deltas as assistant_text and reset pendingText. */ + const flushAssistantText = () => { + const text = pendingText; + pendingText = ""; + if (!text.trim()) return; + emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text }); + }; + /** Stream a full-text snapshot as a delta and accumulate it for flush. */ + const ingest = (text: string) => { + const delta = text.startsWith(lastText) ? text.slice(lastText.length) : text; + lastText = text; + if (!delta) return; + pendingText += delta; + emit({ ...base(threadId, turnId), type: "content.delta", streamKind: "assistant_text", delta }); + }; try { for (;;) { if (cancelled) break; @@ -148,12 +164,9 @@ export const BoxAgentDriver: ProviderDriver = { // stream anyway. const text = ev.text ?? ev.message ?? ev.data?.text ?? ev.data?.content ?? null; if (/assistant|message|output|response/i.test(kind) && typeof text === "string" && text.trim()) { - const delta = text.startsWith(lastText) ? text.slice(lastText.length) : text; - lastText = text; - if (delta) { - emit({ ...base(threadId, turnId), type: "content.delta", streamKind: "assistant_text", delta }); - } + ingest(text); } else if (/tool|command|exec|browse/i.test(kind)) { + flushAssistantText(); emit({ ...base(threadId, turnId), type: "item.started", @@ -167,9 +180,7 @@ export const BoxAgentDriver: ProviderDriver = { // events themselves instead of hanging to the 30-min ceiling if (!promptId && /complete|finish|done|success|fail|error/i.test(kind)) { active.delete(threadId); - if (lastText) { - emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text: lastText }); - } + flushAssistantText(); const failed = /fail|error/i.test(kind); emit({ ...base(threadId, turnId), type: "turn.completed", ok: !failed, stopReason: failed ? kind : null, cost: null }); return; @@ -184,30 +195,17 @@ export const BoxAgentDriver: ProviderDriver = { const state = String(run?.status ?? ""); if (/completed|succeeded|done|finished/i.test(state)) { const result = run?.result ?? run?.output ?? lastText; - // stream only the growth past what events already sent — - // the settled message below carries the full text regardless - if (typeof result === "string" && result.trim() && result !== lastText && result.startsWith(lastText)) { - emit({ - ...base(threadId, turnId), - type: "content.delta", - streamKind: "assistant_text", - delta: result.slice(lastText.length), - }); + if (typeof result === "string" && result.trim() && result !== lastText) { + ingest(result); } - emit({ - ...base(threadId, turnId), - type: "item.completed", - itemType: "assistant_text", - text: typeof result === "string" && result.trim() ? result : lastText || "(finished)", - }); + if (!pendingText.trim() && !lastText.trim()) pendingText = "(finished)"; + flushAssistantText(); active.delete(threadId); emit({ ...base(threadId, turnId), type: "turn.completed", ok: true, stopReason: null, cost: null }); return; } if (/failed|error|cancelled|interrupted/i.test(state)) { - if (lastText) { - emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text: lastText }); - } + flushAssistantText(); active.delete(threadId); emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: state, cost: null }); return; @@ -218,9 +216,11 @@ export const BoxAgentDriver: ProviderDriver = { } } // cancelled + flushAssistantText(); active.delete(threadId); emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "interrupted", cost: null }); } catch (e) { + flushAssistantText(); active.delete(threadId); emit({ ...base(threadId, turnId), type: "runtime.error", message: (e as Error).message }); emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "error", cost: null }); diff --git a/server/drivers/builtIn.ts b/server/drivers/builtIn.ts index 928bfee60..3e8b204b2 100644 --- a/server/drivers/builtIn.ts +++ b/server/drivers/builtIn.ts @@ -6,6 +6,7 @@ import { BoxAgentDriver } from "./boxagent.ts"; import { ClaudeDriver } from "./claude.ts"; import { CodexDriver } from "./codex.ts"; import { GrokDriver } from "./grok.ts"; +import { LocalDriver } from "./local.ts"; import { GrokAgentDriver } from "./acp/grok.ts"; import { GeminiAgentDriver } from "./acp/gemini.ts"; import { KimiAgentDriver } from "./acp/kimi.ts"; @@ -14,6 +15,7 @@ import { CursorAgentDriver } from "./acp/cursor.ts"; import { OpenCodeGoDriver } from "./acp/opencode-go.ts"; import { QwenAgentDriver } from "./acp/qwen.ts"; import { HermesAgentDriver } from "./acp/hermes.ts"; +import { OpenAICompatDriver } from "./openai-compat.ts"; import { PiDriver } from "./pi.ts"; export const BUILT_IN_DRIVERS: readonly AnyProviderDriver[] = [ @@ -27,8 +29,10 @@ export const BUILT_IN_DRIVERS: readonly AnyProviderDriver[] = [ QwenAgentDriver, HermesAgentDriver, PiDriver, + OpenAICompatDriver, ClaudeDriver, CodexDriver, AntigravityDriver, BoxAgentDriver, + LocalDriver, ]; diff --git a/server/drivers/codex.test.ts b/server/drivers/codex.test.ts index b4407827b..3a7ad8d04 100644 --- a/server/drivers/codex.test.ts +++ b/server/drivers/codex.test.ts @@ -349,6 +349,24 @@ describe("CodexDriver turns (fake app-server)", () => { expect(JSON.parse(readFileSync(dump, "utf8")).decision).toEqual({ decision: "approved" }); }); + it("answers Codex 0.149 MCP elicitation with the MCP result shape", async () => { + await create({ mode: "mcp-elicitation" }); + const dump = join(scratch, "mcp-elicitation.json"); + process.env.FAKE_CODEX_DUMP = dump; + + await instance.adapter.sendTurn({ threadId: "t-mcp-elicitation", text: "list bots" }); + const opened = await recorder.until((e) => e.type === "request.opened"); + expect(opened).toMatchObject({ + requestType: "permission", + tool: "list_bots", + summary: 'Allow the agents MCP server to run tool "list_bots"?', + }); + + await instance.adapter.respondToRequest("t-mcp-elicitation", opened.requestId!, { behavior: "allow" }); + await recorder.until((e) => e.type === "turn.completed"); + expect(JSON.parse(readFileSync(dump, "utf8")).decision).toEqual({ action: "accept", content: {} }); + }); + it("stamps approvalScope on cards only when the turn controls this Mac", async () => { await create({ mode: "approval" }); diff --git a/server/drivers/codex.ts b/server/drivers/codex.ts index 7be92282a..464c9338f 100644 --- a/server/drivers/codex.ts +++ b/server/drivers/codex.ts @@ -249,19 +249,35 @@ export const CodexDriver: ProviderDriver = { const method = msg.method as string; const params = msg.params ?? {}; const legacy = method === "execCommandApproval" || method === "applyPatchApproval"; + const isMcpElicitation = + method === "mcpServer/elicitation/request" && + params?._meta?.codex_approval_kind === "mcp_tool_call"; const isQuestion = method === "item/tool/requestUserInput"; + const mcpTool = isMcpElicitation + ? String(params.message ?? "").match(/tool \"([^\"]+)\"/)?.[1] + : undefined; const tool = - method === "item/fileChange/requestApproval" || method === "applyPatchApproval" + isMcpElicitation + ? (mcpTool ?? "mcp") + : method === "item/fileChange/requestApproval" || method === "applyPatchApproval" ? "edit" : isQuestion ? "ask_user" : "shell"; if (config.fullAuto && !isQuestion) { - return send({ jsonrpc: "2.0", id: msg.id, result: { decision: legacy ? "approved" : "accept" } }); + return send({ + jsonrpc: "2.0", + id: msg.id, + result: isMcpElicitation + ? { action: "accept", content: {} } + : { decision: legacy ? "approved" : "accept" }, + }); } const requestId = newId(); const summary = - typeof params.command === "string" + isMcpElicitation && typeof params.message === "string" + ? params.message + : typeof params.command === "string" ? params.command : Array.isArray(params.questions) ? params.questions.map((q: any) => q.question ?? q.header).filter(Boolean).join(" · ") @@ -284,7 +300,11 @@ export const CodexDriver: ProviderDriver = { send({ jsonrpc: "2.0", id: msg.id, - result: { decision: behavior === "allow" ? (legacy ? "approved" : "accept") : legacy ? "denied" : "decline" }, + result: isMcpElicitation + ? behavior === "allow" + ? { action: "accept", content: {} } + : { action: "decline" } + : { decision: behavior === "allow" ? (legacy ? "approved" : "accept") : legacy ? "denied" : "decline" }, }); } emit({ ...base(threadId, turnId), type: "request.resolved", requestId, behavior, source }); diff --git a/server/drivers/local.test.ts b/server/drivers/local.test.ts new file mode 100644 index 000000000..7db9dc996 --- /dev/null +++ b/server/drivers/local.test.ts @@ -0,0 +1,115 @@ +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import type { ProviderInstance } from "../contracts.ts"; +import { parseJson, type JsonValue } from "../schema.ts"; +import { recordEvents, type EventRecorder } from "../testing/events.ts"; +import { decodeFleetLocalSelector, LocalDriver } from "./local.ts"; + +let server: Server | null = null; +let instance: ProviderInstance | null = null; +let recorder: EventRecorder | null = null; +const requests: Array<{ url: string; body: JsonValue | null }> = []; +const chatRequestSchema = z.object({ model: z.string() }).passthrough(); + +async function fakeHost(finalFrameWithoutNewline = false): Promise { + server = createServer((request, response) => { + let raw = ""; + request.on("data", (chunk) => raw += chunk); + request.on("end", () => { + const body = raw ? parseJson(raw) : null; + requests.push({ url: request.url ?? "", body }); + const json = (payload: JsonValue) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(payload)); + }; + if (request.url === "/v1/models") return json({ data: [{ id: "qwen3.8:27b-mlx" }] }); + if (request.url === "/api/ps") return json({ + models: [{ name: "qwen3.8:27b-mlx", context_length: 65_536 }], + }); + if (request.url === "/v1/chat/completions") { + response.writeHead(200, { "content-type": "text/event-stream" }); + if (finalFrameWithoutNewline) { + response.end(`data: ${JSON.stringify({ choices: [{ delta: { content: "tail" } }] })}`); + return; + } + response.write(`data: ${JSON.stringify({ choices: [{ delta: { content: "hello" } }] })}\n\n`); + response.end("data: [DONE]\n\n"); + return; + } + response.writeHead(404).end(); + }); + }); + const running = server; + return new Promise((resolve) => running.listen(0, "127.0.0.1", () => { + // SAFETY: a TCP server listening on an ephemeral IPv4 port returns an AddressInfo object. + const address = running.address() as { port: number }; + resolve(`http://127.0.0.1:${address.port}/v1`); + })); +} + +afterEach(async () => { + recorder?.stop(); + recorder = null; + await instance?.dispose(); + instance = null; + const running = server; + await new Promise((resolve) => { + if (!running) return resolve(); + running.closeIdleConnections(); + running.close(() => resolve()); + }); + server = null; + requests.length = 0; +}); + +describe("fleet local selectors", () => { + it("keeps Mac and Windows namespaces disjoint", () => { + expect(decodeFleetLocalSelector("ollama-mac/qwen3.8:27b-mlx", "mac")).toBe("qwen3.8:27b-mlx"); + expect(decodeFleetLocalSelector("ollama-windows/qwen3.8:27b-mlx", "mac")).toBeNull(); + expect(decodeFleetLocalSelector("bad model", "mac")).toBeNull(); + }); + + it("runs the canonical Mac selector as the host-native model", async () => { + instance = await LocalDriver.create({ + instanceId: "localMac", + displayName: "Mac M5 models", + environment: {}, + enabled: true, + config: { host: "custom", url: await fakeHost(), fleetHost: "mac" }, + }); + recorder = recordEvents(instance.adapter); + // The transport checks only readiness; the guarded fleet projection owns + // every picker row and its chat/non-chat classification. + expect(instance.models.options).toEqual([]); + expect(await instance.snapshot()).toMatchObject({ state: "available" }); + await instance.adapter.sendTurn({ + threadId: "local-turn", + text: "hi", + model: "ollama-mac/qwen3.8:27b-mlx", + }); + await recorder.until((event) => event.type === "turn.completed"); + const chatRequest = requests.find((request) => request.url === "/v1/chat/completions"); + expect(chatRequestSchema.parse(chatRequest?.body).model).toBe("qwen3.8:27b-mlx"); + expect(recorder.events).toContainEqual(expect.objectContaining({ type: "item.completed", text: "hello" })); + }); + + it("processes a final SSE frame without a trailing newline", async () => { + instance = await LocalDriver.create({ + instanceId: "localMac", + displayName: "Mac M5 models", + environment: {}, + enabled: true, + config: { host: "custom", url: await fakeHost(true), fleetHost: "mac" }, + }); + recorder = recordEvents(instance.adapter); + await instance.adapter.sendTurn({ + threadId: "local-tail-turn", + text: "hi", + model: "ollama-mac/qwen3.8:27b-mlx", + }); + await recorder.until((event) => event.type === "turn.completed"); + expect(recorder.events).toContainEqual(expect.objectContaining({ type: "item.completed", text: "tail" })); + }); +}); diff --git a/server/drivers/local.ts b/server/drivers/local.ts new file mode 100644 index 000000000..e24606dab --- /dev/null +++ b/server/drivers/local.ts @@ -0,0 +1,349 @@ +// Direct local OpenAI-compatible driver. The model catalog is projected by +// the guarded fleet registry; this transport only talks to the one configured +// host after the user selects a row. It never scans other providers. +import type { + DriverCreateInput, + ModelCatalog, + ProviderDriver, + ProviderInstance, + ProviderSnapshot, + RuntimeEvent, + RuntimeEventListener, + SendTurnInput, +} from "../contracts.ts"; +import { newEventId, newId } from "../contracts.ts"; +import { z } from "zod"; +import { hostApiKey, LOCAL_HOSTS, type LocalHost } from "./local-inject.ts"; +import { appendNative } from "./native.ts"; + +const DRIVER_KIND = "local"; +// A configured local endpoint should answer on LAN/loopback promptly. Keep +// startup and explicit refresh bounded even when the host is asleep; catalog +// admission remains the authoritative longer-running health signal. +const PROBE_MS = 750; +const TURN_MS = 10 * 60_000; +const MODEL_ID = /^(?![\s\S]*[\r\n])[\w][\w./:+-]*$/; + +export interface LocalConfig { + host: string; + url?: string; + /** Which canonical direct-local selector this instance owns. */ + fleetHost?: "mac" | "windows"; +} + +interface LocalProbe { + ok: boolean; + reason?: string; +} + +const localConfigSchema = z.object({ + host: z.string().min(1).default("ollama").refine( + (value) => value === "custom" || LOCAL_HOSTS.some((host) => host.id === value), + "unknown local host", + ), + url: z.url().refine((value) => value.startsWith("http://") || value.startsWith("https://"), { + message: "local server url must be http(s)", + }).optional(), + fleetHost: z.enum(["mac", "windows"]).optional(), +}); +const streamChunkSchema = z.looseObject({ + choices: z.array(z.looseObject({ + delta: z.looseObject({ content: z.string().optional() }), + })).optional(), + usage: z.object({ + prompt_tokens: z.number().optional(), + completion_tokens: z.number().optional(), + }).nullable().optional(), +}); + +const CUSTOM: LocalHost = { + id: "custom", + label: "Local server", + baseUrl: "http://127.0.0.1:8000/v1", + apiKey: "local", +}; + +function hostFor(config: LocalConfig): LocalHost { + const known = LOCAL_HOSTS.find((host) => host.id === config.host); + const base = known ?? CUSTOM; + return config.url ? { ...base, baseUrl: config.url.replace(/\/$/, "") } : base; +} + +// oxlint-disable-next-line anti-slop/no-unknown-parameters -- ProviderDriver's opaque boundary is parsed immediately by the locked Zod schema. +function decodeConfig(raw: unknown): LocalConfig { + const parsed = localConfigSchema.parse(raw ?? {}); + if (parsed.host === "custom" && !parsed.url) throw new Error("a custom local server needs a url"); + return parsed; +} + +/** `translations.openmausbot` is stable across machines. The API host wants + * only its native model id, and an instance must refuse the other machine's + * selector rather than silently running a same-named model locally. */ +export function decodeFleetLocalSelector(model: string, fleetHost?: "mac" | "windows"): string | null { + const match = /^ollama-(mac|windows)\/(.+)$/.exec(model); + if (!match) return MODEL_ID.test(model) ? model : null; + if (!fleetHost || match[1] !== fleetHost || !MODEL_ID.test(match[2]!)) return null; + return match[2]!; +} + +const EMPTY: ModelCatalog = { default: "", options: [] }; + +export const LocalDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { displayName: "Local models", supportsMultipleInstances: true, access: "custom" }, + models: EMPTY, + install: { + command: { + darwin: "brew install ollama", + linux: "curl -fsSL https://ollama.com/install.sh | sh", + }, + docsUrl: "https://ollama.com/download", + signInCommand: "ollama serve", + }, + decodeConfig, + defaultConfig: () => decodeConfig({ host: "ollama", fleetHost: "mac" }), + + async create(input: DriverCreateInput): Promise { + const host = hostFor(input.config); + const environment = { ...process.env, ...input.environment }; + const headers = { + authorization: `Bearer ${hostApiKey(host, environment)}`, + "content-type": "application/json", + }; + const listeners = new Set(); + const active = new Map(); + let models: ModelCatalog = EMPTY; + let lastProbe: LocalProbe = { ok: false, reason: "not probed yet" }; + + const emit = (event: RuntimeEvent) => { + for (const listener of listeners) listener(event); + }; + const base = (threadId: string, turnId: string) => ({ + eventId: newEventId(), + provider: DRIVER_KIND, + threadId, + turnId, + createdAt: new Date().toISOString(), + }); + const probe = async (url: string): Promise => { + const response = await fetch(url, { headers, signal: AbortSignal.timeout(PROBE_MS) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + }; + + const refreshModels = async () => { + try { + // Transport readiness only. Inventory and capability classification + // come exclusively from the guarded fleet projection; copying a raw + // /models response here would reintroduce unclassified or non-chat + // rows as selectable UI options. + await probe(`${host.baseUrl}/models`); + models = EMPTY; + lastProbe = { ok: true }; + } catch (error) { + models = EMPTY; + const detail = error instanceof Error ? error.message : String(error); + lastProbe = { + ok: false, + reason: /ECONNREFUSED|fetch failed|timeout|Timeout/i.test(detail) + ? `${host.label} is not running at ${host.baseUrl}` + : `${host.label}: ${detail}`, + }; + } + }; + await refreshModels(); + + const complete = async ( + messages: Array<{ role: string; content: string }>, + model: string, + signal: AbortSignal, + onDelta: (delta: string) => void, + ): Promise<{ text: string; usage: { input: number; output: number } | null }> => { + const response = await fetch(`${host.baseUrl}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model, + messages, + stream: true, + stream_options: { include_usage: true }, + }), + signal: AbortSignal.any([signal, AbortSignal.timeout(TURN_MS)]), + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`${host.label} HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`); + } + let text = ""; + let usage: { input: number; output: number } | null = null; + const reader = response.body?.getReader(); + if (!reader) throw new Error(`${host.label} returned no response body`); + const decoder = new TextDecoder(); + let buffer = ""; + const consumeLine = (rawLine: string) => { + const line = rawLine.trim(); + if (!line.startsWith("data:")) return; + const data = line.slice(5).trim(); + if (!data || data === "[DONE]") return; + let decoded: unknown; + try { + decoded = JSON.parse(data); + } catch { + return; + } + const parsed = streamChunkSchema.safeParse(decoded); + if (!parsed.success) return; + const chunk = parsed.data; + const delta = chunk.choices?.[0]?.delta?.content; + if (delta) { + text += delta; + onDelta(delta); + } + if (chunk.usage) usage = { + input: chunk.usage.prompt_tokens ?? 0, + output: chunk.usage.completion_tokens ?? 0, + }; + }; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + consumeLine(line); + } + } + buffer += decoder.decode(); + if (buffer) consumeLine(buffer); + return { text, usage }; + }; + + const sendTurn = async (turn: SendTurnInput) => { + if (active.has(turn.threadId)) throw new Error("a turn is already running on this thread"); + const selected = turn.model || models.default; + const model = selected ? decodeFleetLocalSelector(selected, input.config.fleetHost) : null; + if (!model) { + throw new Error(selected + ? `model selector "${selected}" does not belong to this ${input.config.fleetHost ?? "local"} host` + : `no model to run — ${lastProbe.reason ?? "refresh the fleet catalog"}`); + } + const turnId = newId(); + const abort = new AbortController(); + active.set(turn.threadId, { abort, turnId }); + const messages = [ + ...(turn.system ? [{ role: "system", content: turn.system }] : []), + ...(turn.transcript ?? []).map((message) => ({ role: message.role, content: message.text })), + { role: "user", content: turn.text }, + ]; + appendNative(turn.threadId, { + dir: "out", + source: "local.chat.completions", + msg: { host: input.config.fleetHost ?? host.id, model, messages }, + }); + emit({ ...base(turn.threadId, turnId), type: "turn.started" }); + emit({ ...base(turn.threadId, turnId), type: "session.started", sessionId: null, model }); + void (async () => { + try { + const result = await complete( + messages, + model, + abort.signal, + (delta) => emit({ + ...base(turn.threadId, turnId), + type: "content.delta", + streamKind: "assistant_text", + delta, + }), + ); + appendNative(turn.threadId, { dir: "in", source: "local.chat.completions", msg: result }); + if (result.text.trim()) emit({ + ...base(turn.threadId, turnId), + type: "item.completed", + itemType: "assistant_text", + text: result.text, + }); + if (result.usage) emit({ ...base(turn.threadId, turnId), type: "thread.token-usage.updated", ...result.usage }); + active.delete(turn.threadId); + if (result.usage) { + emit({ + ...base(turn.threadId, turnId), + type: "turn.completed", + ok: true, + stopReason: null, + cost: null, + usage: result.usage, + }); + } else { + emit({ + ...base(turn.threadId, turnId), + type: "turn.completed", + ok: true, + stopReason: null, + cost: null, + }); + } + } catch (error) { + active.delete(turn.threadId); + const aborted = error instanceof Error && error.name === "AbortError"; + const message = error instanceof Error ? error.message : String(error); + if (!aborted) emit({ ...base(turn.threadId, turnId), type: "runtime.error", message }); + emit({ + ...base(turn.threadId, turnId), + type: "turn.completed", + ok: false, + stopReason: aborted ? "interrupted" : "error", + cost: null, + }); + } + })(); + return { turnId }; + }; + + const snapshot = async (): Promise => { + // ProviderRegistry owns refresh policy. Re-probing here would make a + // cached describe() perform network I/O anyway, and a live describe() + // would probe this host twice. + return lastProbe.ok + ? { state: "available", authenticated: true, version: null } + : { state: "unavailable", reason: lastProbe.reason }; + }; + + return { + instanceId: input.instanceId, + driverKind: DRIVER_KIND, + displayName: input.displayName ?? `${input.config.fleetHost === "windows" ? "Windows" : "Mac"} ${host.label}`, + enabled: input.enabled, + get models() { return models; }, + refreshModels, + snapshot, + adapter: { + provider: DRIVER_KIND, + capabilities: { + sessionModelSwitch: "in-session", + computerMcp: false, + agentsMcp: false, + composioMcp: false, + queueing: false, + }, + sendTurn, + interruptTurn: async (threadId) => active.get(threadId)?.abort.abort(), + respondToRequest: async (): Promise<"unavailable"> => "unavailable", + hasSession: (threadId) => active.has(threadId), + stopAll: async () => { + for (const entry of active.values()) entry.abort.abort(); + }, + onEvent: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + dispose: async () => { + for (const entry of active.values()) entry.abort.abort(); + active.clear(); + listeners.clear(); + }, + }; + }, +}; diff --git a/server/drivers/openai-compat.test.ts b/server/drivers/openai-compat.test.ts new file mode 100644 index 000000000..e74768d37 --- /dev/null +++ b/server/drivers/openai-compat.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { recordEvents } from "../testing/events.ts"; +import { OpenAICompatDriver } from "./openai-compat.ts"; + +describe("OpenAICompatDriver", () => { + const savedUrl = process.env.OPENAI_COMPAT_URL; + const savedKey = process.env.OPENAI_COMPAT_API_KEY; + + beforeEach(() => { + delete process.env.OPENAI_COMPAT_URL; + delete process.env.OPENAI_COMPAT_API_KEY; + }); + + afterEach(() => { + if (savedUrl === undefined) delete process.env.OPENAI_COMPAT_URL; + else process.env.OPENAI_COMPAT_URL = savedUrl; + if (savedKey === undefined) delete process.env.OPENAI_COMPAT_API_KEY; + else process.env.OPENAI_COMPAT_API_KEY = savedKey; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("registers with the openai-compat kind and a display name", () => { + expect(OpenAICompatDriver.driverKind).toBe("openai-compat"); + expect(OpenAICompatDriver.metadata.displayName).toMatch(/OpenRouter|Groq/); + }); + + it("falls back to the OpenRouter endpoint by default", () => { + const cfg = OpenAICompatDriver.defaultConfig(); + expect(cfg.url).toBe("https://openrouter.ai/api/v1"); + expect(cfg.apiKeyEnv).toBe("OPENAI_COMPAT_API_KEY"); + }); + + it("honours an explicit url and apiKeyEnv override", () => { + const cfg = OpenAICompatDriver.decodeConfig({ + url: "https://api.groq.com/openai/v1/", + apiKeyEnv: "GROQ_KEY", + }); + expect(cfg.url).toBe("https://api.groq.com/openai/v1"); + expect(cfg.apiKeyEnv).toBe("GROQ_KEY"); + }); + + it("reports unavailable without an API key", async () => { + const inst = await OpenAICompatDriver.create({ + instanceId: "test-1", + displayName: "Free", + enabled: true, + config: { url: "https://openrouter.ai/api/v1", apiKeyEnv: "OPENAI_COMPAT_API_KEY" }, + environment: {}, + }); + const snap = await inst.snapshot(); + expect(snap.state).toBe("unavailable"); + await inst.dispose(); + }); + + it("exposes a refreshed model catalog", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + data: [ + { id: "vendor/model-a", name: "Model A" }, + { id: "vendor/model-b" }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ); + const inst = await OpenAICompatDriver.create({ + instanceId: "test-models", + displayName: "Models", + enabled: true, + config: { url: "https://example.test/v1", apiKeyEnv: "TEST_KEY" }, + environment: { TEST_KEY: "secret" }, + }); + + await inst.refreshModels?.(); + + expect(inst.models).toEqual({ + default: "vendor/model-a", + options: [ + { id: "vendor/model-a", label: "Model A" }, + { id: "vendor/model-b", label: "vendor/model-b" }, + ], + }); + await inst.dispose(); + }); + + it("includes streamed token totals in turn.completed", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/models")) return new Response(JSON.stringify({ data: [] }), { status: 200 }); + return new Response( + 'data: {"choices":[{"delta":{"content":"hello"}}]}\n' + + 'data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3}}\n' + + "data: [DONE]\n", + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }), + ); + const inst = await OpenAICompatDriver.create({ + instanceId: "test-turn", + displayName: "Turn", + enabled: true, + config: { url: "https://example.test/v1", apiKeyEnv: "TEST_KEY" }, + environment: { TEST_KEY: "secret" }, + }); + const recorder = recordEvents(inst.adapter); + + await inst.adapter.sendTurn({ threadId: "thread", text: "private prompt", model: "vendor/model" }); + const completed = await recorder.until((event) => event.type === "turn.completed"); + + expect(completed).toMatchObject({ ok: true, usage: { input: 12, output: 3 } }); + recorder.stop(); + await inst.dispose(); + }); +}); diff --git a/server/drivers/openai-compat.ts b/server/drivers/openai-compat.ts new file mode 100644 index 000000000..7aa101f98 --- /dev/null +++ b/server/drivers/openai-compat.ts @@ -0,0 +1,361 @@ +// OpenAI-compatible driver — any endpoint that speaks the OpenAI +// chat-completions shape (OpenRouter, Groq, Together, a local llama.cpp, +// …). This is the "free models" entry point: point it at OpenRouter's +// free tier or Groq's open-model endpoints and a bot runs without a +// paid Claude/Codex/Grok subscription. +// +// Transcript-replay like grok.ts: the harness folds thread history and +// hands it back each turn (SendTurnInput.transcript); we emit true +// token-level content.delta events and supply generateText. +import type { + DriverCreateInput, + ModelCatalog, + ProviderDriver, + ProviderInstance, + ProviderSnapshot, + RuntimeEvent, + RuntimeEventListener, + SendTurnInput, +} from "../contracts.ts"; +import { newEventId, newId } from "../contracts.ts"; +import { appendNative } from "./native.ts"; + +const DRIVER_KIND = "openai-compat"; + +// Default catalog — overwritten by /models when the endpoint answers. +// Free-tier-friendly defaults so the picker is never empty. +const DEFAULT_MODELS: ModelCatalog = { + default: "meta-llama/llama-3.3-70b-instruct", + options: [ + { id: "meta-llama/llama-3.3-70b-instruct", label: "Llama 3.3 70B (OpenRouter)" }, + { id: "llama-3.3-70b-versatile", label: "Llama 3.3 70B (Groq)" }, + ], +}; + +export interface OpenAICompatConfig { + /** Base URL, no trailing /v1 assumed — we append /chat/completions. */ + url: string; + /** Env var (instance environment or process.env) carrying the API key. */ + apiKeyEnv: string; +} + +function decodeConfig(raw: unknown): OpenAICompatConfig { + const o = (raw ?? {}) as Record; + const envUrl = process.env.OPENAI_COMPAT_URL; + return { + url: + typeof o.url === "string" && o.url + ? o.url.replace(/\/+$/, "") + : envUrl + ? envUrl.replace(/\/+$/, "") + : "https://openrouter.ai/api/v1", + apiKeyEnv: typeof o.apiKeyEnv === "string" && o.apiKeyEnv ? o.apiKeyEnv : "OPENAI_COMPAT_API_KEY", + }; +} + +export const OpenAICompatDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OpenAI-compatible (OpenRouter / Groq)", + supportsMultipleInstances: true, + access: "custom", + }, + models: DEFAULT_MODELS, + // No CLI to install — the "install" is getting a free API key. + install: { + docsUrl: "https://openrouter.ai/keys", + signInCommand: + "add {\"openaiCompat\":{\"key\":\"sk-or-v1-…\"}} to ~/.openmausbot/config.json (or set OPENAI_COMPAT_API_KEY)", + command: { + darwin: + "Get a free key at https://openrouter.ai/keys (or https://console.groq.com) then add it to ~/.openmausbot/config.json under openaiCompat.key", + linux: + "Get a free key at https://openrouter.ai/keys (or https://console.groq.com) then add it to ~/.openmausbot/config.json under openaiCompat.key", + win32: + "Get a free key at https://openrouter.ai/keys (or https://console.groq.com) then add it to %USERPROFILE%\\.openmausbot\\config.json under openaiCompat.key", + }, + }, + decodeConfig, + defaultConfig: () => decodeConfig({}), + + async create(input: DriverCreateInput): Promise { + const { instanceId, config } = input; + const apiKey = + input.environment[config.apiKeyEnv] ?? process.env[config.apiKeyEnv] ?? ""; + const listeners = new Set(); + const active = new Map(); + let catalog = DEFAULT_MODELS; + + const emit = (event: RuntimeEvent) => { + for (const l of [...listeners]) l(event); + }; + const base = (threadId: string, turnId: string) => ({ + eventId: newEventId(), + provider: DRIVER_KIND, + threadId, + turnId, + createdAt: new Date().toISOString(), + }); + + const complete = async ( + messages: Array<{ role: string; content: string }>, + model: string, + opts: { stream: boolean; signal?: AbortSignal; onDelta?: (d: string) => void }, + ): Promise<{ text: string; usage: { input: number; output: number } | null }> => { + const res = await fetch(`${config.url}/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ model, messages, stream: opts.stream }), + signal: opts.signal ?? AbortSignal.timeout(120_000), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `upstream HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ""}`, + ); + } + if (!opts.stream) { + const json: any = await res.json(); + return { + text: json.choices?.[0]?.message?.content ?? "", + usage: json.usage + ? { + input: json.usage.prompt_tokens ?? 0, + output: json.usage.completion_tokens ?? 0, + } + : null, + }; + } + let text = ""; + let usage: { input: number; output: number } | null = null; + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl; + while ((nl = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data === "[DONE]") continue; + let chunk: any; + try { + chunk = JSON.parse(data); + } catch { + continue; + } + const delta = chunk.choices?.[0]?.delta?.content; + if (delta) { + text += delta; + opts.onDelta?.(delta); + } + if (chunk.usage) { + usage = { + input: chunk.usage.prompt_tokens ?? 0, + output: chunk.usage.completion_tokens ?? 0, + }; + } + } + } + return { text, usage }; + }; + + const fetchModels = async (): Promise => { + if (!apiKey) return; + try { + const res = await fetch(`${config.url}/models`, { + headers: { authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(8_000), + }); + if (!res.ok) return; + const json: any = await res.json(); + const rows: Array<{ id?: unknown; name?: unknown }> = Array.isArray(json) + ? json + : Array.isArray(json?.data) + ? json.data + : []; + const seen = new Set(); + const options: ModelCatalog["options"] = []; + for (const row of rows) { + const id = typeof row.id === "string" ? row.id : ""; + if (!id || seen.has(id)) continue; + seen.add(id); + const label = + typeof row.name === "string" && row.name.trim() + ? row.name + : id; + options.push({ id, label }); + } + if (options.length) { + catalog = { default: options[0].id, options }; + } + } catch { + // keep DEFAULT_MODELS — never fail the instance on a catalog miss + } + }; + if (apiKey) void fetchModels(); + + const sendTurn = async (turn: SendTurnInput) => { + const { threadId } = turn; + if (!apiKey) { + throw new Error( + `no API key — set ${config.apiKeyEnv} or add it to the instance config`, + ); + } + if (active.has(threadId)) { + throw new Error("a turn is already running on this thread"); + } + const turnId = newId(); + const abort = new AbortController(); + active.set(threadId, { abort, turnId }); + + const messages = [ + ...(turn.system ? [{ role: "system", content: turn.system }] : []), + ...(turn.transcript ?? []).map((m) => ({ + role: m.role === "assistant" ? "assistant" : "user", + content: m.text, + })), + { role: "user", content: turn.text }, + ]; + appendNative(threadId, { + dir: "out", + source: "openai-compat.chat.completions", + // Native logs are diagnostic artifacts users commonly attach to + // issues. Keep routing metadata, not prompts or transcript content. + msg: { model: turn.model ?? catalog.default, messageCount: messages.length }, + }); + + emit({ ...base(threadId, turnId), type: "turn.started" }); + emit({ + ...base(threadId, turnId), + type: "session.started", + sessionId: null, + model: turn.model ?? catalog.default, + }); + + (async () => { + try { + const { text, usage } = await complete( + messages, + turn.model || catalog.default, + { + stream: true, + signal: abort.signal, + onDelta: (delta) => + emit({ + ...base(threadId, turnId), + type: "content.delta", + streamKind: "assistant_text", + delta, + }), + }, + ); + appendNative(threadId, { + dir: "in", + source: "openai-compat.chat.completions", + msg: { textLength: text.length, usage }, + }); + if (text.trim()) { + emit({ + ...base(threadId, turnId), + type: "item.completed", + itemType: "assistant_text", + text, + }); + } + if (usage) { + emit({ + ...base(threadId, turnId), + type: "thread.token-usage.updated", + ...usage, + }); + } + active.delete(threadId); + emit({ + ...base(threadId, turnId), + type: "turn.completed", + ok: true, + stopReason: null, + cost: null, + ...(usage ? { usage } : {}), + }); + } catch (e) { + active.delete(threadId); + const aborted = (e as Error).name === "AbortError"; + if (!aborted) { + emit({ + ...base(threadId, turnId), + type: "runtime.error", + message: (e as Error).message, + }); + } + emit({ + ...base(threadId, turnId), + type: "turn.completed", + ok: false, + stopReason: aborted ? "interrupted" : "error", + cost: null, + }); + } + })(); + + return { turnId }; + }; + + const snapshot = async (): Promise => { + if (!apiKey) { + return { + state: "unavailable", + reason: `no API key — set ${config.apiKeyEnv} or add it to the instance config`, + }; + } + return { state: "available", authenticated: true, version: null, billing: "metered" }; + }; + + return { + instanceId, + driverKind: DRIVER_KIND, + displayName: input.displayName, + enabled: input.enabled, + get models() { + return catalog; + }, + refreshModels: fetchModels, + snapshot, + adapter: { + provider: DRIVER_KIND, + capabilities: { sessionModelSwitch: "in-session" }, + sendTurn, + interruptTurn: async (threadId) => active.get(threadId)?.abort.abort(), + respondToRequest: async () => "unavailable" as const, + hasSession: (threadId) => active.has(threadId), + stopAll: async () => { + for (const { abort } of active.values()) abort.abort(); + }, + onEvent: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + generateText: async (prompt: string) => { + const { text } = await complete( + [{ role: "user", content: prompt }], + catalog.default, + { stream: false }, + ); + return text; + }, + dispose: async () => { + for (const { abort } of active.values()) abort.abort(); + listeners.clear(); + }, + }; + }, +}; diff --git a/server/drivers/pi.test.ts b/server/drivers/pi.test.ts index 72756159f..e9bf87e0d 100644 --- a/server/drivers/pi.test.ts +++ b/server/drivers/pi.test.ts @@ -251,6 +251,33 @@ describe("PiDriver turns (fake CLI)", () => { expect(instance.adapter.hasSession("t-tool")).toBe(false); }); + it("emits each assistant text block before the tool that follows it", async () => { + await create("interleave"); + await instance.adapter.sendTurn({ threadId: "t-interleave", text: "go", model: "ollama-cloud/glm-5.2" }); + await recorder.until((e) => e.type === "turn.completed"); + + const types = recorder.events.map((e) => e.type); + expect(types).toEqual([ + "turn.started", + "session.started", + "content.delta", + "item.completed", // before one + "item.started", + "item.completed", // tool + "content.delta", + "item.completed", // before two + "item.started", + "item.completed", // tool + "content.delta", + "item.completed", // after + "turn.completed", + ]); + const texts = recorder.events + .filter((e) => e.type === "item.completed" && (e as { itemType: string }).itemType === "assistant_text") + .map((e) => (e as { text: string }).text); + expect(texts).toEqual(["before one", "before two", "after"]); + }); + it("brokers a permission ask through request.opened → respondToRequest", async () => { await create("permission"); await instance.adapter.sendTurn({ threadId: "t-perm", text: "go" }); diff --git a/server/drivers/pi.ts b/server/drivers/pi.ts index 5a9870966..eb331e1c8 100644 --- a/server/drivers/pi.ts +++ b/server/drivers/pi.ts @@ -284,12 +284,18 @@ export const PiDriver: ProviderDriver = { child.stdin.write(JSON.stringify(obj) + "\n"); }; + /** Emit buffered assistant text as its own item, then clear it. */ + const flushAssistantText = () => { + const text = assistantText; + assistantText = ""; + if (!text.trim()) return; + emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text }); + }; + const settle = (ok: boolean, stopReason?: string | null, usage?: { input?: number; output?: number }) => { if (settled) return; settled = true; - if (assistantText.trim()) { - emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text: assistantText }); - } + flushAssistantText(); emit({ ...base(threadId, turnId), type: "turn.completed", @@ -350,6 +356,7 @@ export const PiDriver: ProviderDriver = { return; } case "tool_execution_start": { + flushAssistantText(); emit({ ...base(threadId, turnId), type: "item.started", @@ -373,6 +380,7 @@ export const PiDriver: ProviderDriver = { // pi floods setWidget/setStatus for TUI bookkeeping; only // select/confirm/input are questions that wait for an answer. if (evt.method === "select" || evt.method === "confirm" || evt.method === "input") { + flushAssistantText(); const reqId = evt.id ?? newId(); const isQuestion = evt.method === "input"; emit({ @@ -567,4 +575,4 @@ export const PiDriver: ProviderDriver = { }, }; }, -}; \ No newline at end of file +}; diff --git a/server/fleet-model-catalog.test.ts b/server/fleet-model-catalog.test.ts new file mode 100644 index 000000000..358ac7498 --- /dev/null +++ b/server/fleet-model-catalog.test.ts @@ -0,0 +1,523 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { ModelOption } from "./contracts.ts"; +import { + DEFAULT_AOS_MODEL_CATALOG_PATH, + FleetModelCatalogRegistry, + parseFleetModelCatalog, + projectFleetModels, + type FleetModelCatalogSnapshot, + type ParsedFleetModelCatalog, +} from "./fleet-model-catalog.ts"; + +interface ProjectionStatus { + configured: boolean; + reachable: boolean; + verified: boolean; + admitted: boolean; + busy: boolean; + reason: string | null; + last_verification_receipt: string | null; +} + +interface ProjectionModel { + id: string; + display_name: string; + kind: "model" | "route_group"; + provider_id: string; + native_model_id: string | null; + capabilities: string[]; + host: "hosted" | "mac" | "windows"; + cost_class: "paid_subscription" | "paid_metered" | "free" | "local"; + manual_only: boolean; + is_default: boolean; + selectable: boolean; + status: ProjectionStatus; + translations: { + litellm: string | null; + hermes: string | null; + opencode: string | null; + telegram: string | null; + openmausbot: string | null; + }; + route_members: string[]; +} + +interface ProjectionFixture { + schema_version: "openmausbot-models/v1"; + catalog_version: number; + generated_at: string; + source: { + registry_schema_version: string; + registry_version: number; + registry_sha256: string; + }; + provider_candidates: Array<{ + provider_id: string; + display_name: string; + configured: boolean; + selectable: boolean; + reason: "unverified_provider_or_model"; + }>; + default_model_id: string; + models: ProjectionModel[]; +} + +function concreteModel(id: string, overrides: Partial = {}): ProjectionModel { + return { + id, + display_name: id, + kind: "model", + provider_id: "minimax", + native_model_id: id, + capabilities: ["chat"], + host: "hosted", + cost_class: "paid_subscription", + manual_only: true, + is_default: false, + selectable: false, + status: { + configured: true, + reachable: false, + verified: false, + admitted: false, + busy: false, + reason: "fresh admission receipt required", + last_verification_receipt: null, + }, + translations: { + litellm: id, + hermes: `litellm-local:${id}`, + opencode: `litellm-local/${id}`, + telegram: id, + openmausbot: id, + }, + route_members: [], + ...overrides, + }; +} + +function baseCatalog(): ProjectionFixture { + const group = concreteModel("MiniMax-M3", { + display_name: "MiniMax M3", + kind: "route_group", + native_model_id: "MiniMax-M3", + capabilities: ["chat", "tool_use"], + manual_only: false, + is_default: true, + selectable: true, + status: { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy: false, + reason: null, + last_verification_receipt: "/receipt/group.json", + }, + route_members: ["minimax-m3-light"], + }); + const light = concreteModel("minimax-m3-light", { + display_name: "MiniMax M3 — Lightcloud007", + native_model_id: "MiniMax-M3", + }); + return { + schema_version: "openmausbot-models/v1", + catalog_version: 7, + generated_at: "2026-08-22T05:00:00Z", + source: { + registry_schema_version: "aos-model-registry/v1", + registry_version: 12, + registry_sha256: "a".repeat(64), + }, + provider_candidates: [{ + provider_id: "candidate-provider", + display_name: "Candidate Provider", + configured: true, + selectable: false, + reason: "unverified_provider_or_model", + }], + default_model_id: "MiniMax-M3", + models: [group, light], + }; +} + +function projection(mutate?: (catalog: ProjectionFixture) => void): string { + const catalog = baseCatalog(); + mutate?.(catalog); + return JSON.stringify(catalog); +} + +function readySnapshot(parsed: ParsedFleetModelCatalog): FleetModelCatalogSnapshot { + return { + schema: "openmausbot-models/v1", + source: { path: "/fixture", state: "ready", refreshedAt: "now" }, + models: parsed.models, + providerCandidates: parsed.providerCandidates, + }; +} + +function emptyOptions(): ModelOption[] { + return []; +} + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("parseFleetModelCatalog", () => { + it("accepts the locked v1 projection and maps Hermes/OpenCode ids", () => { + const catalog = parseFleetModelCatalog(projection()); + expect(catalog).toMatchObject({ + catalogVersion: 7, + registrySchemaVersion: "aos-model-registry/v1", + registryVersion: 12, + }); + expect(catalog.models[0]).toMatchObject({ + canonicalId: "MiniMax-M3", + kind: "route_group", + costClass: "paid_subscription", + default: true, + translations: [ + { driverKind: "hermesAgent", modelId: "litellm-local:MiniMax-M3" }, + { driverKind: "opencodeGo", modelId: "litellm-local/MiniMax-M3" }, + ], + routeMembers: ["minimax-m3-light"], + }); + expect(catalog.providerCandidates).toEqual([{ + providerId: "candidate-provider", + label: "Candidate Provider", + configured: true, + selectable: false, + reason: "unverified_provider_or_model", + }]); + }); + + it("rejects contradictory selectability, missing group members, and default drift", () => { + expect(() => parseFleetModelCatalog(projection((catalog) => { + catalog.models[1]!.selectable = true; + }))).toThrow("selectable contradicts"); + expect(() => parseFleetModelCatalog(projection((catalog) => { + catalog.models[0]!.route_members = ["absent"]; + }))).toThrow("route member"); + expect(() => parseFleetModelCatalog(projection((catalog) => { + catalog.models[0]!.is_default = false; + catalog.models[1]!.is_default = true; + }))).toThrow("single is_default row must agree"); + expect(() => parseFleetModelCatalog(projection((catalog) => { + catalog.provider_candidates[0]!.selectable = true; + }))).toThrow("must remain false"); + }); + + it("accepts a null provider-native id for a disabled inventory-only row", () => { + const catalog = parseFleetModelCatalog(projection((source) => { + source.models[1]!.native_model_id = null; + })); + expect(catalog.models[1]?.nativeModelId).toBeNull(); + }); + + it("rejects divergent schemas, extra credential fields, secret-looking values, and duplicate ids", () => { + const divergent = Object.assign(baseCatalog(), { schema: "openmausbot-models.v1" }); + expect(() => parseFleetModelCatalog(JSON.stringify(divergent))).toThrow("schema"); + + const withKey = Object.assign(baseCatalog(), { api_key: "should-never-be-here" }); + expect(() => parseFleetModelCatalog(JSON.stringify(withKey))).toThrow("api_key"); + const withReference = Object.assign(baseCatalog(), { credential_ref: "logical-name" }); + expect(() => parseFleetModelCatalog(JSON.stringify(withReference))).toThrow("credential_ref"); + + const withValue = baseCatalog(); + withValue.models[0]!.display_name = "Bearer definitely-a-secret-value"; + expect(() => parseFleetModelCatalog(JSON.stringify(withValue))).toThrow("credential value"); + + const duplicate = baseCatalog(); + duplicate.models.push({ ...duplicate.models[0]! }); + expect(() => parseFleetModelCatalog(JSON.stringify(duplicate))).toThrow("duplicate canonical model id"); + }); +}); + +describe("FleetModelCatalogRegistry", () => { + it("derives the default catalog path from XDG data home or the current user home", () => { + const dataHome = process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share"); + expect(DEFAULT_AOS_MODEL_CATALOG_PATH).toBe(join( + dataHome, + "aos-model-catalog", + "current", + "openmausbot-models.v1.json", + )); + }); + + it("keeps the last inventory visible but fail-closes it after an invalid refresh", () => { + const dir = mkdtempSync(join(tmpdir(), "omb-fleet-catalog-")); + dirs.push(dir); + const path = join(dir, "catalog.json"); + writeFileSync(path, projection()); + const registry = new FleetModelCatalogRegistry(path); + expect(registry.snapshot().source.state).toBe("ready"); + writeFileSync(path, "not json"); + const failed = registry.refresh(); + expect(failed.source.state).toBe("invalid"); + expect(failed.models).toHaveLength(2); + expect(failed.models.every((model) => model.status.admitted === false)).toBe(true); + }); + + it("applies a registry-bound shared default without mutating active tasks", () => { + const dir = mkdtempSync(join(tmpdir(), "omb-fleet-default-")); + dirs.push(dir); + const path = join(dir, "openmausbot-models.v1.json"); + const defaultPath = join(dir, "default-model.v1.json"); + const raw = projection((catalog) => { + const light = catalog.models[1]!; + light.selectable = true; + light.status = { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy: false, + reason: null, + last_verification_receipt: "/receipt/light.json", + }; + }); + writeFileSync(path, raw); + writeFileSync(defaultPath, JSON.stringify({ + schema_version: "aos-model-default/v1", + canonical_model_id: "minimax-m3-light", + catalog_sha256: createHash("sha256").update(raw).digest("hex"), + registry_sha256: "a".repeat(64), + applies_to_new_sessions_only: true, + active_sessions_rewritten: false, + })); + + const snapshot = new FleetModelCatalogRegistry(path, defaultPath).snapshot(); + + expect(snapshot.source.defaultState).toBe("shared"); + expect(snapshot.source.defaultModelId).toBe("minimax-m3-light"); + expect(snapshot.models.find((model) => model.canonicalId === "MiniMax-M3")?.default).toBe(false); + expect(snapshot.models.find((model) => model.canonicalId === "minimax-m3-light")?.default).toBe(true); + }); + + it("keeps the catalog default when the shared default is stale", () => { + const dir = mkdtempSync(join(tmpdir(), "omb-fleet-default-stale-")); + dirs.push(dir); + const path = join(dir, "openmausbot-models.v1.json"); + const defaultPath = join(dir, "default-model.v1.json"); + const raw = projection(); + writeFileSync(path, raw); + writeFileSync(defaultPath, JSON.stringify({ + schema_version: "aos-model-default/v1", + canonical_model_id: "MiniMax-M3", + catalog_sha256: createHash("sha256").update(raw).digest("hex"), + registry_sha256: "c".repeat(64), + applies_to_new_sessions_only: true, + active_sessions_rewritten: false, + })); + + const snapshot = new FleetModelCatalogRegistry(path, defaultPath).snapshot(); + + expect(snapshot.source.defaultState).toBe("invalid"); + expect(snapshot.source.defaultReason).toContain("hash is stale"); + expect(snapshot.source.defaultModelId).toBe("MiniMax-M3"); + }); +}); + +describe("projectFleetModels", () => { + it("preserves historical custom rows for installations with no AOS catalog", () => { + const [instance] = projectFleetModels( + [{ + instanceId: "claude", + driverKind: "claudeAgent", + models: { default: "", options: [{ id: "omlx::local", label: "Local", custom: true }] }, + }], + { + schema: "openmausbot-models/v1", + source: { path: "/missing", state: "missing", refreshedAt: "now" }, + models: [], + providerCandidates: [], + }, + ); + expect(instance.models.options).toContainEqual(expect.objectContaining({ id: "omlx::local" })); + }); + + it("uses driver-native ids and disables busy, non-chat, and unverified rows", () => { + const parsed = parseFleetModelCatalog(projection((catalog) => { + catalog.models.push( + concreteModel("windows-qwen", { + display_name: "Qwen on Windows", + provider_id: "ollama", + native_model_id: "qwen3:14b", + host: "windows", + cost_class: "local", + status: { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy: true, + reason: "GPU is busy", + last_verification_receipt: "/receipt/windows.json", + }, + translations: { + litellm: "windows-qwen", + hermes: "litellm-local:windows-qwen", + opencode: "litellm-local/windows-qwen", + telegram: "windows-qwen", + openmausbot: "ollama-windows/qwen3:14b", + }, + }), + concreteModel("nomic-embed", { + display_name: "Nomic Embed", + provider_id: "ollama", + native_model_id: "nomic-embed-text", + capabilities: ["embedding"], + host: "mac", + cost_class: "local", + status: { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy: false, + reason: null, + last_verification_receipt: "/receipt/mac.json", + }, + translations: { + litellm: "nomic-embed", + hermes: "litellm-local:nomic-embed", + opencode: "litellm-local/nomic-embed", + telegram: null, + openmausbot: "ollama-mac/nomic-embed-text", + }, + }), + ); + })); + const projected = projectFleetModels( + [ + { + instanceId: "hermes", + driverKind: "hermesAgent", + models: { + default: "raw-local-model", + options: [{ id: "raw-local-model", label: "Unclassified raw row", custom: true }], + }, + }, + { instanceId: "opencode", driverKind: "opencodeGo", models: { default: "go", options: emptyOptions() } }, + ], + readySnapshot(parsed), + ); + expect(projected[0]?.models.default).toBe("litellm-local:MiniMax-M3"); + expect(projected[0]?.models.options).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: "litellm-local:MiniMax-M3", + canonicalId: "MiniMax-M3", + isDefault: true, + selectable: true, + }), + expect.objectContaining({ id: "litellm-local:windows-qwen", selectable: false, reason: "GPU is busy" }), + expect.objectContaining({ id: "litellm-local:nomic-embed", selectable: false, reason: "Not chat-capable" }), + expect.objectContaining({ id: "litellm-local:minimax-m3-light", selectable: false }), + ])); + expect(projected[1]?.models.options).toContainEqual(expect.objectContaining({ + id: "litellm-local/MiniMax-M3", + canonicalId: "MiniMax-M3", + selectable: true, + })); + expect(projected[0]?.models.options.some((option) => option.id === "raw-local-model")).toBe(false); + }); + + it("keeps Mac and Windows direct-local translations on disjoint instances", () => { + const parsed = parseFleetModelCatalog(projection((source) => { + const local = (id: string, host: "mac" | "windows", selector: string, busy: boolean) => concreteModel(id, { + display_name: `${id} on ${host}`, + provider_id: "ollama", + native_model_id: "qwen3:14b", + host, + cost_class: "local", + selectable: !busy, + status: { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy, + reason: busy ? "GPU is busy" : null, + last_verification_receipt: "/receipt/local.json", + }, + translations: { + litellm: null, + hermes: null, + opencode: null, + telegram: null, + openmausbot: selector, + }, + }); + source.models.push( + local("mac-qwen", "mac", "ollama-mac/qwen3:14b", false), + local("windows-qwen", "windows", "ollama-windows/qwen3:14b", true), + ); + })); + const projected = projectFleetModels( + [ + { instanceId: "localMac", driverKind: "local", models: { default: "", options: emptyOptions() } }, + { instanceId: "localWindows", driverKind: "local", models: { default: "", options: emptyOptions() } }, + ], + readySnapshot(parsed), + ); + expect(projected[0]?.models.options).toContainEqual(expect.objectContaining({ + canonicalId: "mac-qwen", + id: "ollama-mac/qwen3:14b", + selectable: true, + })); + expect(projected[0]?.models.options.some((option) => option.canonicalId === "windows-qwen")).toBe(false); + expect(projected[1]?.models.options).toContainEqual(expect.objectContaining({ + canonicalId: "windows-qwen", + id: "ollama-windows/qwen3:14b", + selectable: false, + reason: "GPU is busy", + })); + }); + + it("keeps unsupported models and provider candidates visible but disabled on one rail", () => { + const parsed = parseFleetModelCatalog(projection((source) => { + source.models.push(concreteModel("unsupported-candidate", { + display_name: "Unsupported candidate", + provider_id: "candidate-provider", + native_model_id: null, + cost_class: "free", + status: { + configured: true, + reachable: false, + verified: false, + admitted: false, + busy: false, + reason: "Provider model discovery did not verify this candidate", + last_verification_receipt: null, + }, + translations: { litellm: null, hermes: null, opencode: null, telegram: null, openmausbot: null }, + })); + })); + const [hermes, opencode] = projectFleetModels( + [ + { instanceId: "hermes", driverKind: "hermesAgent", models: { default: "", options: emptyOptions() } }, + { instanceId: "opencode", driverKind: "opencodeGo", models: { default: "", options: emptyOptions() } }, + ], + readySnapshot(parsed), + ); + expect(hermes.models.options).toContainEqual(expect.objectContaining({ + canonicalId: "unsupported-candidate", + selectable: false, + reason: "Provider model discovery did not verify this candidate", + })); + expect(opencode.models.options.some((option) => option.canonicalId === "unsupported-candidate")).toBe(false); + expect(hermes.models.options).toContainEqual(expect.objectContaining({ + canonicalId: "provider-candidate:candidate-provider", + provider: "candidate-provider", + selectable: false, + reason: "unverified_provider_or_model", + })); + }); +}); diff --git a/server/fleet-model-catalog.ts b/server/fleet-model-catalog.ts new file mode 100644 index 000000000..b7d3c68d8 --- /dev/null +++ b/server/fleet-model-catalog.ts @@ -0,0 +1,552 @@ +// Guarded AOS fleet-model projection for OpenMausBot. +// +// This adapter reads one versioned, secret-free file. It never discovers +// providers, talks to model hosts, or reads credential stores. The producer +// owns live admission; OpenMausBot only renders its cached verdict and maps a +// stable canonical id to the native id understood by a concrete driver. +import { createHash } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +import { z } from "zod"; + +import type { ModelOption, ModelRuntimeStatus } from "./contracts.ts"; +import { parseJson, schemaIssue } from "./schema.ts"; + +const AOS_DATA_HOME = process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share"); +export const DEFAULT_AOS_MODEL_CATALOG_PATH = join( + AOS_DATA_HOME, + "aos-model-catalog", + "current", + "openmausbot-models.v1.json", +); + +const MAX_CATALOG_BYTES = 2 * 1024 * 1024; +const ID = /^[a-z0-9][a-z0-9._:/+-]{0,191}$/i; +const RFC3339 = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/; +const FORBIDDEN_SECRET_VALUE = /^(?:Bearer\s+\S{12,}|sk-[A-Za-z0-9_-]{12,}|xai-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{20,}|AKIA[A-Z0-9]{16})$/; + +const stableIdSchema = z.string() + .regex(ID, "must be a stable model id") + .refine((value) => !FORBIDDEN_SECRET_VALUE.test(value), "must not contain a credential value"); +const secretFreeTextSchema = z.string().trim().min(1).max(4_096).refine( + (value) => !FORBIDDEN_SECRET_VALUE.test(value), + "must not contain a credential value", +); +const capabilitySchema = z.string().regex(/^[a-z][a-z0-9._-]{0,47}$/); +const capabilitiesSchema = z.array(capabilitySchema).refine( + (values) => new Set(values).size === values.length, + "must not contain duplicate capabilities", +); +const runtimeStatusSchema = z.object({ + configured: z.boolean(), + reachable: z.boolean(), + verified: z.boolean(), + admitted: z.boolean(), + busy: z.boolean(), + reason: secretFreeTextSchema.nullable(), + last_verification_receipt: secretFreeTextSchema.nullable(), +}).strict(); +const translationsSchema = z.object({ + litellm: stableIdSchema.nullable(), + hermes: stableIdSchema.nullable(), + opencode: stableIdSchema.nullable(), + telegram: stableIdSchema.nullable(), + openmausbot: stableIdSchema.nullable(), +}).strict(); +const modelRowSchema = z.object({ + id: stableIdSchema, + display_name: secretFreeTextSchema.max(160), + kind: z.enum(["model", "route_group"]), + provider_id: stableIdSchema, + native_model_id: secretFreeTextSchema.max(512).nullable(), + capabilities: capabilitiesSchema, + host: z.enum(["hosted", "mac", "windows"]), + cost_class: z.enum(["paid_subscription", "paid_metered", "free", "local"]), + manual_only: z.boolean(), + is_default: z.boolean(), + selectable: z.boolean(), + status: runtimeStatusSchema, + translations: translationsSchema, + route_members: z.array(stableIdSchema).refine( + (members) => new Set(members).size === members.length, + "must not contain duplicate route members", + ), +}).strict().superRefine((model, context) => { + if (model.selectable && ( + !model.status.configured || + !model.status.reachable || + !model.status.verified || + !model.status.admitted || + model.status.busy || + !model.capabilities.includes("chat") + )) { + context.addIssue({ + code: "custom", + path: ["selectable"], + message: "selectable contradicts runtime admission gates or chat capability", + }); + } + if (model.kind === "model" && model.route_members.length > 0) { + context.addIssue({ + code: "custom", + path: ["route_members"], + message: "a concrete model cannot have route members", + }); + } +}); +const providerCandidateSchema = z.object({ + provider_id: stableIdSchema, + display_name: secretFreeTextSchema.max(160), + configured: z.boolean(), + selectable: z.boolean().refine((selectable) => !selectable, { + message: "must remain false without an exact model identity", + }), + reason: z.literal("unverified_provider_or_model"), +}).strict(); +const catalogProjectionSchema = z.object({ + schema_version: z.literal("openmausbot-models/v1"), + catalog_version: z.number().int().positive(), + generated_at: z.string().refine( + (value) => RFC3339.test(value) && !Number.isNaN(Date.parse(value)), + "must be RFC3339", + ), + source: z.object({ + registry_schema_version: secretFreeTextSchema.max(160), + registry_version: z.number().int().positive(), + registry_sha256: z.string().regex(/^[a-f0-9]{64}$/i), + }).strict(), + default_model_id: stableIdSchema, + provider_candidates: z.array(providerCandidateSchema), + models: z.array(modelRowSchema), +}).strict().superRefine((catalog, context) => { + const ids = new Set(); + for (const [index, model] of catalog.models.entries()) { + if (ids.has(model.id)) { + context.addIssue({ + code: "custom", + path: ["models", index, "id"], + message: `duplicate canonical model id "${model.id}"`, + }); + } + ids.add(model.id); + } + const providerIds = new Set(); + for (const [index, candidate] of catalog.provider_candidates.entries()) { + if (providerIds.has(candidate.provider_id)) { + context.addIssue({ + code: "custom", + path: ["provider_candidates", index, "provider_id"], + message: `duplicate provider candidate "${candidate.provider_id}"`, + }); + } + providerIds.add(candidate.provider_id); + } + if (!ids.has(catalog.default_model_id)) { + context.addIssue({ + code: "custom", + path: ["default_model_id"], + message: "is absent from models", + }); + } + const defaults = catalog.models.filter((model) => model.is_default); + if (defaults.length !== 1 || defaults[0]?.id !== catalog.default_model_id) { + context.addIssue({ + code: "custom", + path: ["default_model_id"], + message: "and the single is_default row must agree", + }); + } + for (const [index, model] of catalog.models.entries()) { + for (const member of model.route_members) { + if (!ids.has(member)) { + context.addIssue({ + code: "custom", + path: ["models", index, "route_members"], + message: `route member "${member}" is absent from models`, + }); + } + } + } +}); +const sharedDefaultSchema = z.object({ + schema_version: z.literal("aos-model-default/v1"), + canonical_model_id: stableIdSchema, + catalog_sha256: z.string().regex(/^[a-f0-9]{64}$/i), + registry_sha256: z.string().regex(/^[a-f0-9]{64}$/i), + applies_to_new_sessions_only: z.literal(true), + active_sessions_rewritten: z.literal(false), +}).strict(); + +export type FleetCatalogState = "ready" | "missing" | "invalid"; + +export interface FleetModelTranslation { + driverKind: string; + modelId: string; + instanceId?: string; +} + +export interface FleetModelRecord { + canonicalId: string; + label: string; + kind: "model" | "route_group"; + nativeModelId: string | null; + provider: string; + host: "hosted" | "mac" | "windows"; + costClass: "paid_subscription" | "paid_metered" | "free" | "local"; + capabilities: string[]; + status: ModelRuntimeStatus; + reason?: string; + default: boolean; + manualOnly: boolean; + declaredSelectable: boolean; + verificationReceipt?: string; + translations: FleetModelTranslation[]; + routeMembers: string[]; +} + +export interface FleetProviderCandidate { + providerId: string; + label: string; + configured: boolean; + selectable: false; + reason: string; +} + +export interface FleetModelCatalogSnapshot { + schema: "openmausbot-models/v1"; + source: { + path: string; + state: FleetCatalogState; + refreshedAt: string; + generatedAt?: string; + catalogVersion?: number; + registrySchemaVersion?: string; + registryVersion?: number; + registrySha256?: string; + defaultModelId?: string; + defaultState?: "catalog" | "shared" | "invalid"; + defaultReason?: string; + reason?: string; + }; + models: FleetModelRecord[]; + providerCandidates: FleetProviderCandidate[]; +} + +export interface ParsedFleetModelCatalog { + generatedAt: string; + catalogVersion: number; + registrySchemaVersion: string; + registryVersion: number; + registrySha256: string; + models: FleetModelRecord[]; + providerCandidates: FleetProviderCandidate[]; +} + +interface InstanceDescription { + instanceId: string; + driverKind: string; + models: { default: string; options: ModelOption[] }; + snapshot?: { state: string; reason?: string }; +} + +function modelTranslations(model: z.output): FleetModelTranslation[] { + const translations: FleetModelTranslation[] = []; + if (model.translations.hermes) { + translations.push({ driverKind: "hermesAgent", modelId: model.translations.hermes }); + } + if (model.translations.opencode) { + translations.push({ driverKind: "opencodeGo", modelId: model.translations.opencode }); + } + if ((model.host === "mac" || model.host === "windows") && model.translations.openmausbot) { + translations.push({ + driverKind: "local", + modelId: model.translations.openmausbot, + instanceId: model.host === "mac" ? "localMac" : "localWindows", + }); + } + return translations; +} + +export function parseFleetModelCatalog(raw: string): ParsedFleetModelCatalog { + if (Buffer.byteLength(raw, "utf8") > MAX_CATALOG_BYTES) throw new Error("catalog exceeds 2 MiB"); + let json; + try { + json = parseJson(raw); + } catch { + throw new Error("catalog is not valid JSON"); + } + const parsed = catalogProjectionSchema.safeParse(json); + if (!parsed.success) throw new Error(schemaIssue(parsed.error, "catalog is invalid")); + const catalog = parsed.data; + const models = catalog.models.map((model): FleetModelRecord => { + const record: FleetModelRecord = { + canonicalId: model.id, + label: model.display_name, + kind: model.kind, + nativeModelId: model.native_model_id, + provider: model.provider_id, + host: model.host, + costClass: model.cost_class, + capabilities: [...model.capabilities], + status: { + configured: model.status.configured, + reachable: model.status.reachable, + verified: model.status.verified, + admitted: model.status.admitted, + busy: model.status.busy, + }, + default: model.is_default, + manualOnly: model.manual_only, + declaredSelectable: model.selectable, + translations: modelTranslations(model), + routeMembers: [...model.route_members], + }; + if (model.status.reason) record.reason = model.status.reason; + if (model.status.last_verification_receipt) { + record.verificationReceipt = model.status.last_verification_receipt; + } + return record; + }); + return { + generatedAt: catalog.generated_at, + catalogVersion: catalog.catalog_version, + registrySchemaVersion: catalog.source.registry_schema_version, + registryVersion: catalog.source.registry_version, + registrySha256: catalog.source.registry_sha256, + models, + providerCandidates: catalog.provider_candidates.map((candidate) => ({ + providerId: candidate.provider_id, + label: candidate.display_name, + configured: candidate.configured, + selectable: false, + reason: candidate.reason, + })), + }; +} + +function failedModels(previous: readonly FleetModelRecord[], reason: string): FleetModelRecord[] { + return previous.map((model) => ({ + ...model, + status: { ...model.status, admitted: false }, + reason, + })); +} + +export class FleetModelCatalogRegistry { + readonly path: string; + readonly defaultPath: string; + private cached: FleetModelCatalogSnapshot; + + constructor( + path = process.env.AOS_MODEL_CATALOG_PATH?.trim() || DEFAULT_AOS_MODEL_CATALOG_PATH, + defaultPath = process.env.AOS_MODEL_DEFAULT_PATH?.trim() || join(dirname(path), "default-model.v1.json"), + ) { + this.path = path; + this.defaultPath = defaultPath; + this.cached = { + schema: "openmausbot-models/v1", + source: { path, state: "missing", refreshedAt: new Date().toISOString(), reason: "catalog not loaded" }, + models: [], + providerCandidates: [], + }; + this.refresh(); + } + + snapshot(): FleetModelCatalogSnapshot { + return structuredClone(this.cached); + } + + refresh(): FleetModelCatalogSnapshot { + const refreshedAt = new Date().toISOString(); + try { + const size = statSync(this.path).size; + if (size > MAX_CATALOG_BYTES) throw new Error("catalog exceeds 2 MiB"); + const raw = readFileSync(this.path, "utf8"); + const parsed = parseFleetModelCatalog(raw); + const catalogSha256 = createHash("sha256").update(raw).digest("hex"); + let defaultState: "catalog" | "shared" | "invalid" = "catalog"; + let defaultReason: string | undefined; + try { + const shared = sharedDefaultSchema.parse( + parseJson(readFileSync(this.defaultPath, "utf8")), + ); + if (shared.registry_sha256 !== parsed.registrySha256) { + throw new Error("shared default registry hash is stale"); + } + if (shared.catalog_sha256 !== catalogSha256) { + throw new Error("shared default catalog hash is stale"); + } + const selected = parsed.models.find( + (model) => model.canonicalId === shared.canonical_model_id, + ); + if (!selected) throw new Error("shared default canonical model is unknown"); + const unavailable = unavailability(selected); + if (unavailable) throw new Error(`shared default is unavailable: ${unavailable}`); + for (const model of parsed.models) { + model.default = model.canonicalId === shared.canonical_model_id; + } + defaultState = "shared"; + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + defaultState = "invalid"; + defaultReason = error instanceof Error ? error.message : "shared default is invalid"; + } + } + const defaultModelId = parsed.models.find((model) => model.default)?.canonicalId; + const source: FleetModelCatalogSnapshot["source"] = { + path: this.path, + state: "ready", + refreshedAt, + generatedAt: parsed.generatedAt, + catalogVersion: parsed.catalogVersion, + registrySchemaVersion: parsed.registrySchemaVersion, + registryVersion: parsed.registryVersion, + registrySha256: parsed.registrySha256, + defaultModelId, + defaultState, + }; + if (defaultReason) source.defaultReason = defaultReason; + this.cached = { + schema: "openmausbot-models/v1", + source, + models: parsed.models, + providerCandidates: parsed.providerCandidates, + }; + } catch (error) { + const missing = error instanceof Error && "code" in error && error.code === "ENOENT"; + const reason = missing ? "catalog file is missing" : error instanceof Error ? error.message : "catalog is invalid"; + this.cached = { + schema: "openmausbot-models/v1", + source: { + path: this.path, + state: missing ? "missing" : "invalid", + refreshedAt, + reason, + }, + models: failedModels(this.cached.models, reason), + providerCandidates: this.cached.providerCandidates.map((candidate) => ({ ...candidate, reason })), + }; + } + return this.snapshot(); + } +} + +function unavailability(model: FleetModelRecord): string | undefined { + if (!model.capabilities.includes("chat")) return model.reason ?? "Not chat-capable"; + if (model.status.busy) return model.reason ?? "Host is busy"; + if (!model.status.configured) return model.reason ?? "Not configured"; + if (!model.status.reachable) return model.reason ?? "Host is unreachable"; + if (!model.status.verified) return model.reason ?? "Not verified"; + if (!model.status.admitted) return model.reason ?? "Not admitted"; + if (!model.declaredSelectable) return model.reason ?? "Not currently selectable"; + return undefined; +} + +function optionFor(model: FleetModelRecord, nativeId: string): ModelOption { + const reason = unavailability(model); + const option: ModelOption = { + id: nativeId, + label: model.label, + custom: true, + canonicalId: model.canonicalId, + provider: model.provider, + host: model.host, + costClass: model.costClass, + manualOnly: model.manualOnly, + isDefault: model.default, + capabilities: [...model.capabilities], + status: { ...model.status }, + selectable: reason === undefined, + }; + if (reason) option.reason = reason; + if (model.verificationReceipt) option.verificationReceipt = model.verificationReceipt; + return option; +} + +/** Merge driver-native projections into model-picker rows without changing + * the live driver catalog or probing any provider. */ +export function projectFleetModels( + instances: readonly T[], + catalog: FleetModelCatalogSnapshot, +): T[] { + const projectedCanonicalIds = new Set(); + const ownsCustomInventory = catalog.source.state === "ready" || + catalog.models.length > 0 || catalog.providerCandidates.length > 0; + const projected = instances.map((instance) => { + // Once a guarded catalog exists, it is the only owner of Custom rows. + // Raw local discovery cannot re-admit an unclassified embedding model or + // a machine that the producer marked busy/unreachable. A non-AOS install + // with no catalog keeps the product's historical custom-model behavior. + const options = instance.models.options + .filter((option) => !ownsCustomInventory || !option.custom) + .map((option) => ({ ...option })); + let defaultId = options.some((option) => option.id === instance.models.default) + ? instance.models.default + : options[0]?.id ?? ""; + for (const model of catalog.models) { + const translations = model.translations.filter((translation) => + translation.driverKind === instance.driverKind && + (!translation.instanceId || translation.instanceId === instance.instanceId) + ); + for (const translation of translations) { + projectedCanonicalIds.add(model.canonicalId); + const projectedOption = optionFor(model, translation.modelId); + const existing = options.findIndex((option) => option.id === projectedOption.id); + if (existing >= 0) options[existing] = { ...options[existing], ...projectedOption }; + else options.push(projectedOption); + if (model.default && projectedOption.selectable) defaultId = projectedOption.id; + } + } + return { + ...instance, + models: { default: defaultId, options }, + }; + }); + + // Unsupported candidates still belong in the inventory. Put each one on a + // single preferred fleet rail as a disabled row instead of silently + // dropping it or inventing a driver translation that could execute it. + const inventoryTarget = + projected.find((instance) => instance.driverKind === "hermesAgent") ?? + projected.find((instance) => instance.driverKind === "opencodeGo") ?? + projected[0]; + if (inventoryTarget) { + for (const model of catalog.models) { + if (projectedCanonicalIds.has(model.canonicalId)) continue; + const inventory = optionFor(model, model.canonicalId); + inventory.selectable = false; + inventory.reason = model.reason ?? "Unavailable on this OpenMausBot surface"; + if (!inventoryTarget.models.options.some((option) => option.canonicalId === model.canonicalId)) { + inventoryTarget.models.options.push(inventory); + } + } + for (const candidate of catalog.providerCandidates) { + const canonicalId = `provider-candidate:${candidate.providerId}`; + if (inventoryTarget.models.options.some((option) => option.canonicalId === canonicalId)) continue; + inventoryTarget.models.options.push({ + id: canonicalId, + label: candidate.label, + custom: true, + canonicalId, + provider: candidate.providerId, + host: "hosted", + costClass: "unknown", + manualOnly: true, + isDefault: false, + capabilities: [], + status: { + configured: candidate.configured, + reachable: false, + verified: false, + admitted: false, + busy: false, + }, + selectable: false, + reason: candidate.reason, + }); + } + } + return projected; +} diff --git a/server/harness/registry.test.ts b/server/harness/registry.test.ts index f677fd242..350c42579 100644 --- a/server/harness/registry.test.ts +++ b/server/harness/registry.test.ts @@ -96,6 +96,32 @@ describe("ProviderRegistry", () => { expect(f.snapshot).toMatchObject({ state: "unavailable", reason: "boom at create" }); }); + it("creates provider instances concurrently while preserving config order", async () => { + const first = makeFakeDriver({ kind: "first" }); + const second = makeFakeDriver({ kind: "second" }); + let active = 0; + let maxActive = 0; + for (const fake of [first, second]) { + const create = fake.driver.create.bind(fake.driver); + fake.driver.create = async (input) => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 20)); + try { + return await create(input); + } finally { + active -= 1; + } + }; + } + const registry = new ProviderRegistry([first.driver, second.driver]); + + await registry.load({ a: { driver: "first" }, b: { driver: "second" } }); + + expect(maxActive).toBe(2); + expect(registry.entries().map((entry) => entry.instanceId)).toEqual(["a", "b"]); + }); + it("describe() reports a snapshot() failure as unavailable rather than throwing", async () => { const fake = makeFakeDriver({ failSnapshot: "provider probe exploded" }); const registry = new ProviderRegistry([fake.driver]); diff --git a/server/harness/registry.ts b/server/harness/registry.ts index efa00953e..75766fcd7 100644 --- a/server/harness/registry.ts +++ b/server/harness/registry.ts @@ -58,21 +58,27 @@ export class ProviderRegistry { } async load(configs: InstanceConfigMap) { - for (const [instanceId, entry] of Object.entries(configs)) { + const loaded = await Promise.all(Object.entries(configs).map(async ([instanceId, entry]): Promise<{ + instanceId: InstanceId; + registryEntry: RegistryEntry; + rawCli?: string; + }> => { const driver = this.driversByKind.get(entry.driver); if (!driver) { - this.byId.set(instanceId, { + return { instanceId, - shadow: { + registryEntry: { instanceId, - driverKind: entry.driver, - displayName: entry.displayName, - cli: cliOfRaw(entry.config), - shadow: true, - reason: `unknown driver "${entry.driver}" — kept as configured, unavailable here`, + shadow: { + instanceId, + driverKind: entry.driver, + displayName: entry.displayName, + cli: cliOfRaw(entry.config), + shadow: true, + reason: `unknown driver "${entry.driver}" — kept as configured, unavailable here`, + }, }, - }); - continue; + }; } try { const config = entry.config === undefined ? driver.defaultConfig() : driver.decodeConfig(entry.config); @@ -80,7 +86,6 @@ export class ProviderRegistry { // decodeConfig fills in the driver default ("claude", "codex", …), // so reading `cli` there would flag every instance as overridden. const rawCli = cliOfRaw(entry.config); - if (rawCli) this.cliByInstance.set(instanceId, rawCli); const live = await driver.create({ instanceId, displayName: entry.displayName ?? driver.metadata.displayName, @@ -88,20 +93,29 @@ export class ProviderRegistry { enabled: entry.enabled ?? true, config, }); - this.byId.set(instanceId, { instanceId, live }); + return { instanceId, registryEntry: { instanceId, live }, rawCli }; } catch (e) { - this.byId.set(instanceId, { + return { instanceId, - shadow: { + registryEntry: { instanceId, - driverKind: entry.driver, - displayName: entry.displayName ?? driver.metadata.displayName, - cli: cliOfRaw(entry.config), - shadow: true, - reason: e instanceof Error ? e.message : String(e), + shadow: { + instanceId, + driverKind: entry.driver, + displayName: entry.displayName ?? driver.metadata.displayName, + cli: cliOfRaw(entry.config), + shadow: true, + reason: e instanceof Error ? e.message : String(e), + }, }, - }); + }; } + })); + // Promise.all preserves config order while allowing slow provider probes + // to overlap. Commit the completed rows only after every create settles. + for (const row of loaded) { + if (row.rawCli) this.cliByInstance.set(row.instanceId, row.rawCli); + this.byId.set(row.instanceId, row.registryEntry); } } @@ -117,8 +131,12 @@ export class ProviderRegistry { return [...this.byId.values()].flatMap((e) => (e.live ? [e.live] : [])); } - /** instance snapshots for the model picker: id, driver, models, health */ - async describe() { + /** Instance snapshots for the model picker: id, driver, models, health. + * Live model discovery is opt-out for existing callers, but the HTTP picker + * route passes refreshModels:false so opening cached UI never fans out to + * unrelated CLIs or local providers. */ + async describe(options: { refreshModels?: boolean } = {}) { + const refreshModels = options.refreshModels !== false; // Multiple instances may share a driver. Scan each default binary once // per response instead of repeating filesystem work for every row. const candidatesByName = new Map(); @@ -155,7 +173,7 @@ export class ProviderRegistry { const inst = entry.live; let snapshot: ProviderSnapshot; try { - await inst.refreshModels?.(); + if (refreshModels) await inst.refreshModels?.(); snapshot = await inst.snapshot(); } catch (e) { snapshot = { state: "unavailable", reason: e instanceof Error ? e.message : String(e) }; diff --git a/server/index.test.ts b/server/index.test.ts index b8ada247c..b40f758d4 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -18,6 +18,7 @@ import { IMAGE_MAX_BYTES } from "./attachments.ts"; const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); const ROOT = join(SERVER_DIR, ".."); const FAKE_CLAUDE_CLI = join(SERVER_DIR, "testing", "fake-claude-cli.ts"); +const FAKE_ACP_CLI = join(SERVER_DIR, "testing", "fake-acp-cli.ts"); const PORT = 18800 + Math.floor(Math.random() * 10_000); const BASE = `http://127.0.0.1:${PORT}`; const WEBHOOK_PORT = 39000 + Math.floor(Math.random() * 10_000); @@ -30,8 +31,52 @@ let boxStubPort = 0; let home: string; let staticDir: string; let fakeClaudeDump: string; +let fleetCatalogPath: string; let stderr = ""; +const fleetCatalogFixture = (busy = false) => ({ + schema_version: "openmausbot-models/v1", + catalog_version: 1, + generated_at: "2026-08-22T05:00:00Z", + source: { + registry_schema_version: "aos-model-registry/v1", + registry_version: 1, + registry_sha256: "a".repeat(64), + }, + default_model_id: "fixture-fleet-model", + provider_candidates: [], + models: [{ + id: "fixture-fleet-model", + display_name: "Fixture fleet model", + kind: "model", + provider_id: "fixture", + native_model_id: "fixture-fleet-model", + capabilities: ["chat"], + host: "hosted", + cost_class: "free", + manual_only: false, + is_default: true, + selectable: !busy, + status: { + configured: true, + reachable: true, + verified: true, + admitted: true, + busy, + reason: busy ? "Fixture host is busy" : null, + last_verification_receipt: "/fixture/receipt.json", + }, + translations: { + litellm: "fixture-fleet-model", + hermes: "litellm-local:fixture-fleet-model", + opencode: "litellm-local/fixture-fleet-model", + telegram: "fixture-fleet-model", + openmausbot: "fixture-fleet-model", + }, + route_members: [], + }], +}); + const api = async (method: string, path: string, body?: unknown): Promise<{ status: number; body: any }> => { const res = await fetch(`${BASE}${path}`, { method, @@ -68,17 +113,20 @@ beforeAll(async () => { home = mkdtempSync(join(tmpdir(), "omb-api-test-")); staticDir = join(home, "static"); fakeClaudeDump = join(home, "fake-claude-dump.json"); + fleetCatalogPath = join(home, "openmausbot-models.v1.json"); // a fleet of exactly one unknown driver: no CLI probes, no network mkdirSync(join(home, ".openmausbot"), { recursive: true }); mkdirSync(join(staticDir, "assets"), { recursive: true }); writeFileSync(join(staticDir, "index.html"), "Packaged OpenMausBot"); writeFileSync(join(staticDir, "assets", "smoke.css"), "body { color: white; }"); + writeFileSync(fleetCatalogPath, JSON.stringify(fleetCatalogFixture())); writeFileSync( join(home, ".openmausbot", "config.json"), JSON.stringify({ instances: { ghost: { driver: "not-a-real-driver", displayName: "Ghost" }, claude: { driver: "claudeAgent", displayName: "Fixture Claude", config: { cli: FAKE_CLAUDE_CLI } }, + hermes: { driver: "hermesAgent", displayName: "Fixture Hermes", config: { cli: FAKE_ACP_CLI } }, }, }), ); @@ -224,6 +272,7 @@ beforeAll(async () => { OMB_STATIC_DIR: staticDir, FAKE_CLAUDE_MODE: "hang", FAKE_CLAUDE_DUMP: fakeClaudeDump, + AOS_MODEL_CATALOG_PATH: fleetCatalogPath, }, stdio: ["ignore", "pipe", "pipe"], }); @@ -317,6 +366,89 @@ describe("harness HTTP API", () => { expect(body.bots[0].messages.length).toBeGreaterThanOrEqual(2); }); + it("adds and removes room members through PATCH", async () => { + const [first, second, third] = await Promise.all([ + api("POST", "/api/bots"), + api("POST", "/api/bots"), + api("POST", "/api/bots"), + ]).then((created) => created.map((response) => response.body.bot)); + const room = (await api("POST", "/api/groups", { name: "Roster", memberIds: [first.id, second.id] })).body.group; + try { + const added = await api("PATCH", `/api/groups/${room.id}`, { memberIds: [first.id, second.id, third.id] }); + expect(added.status).toBe(200); + expect(added.body.group.memberIds).toEqual([first.id, second.id, third.id]); + + const removed = await api("PATCH", `/api/groups/${room.id}`, { memberIds: [third.id] }); + expect(removed.status).toBe(200); + expect(removed.body.group.memberIds).toEqual([third.id]); + + const state = (await api("GET", "/api/bots")).body; + expect(state.groups.find((group: { id: string }) => group.id === room.id).memberIds).toEqual([third.id]); + } finally { + await api("DELETE", `/api/groups/${room.id}`); + for (const bot of [first, second, third]) await api("DELETE", `/api/bots/${bot.id}`); + } + }); + + it("refuses to empty a room's roster", async () => { + const bot = (await api("POST", "/api/bots")).body.bot; + const room = (await api("POST", "/api/groups", { name: "Never empty", memberIds: [bot.id] })).body.group; + try { + for (const memberIds of [[], ["no-such-bot"]]) { + const attempted = await api("PATCH", `/api/groups/${room.id}`, { memberIds }); + expect(attempted.status).toBe(400); + expect(attempted.body.error).toMatch(/at least one bot/i); + } + const state = (await api("GET", "/api/bots")).body; + expect(state.groups.find((group: { id: string }) => group.id === room.id).memberIds).toEqual([bot.id]); + } finally { + await api("DELETE", `/api/groups/${room.id}`); + await api("DELETE", `/api/bots/${bot.id}`); + } + }); + + it("deduplicates repeated room members while preserving their first-seen order", async () => { + const [first, second] = await Promise.all([api("POST", "/api/bots"), api("POST", "/api/bots")]).then( + (created) => created.map((response) => response.body.bot), + ); + const room = (await api("POST", "/api/groups", { name: "Unique roster", memberIds: [first.id] })).body.group; + try { + const patched = await api("PATCH", `/api/groups/${room.id}`, { + memberIds: [second.id, first.id, second.id, first.id], + }); + expect(patched.status).toBe(200); + expect(patched.body.group.memberIds).toEqual([second.id, first.id]); + } finally { + await api("DELETE", `/api/groups/${room.id}`); + for (const bot of [first, second]) await api("DELETE", `/api/bots/${bot.id}`); + } + }); + + it("keeps direct-message channels a fixed pair at the API boundary", async () => { + const attempted = await api("PATCH", "/api/groups/test-dm", { memberIds: ["test-bot-a"] }); + expect(attempted.status).toBe(400); + expect(attempted.body.error).toMatch(/direct-message.*members/i); + const state = await api("GET", "/api/bots"); + const dm = state.body.groups.find((group: { id: string }) => group.id === "test-dm"); + expect(dm.memberIds).toEqual(["test-bot-a", "test-bot-b"]); + }); + + it("hands the lead to a remaining member when the lead leaves the room", async () => { + const [lead, other] = await Promise.all([api("POST", "/api/bots"), api("POST", "/api/bots")]).then((created) => + created.map((response) => response.body.bot), + ); + const room = (await api("POST", "/api/groups", { name: "Handover", memberIds: [lead.id, other.id] })).body.group; + try { + expect(room.defaultResponder).toEqual({ kind: "member", botId: lead.id }); + const patched = await api("PATCH", `/api/groups/${room.id}`, { memberIds: [other.id] }); + expect(patched.status).toBe(200); + expect(patched.body.group.defaultResponder).toEqual({ kind: "member", botId: other.id }); + } finally { + await api("DELETE", `/api/groups/${room.id}`); + for (const bot of [lead, other]) await api("DELETE", `/api/bots/${bot.id}`); + } + }); + it("keeps direct-message channels folderless at the API boundary", async () => { const attempted = await api("PATCH", "/api/groups/test-dm", { cwd: home }); expect(attempted.status).toBe(400); @@ -373,6 +505,85 @@ describe("harness HTTP API", () => { })); }); + it("projects the cached secret-free fleet catalog and fail-closes an invalid refresh", async () => { + const first = await api("GET", "/api/instances"); + const hermes = first.body.instances.find((instance: { instanceId: string }) => instance.instanceId === "hermes"); + expect(hermes.models.options).toContainEqual(expect.objectContaining({ + id: "litellm-local:fixture-fleet-model", + canonicalId: "fixture-fleet-model", + costClass: "free", + host: "hosted", + selectable: true, + })); + + writeFileSync(fleetCatalogPath, "not-json"); + const failed = await api("POST", "/api/model-catalog/refresh"); + expect(failed.status).toBe(200); + expect(failed.body.catalog.source.state).toBe("invalid"); + const failedHermes = failed.body.instances.find((instance: { instanceId: string }) => instance.instanceId === "hermes"); + expect(failedHermes.models.options).toContainEqual(expect.objectContaining({ + canonicalId: "fixture-fleet-model", + selectable: false, + })); + + writeFileSync(fleetCatalogPath, JSON.stringify(fleetCatalogFixture())); + expect((await api("POST", "/api/model-catalog/refresh")).body.catalog.source.state).toBe("ready"); + }); + + it("translates a stable fleet id and starts one fresh task after a model change", async () => { + writeFileSync(fleetCatalogPath, JSON.stringify(fleetCatalogFixture())); + await api("POST", "/api/model-catalog/refresh"); + const defaulted = (await api("POST", "/api/bots")).body.bot; + expect(defaulted.modelSelection).toEqual({ + instanceId: "hermes", + model: "litellm-local:fixture-fleet-model", + }); + const created = (await api("PATCH", `/api/bots/${defaulted.id}`, { + modelSelection: { instanceId: "claude", model: "claude-sonnet-5" }, + })).body.bot; + const previousThread = created.threadId; + const previousTaskCount = created.tasks.length; + + const switched = await api("POST", `/api/bots/${created.id}/model`, { + instanceId: "hermes", + model: "caller-value-is-not-trusted", + canonicalId: "fixture-fleet-model", + }); + expect(switched.status).toBe(201); + expect(switched.body.changed).toBe(true); + expect(switched.body.bot.modelSelection).toEqual({ + instanceId: "hermes", + model: "litellm-local:fixture-fleet-model", + }); + expect(switched.body.bot.threadId).not.toBe(previousThread); + expect(switched.body.bot.tasks).toHaveLength(previousTaskCount + 1); + expect(switched.body.bot.messages).toEqual([]); + + const same = await api("POST", `/api/bots/${created.id}/model`, { + instanceId: "hermes", + model: "still-not-authoritative", + canonicalId: "fixture-fleet-model", + }); + expect(same.status).toBe(200); + expect(same.body.changed).toBe(false); + expect(same.body.bot.tasks).toHaveLength(previousTaskCount + 1); + + try { + writeFileSync(fleetCatalogPath, JSON.stringify(fleetCatalogFixture(true))); + await api("POST", "/api/model-catalog/refresh"); + const disabled = await api("POST", `/api/bots/${created.id}/model`, { + instanceId: "hermes", + model: "litellm-local:fixture-fleet-model", + canonicalId: "fixture-fleet-model", + }); + expect(disabled.status).toBe(409); + expect(disabled.body.error).toBe("Fixture host is busy"); + } finally { + writeFileSync(fleetCatalogPath, JSON.stringify(fleetCatalogFixture())); + await api("POST", "/api/model-catalog/refresh"); + } + }); + it("searches transcripts and exports a conversation", async () => { const bot = (await api("POST", "/api/bots")).body.bot; // every new bot opens with a seeded greeting — a known searchable string @@ -475,6 +686,21 @@ describe("harness HTTP API", () => { const clearedEmpty = await api("PATCH", `/api/bots/${bot.id}`, { section: " " }); expect(clearedEmpty.status).toBe(200); expect(clearedEmpty.body.bot).not.toHaveProperty("section"); + + // rooms file under the same sidebar sections, with the same contract + const sectionRoom = (await api("POST", "/api/groups", { name: "Filed", memberIds: [bot.id] })).body.group; + const roomSectioned = await api("PATCH", `/api/groups/${sectionRoom.id}`, { section: " Clients " }); + expect(roomSectioned.status).toBe(200); + expect(roomSectioned.body.group).toMatchObject({ section: "Clients" }); + expect((await api("PATCH", `/api/groups/${sectionRoom.id}`, { section: 7 })).status).toBe(400); + expect((await api("PATCH", `/api/groups/${sectionRoom.id}`, { section: "S".repeat(61) })).status).toBe(400); + const roomSectionCleared = await api("PATCH", `/api/groups/${sectionRoom.id}`, { section: null }); + expect(roomSectionCleared.status).toBe(200); + expect(roomSectionCleared.body.group).not.toHaveProperty("section"); + const roomSectionEmpty = await api("PATCH", `/api/groups/${sectionRoom.id}`, { section: " " }); + expect(roomSectionEmpty.status).toBe(200); + expect(roomSectionEmpty.body.group).not.toHaveProperty("section"); + expect((await api("DELETE", `/api/groups/${sectionRoom.id}`)).status).toBe(200); expect(gated.body.bot.composio).toBe(false); expect((await api("PATCH", `/api/bots/${bot.id}`, { composio: true })).body.bot.composio).toBe(true); @@ -484,6 +710,91 @@ describe("harness HTTP API", () => { expect(after.body.bots.find((b: { id: string }) => b.id === bot.id)).toBeUndefined(); }); + it("explains when archived room members cannot respond", async () => { + const archived = (await api("POST", "/api/bots")).body.bot; + const active = (await api("POST", "/api/bots")).body.bot; + const room = (await api("POST", "/api/groups", { + name: "Archived member feedback", + memberIds: [archived.id, active.id], + })).body.group; + + try { + const archivedBot = await api("PATCH", `/api/bots/${archived.id}`, { + name: "Quill", + hidden: true, + chiefOfStaff: false, + }); + expect(archivedBot.status).toBe(200); + await api("PATCH", `/api/bots/${active.id}`, { + name: "Atlas", + modelSelection: { instanceId: "ghost" }, + }); + await api("PATCH", `/api/groups/${room.id}`, { defaultResponder: { kind: "mentions" } }); + + expect((await api("POST", `/api/groups/${room.id}/messages`, { text: "@Quill take this" })).status).toBe(202); + let state = (await api("GET", "/api/bots?messages=20")).body; + let messages = state.groups.find((group: { id: string }) => group.id === room.id).messages; + expect(messages.at(-1)).toMatchObject({ + kind: "activity", + tool: { + name: "Quill is archived and can't respond — restore it or mention an active room member.", + ok: false, + }, + }); + + const archivedError = "Quill is archived and can't respond — restore it or mention an active room member."; + const beforeMixedMention = messages.filter((message: { tool?: { name?: string } }) => + message.tool?.name === archivedError + ).length; + await api("POST", `/api/groups/${room.id}/messages`, { text: "@Quill and @Atlas take this" }); + await expect.poll(async () => { + state = (await api("GET", "/api/bots?messages=20")).body; + messages = state.groups.find((group: { id: string }) => group.id === room.id).messages; + return { + archivedErrors: messages.filter((message: { tool?: { name?: string } }) => + message.tool?.name === archivedError + ).length, + activeDispatched: messages.some((message: { tool?: { name?: string } }) => + message.tool?.name === "error: Atlas's model is unavailable" + ), + }; + }).toEqual({ archivedErrors: beforeMixedMention + 1, activeDispatched: true }); + + await api("PATCH", `/api/groups/${room.id}`, { + defaultResponder: { kind: "member", botId: archived.id }, + }); + await api("POST", `/api/groups/${room.id}/messages`, { text: "use the default responder" }); + state = (await api("GET", "/api/bots?messages=20")).body; + messages = state.groups.find((group: { id: string }) => group.id === room.id).messages; + expect(messages.at(-1)?.tool).toEqual({ name: archivedError, ok: false }); + + await api("PATCH", `/api/groups/${room.id}`, { defaultResponder: { kind: "mentions" } }); + + const beforeUnmentioned = messages.length; + await api("POST", `/api/groups/${room.id}/messages`, { text: "no mention" }); + state = (await api("GET", "/api/bots?messages=20")).body; + messages = state.groups.find((group: { id: string }) => group.id === room.id).messages; + expect(messages).toHaveLength(beforeUnmentioned + 1); + expect(messages.at(-1)).toMatchObject({ kind: "text", role: "user", text: "no mention" }); + + await api("PATCH", `/api/bots/${active.id}`, { hidden: true }); + await api("POST", `/api/groups/${room.id}/messages`, { text: "hello everyone" }); + state = (await api("GET", "/api/bots?messages=20")).body; + messages = state.groups.find((group: { id: string }) => group.id === room.id).messages; + expect(messages.at(-1)).toMatchObject({ + kind: "activity", + tool: { + name: "No active room members can respond — restore an archived bot or add an active member.", + ok: false, + }, + }); + } finally { + await api("DELETE", `/api/groups/${room.id}`); + await api("DELETE", `/api/bots/${archived.id}`); + await api("DELETE", `/api/bots/${active.id}`); + } + }); + it("saves, serves, and guards image attachments", async () => { // a real 1x1 PNG so the bytes round-trip intact const png = Buffer.from( @@ -780,6 +1091,48 @@ describe("harness HTTP API", () => { } }); + it("the scout reads a folder, proposes an importable team, and creates nothing until the human imports", async () => { + const folder = mkdtempSync(join(tmpdir(), "omb-scout-")); + writeFileSync(join(folder, "README.md"), "# Demo Shop\n\nA storefront demo.\n"); + writeFileSync( + join(folder, "package.json"), + JSON.stringify({ dependencies: { react: "^19" }, devDependencies: { vitest: "^3" } }), + ); + + const before = (await api("GET", "/api/bots")).body; + + expect((await api("GET", "/api/teams/scout")).status).toBe(400); + expect((await api("GET", `/api/teams/scout?cwd=${encodeURIComponent(join(folder, "nope"))}`)).status).toBe(400); + + const scouted = await api("GET", `/api/teams/scout?cwd=${encodeURIComponent(folder)}`); + expect(scouted.status).toBe(200); + expect(scouted.body.profile).toMatchObject({ name: "Demo Shop", summary: "A storefront demo." }); + expect(scouted.body.profile.stacks).toContain("React"); + expect(scouted.body.suggestion.roomName).toBe("Demo Shop"); + const keys = scouted.body.suggestion.manifest.team.members.map((member: { key: string }) => member.key); + expect(keys).toEqual(["lead", "frontend", "testing"]); + expect(Object.keys(scouted.body.suggestion.reasons).sort()).toEqual(keys.slice().sort()); + + // scouting is read-only: no bot and no room exists until the import + const after = (await api("GET", "/api/bots")).body; + expect(after.bots).toHaveLength(before.bots.length); + expect(after.groups).toHaveLength(before.groups.length); + + // and the suggestion goes through the real importer verbatim + const imported = await api( + "POST", + `/api/teams/import?mode=project&cwd=${encodeURIComponent(folder)}&room=${encodeURIComponent(scouted.body.suggestion.roomName)}`, + scouted.body.suggestion.manifest, + ); + expect(imported.status).toBe(201); + expect(imported.body.group).toMatchObject({ name: "Demo Shop", cwd: folder }); + expect(imported.body.bots).toHaveLength(3); + + expect((await api("DELETE", `/api/groups/${imported.body.group.id}`)).status).toBe(200); + for (const bot of imported.body.bots) await api("DELETE", `/api/bots/${bot.id}`); + rmSync(folder, { recursive: true, force: true }); + }); + it("team import is additive-only: smuggled grants, claimed ids, and re-imports never touch existing records", async () => { // an armed bot: every privilege a malicious manifest could try to // capture is switched ON here, so any write-through shows up as a diff diff --git a/server/index.ts b/server/index.ts index 6e80578e1..b6df375c7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -61,7 +61,7 @@ import { ComputerControl } from "./computer-control.ts"; import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.ts"; import { describeSpawnFailure, execCli } from "./procs.ts"; import { buildNotification, type Notification } from "./notify.ts"; -import { isEffortLevel, type RequestOutcome, type RuntimeEvent } from "./contracts.ts"; +import { EFFORT_LEVELS, isEffortLevel, type ModelSelection, type RequestOutcome, type RuntimeEvent } from "./contracts.ts"; import { BUILT_IN_DRIVERS } from "./drivers/builtIn.ts"; import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply, type CommsBus } from "./comms-visibility.ts"; @@ -99,6 +99,8 @@ import { LocalVmLease, LocalVmLeasePool } from "./local-vm-lease.ts"; import { RepeatDetector, callKey } from "./repeat-detector.ts"; import * as vps from "./vps-computer.ts"; import { RoutineManager, type RoutineRunOn, type RoutineRunTrigger } from "./routines.ts"; +import { fetchBotDirectory, matchDirectoryBots, type MatchedDirectoryBot } from "./bot-directory.ts"; +import { scoutProject, suggestTeam } from "./project-scout.ts"; import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-library.ts"; import { createTeamManifest, importedMemberProfile, parseTeamManifest } from "./team-manifest.ts"; import { readThreadEvents } from "./thread-events.ts"; @@ -108,9 +110,19 @@ import { WebhookManager } from "./webhooks.ts"; import { SPAWNED_PROXIES } from "./proxy-paths.ts"; import { loadBundledSkills, renderSkillInstructions, selectBundledSkills } from "./skill-library.ts"; import { shouldMountLocalComputer } from "./local-routing.ts"; +import { + FleetModelCatalogRegistry, + projectFleetModels, +} from "./fleet-model-catalog.ts"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1); +const modelSwitchBodySchema = z.object({ + instanceId: z.string().min(1), + model: z.string().min(1), + canonicalId: z.string().min(1).optional(), + effort: z.enum(EFFORT_LEVELS).optional(), +}).strict(); const STATIC_DIR = process.env.OMB_STATIC_DIR || null; const MIME: Record = { ".html": "text/html", @@ -127,6 +139,15 @@ ensureDirs(); const cfg = loadConfig(); const registry = new ProviderRegistry(BUILT_IN_DRIVERS); await registry.load(instanceConfigs(cfg)); +const fleetModelCatalog = new FleetModelCatalogRegistry(); +let cachedInstanceDescriptions: Awaited> | null = null; + +async function instanceDescriptions(refresh = false) { + if (refresh || !cachedInstanceDescriptions) { + cachedInstanceDescriptions = await registry.describe({ refreshModels: refresh }); + } + return cachedInstanceDescriptions; +} const bundledSkills = loadBundledSkills(); const bus = new EventBus(); @@ -241,17 +262,28 @@ function askBotAndWait(targetBotId: string, message: string, depth: number, from }); } -// default selection for new bots: first available instance, claude preferred +// Default selection for new bots: the validated fleet-wide shared default +// wins when its owning instance is available. Existing tasks retain their +// saved selection; this function is only used for new bots/tasks and reset. async function defaultSelection() { - const described = await registry.describe(); + const described = projectFleetModels( + await instanceDescriptions(false), + fleetModelCatalog.snapshot(), + ); const available = described.filter((d) => d.snapshot.state === "available"); + const sharedDefault = available.find((instance) => + instance.models.options.some((option) => option.isDefault && option.selectable !== false) + ); // Deliberately NO fallback to described[0]. Handing a bot an engine whose // CLI isn't installed makes it look ready and then fail on send with a raw // spawn ENOENT — the single worst first-run experience, and the one every // user with no CLIs used to get. An empty selection is honest: the UI shows // the setup path instead of a bot that cannot answer. - const pick = available.find((d) => d.driverKind === "claudeAgent") ?? available[0]; - return { instanceId: pick?.instanceId ?? "", model: pick?.models.default ?? "" }; + const pick = sharedDefault ?? available.find((d) => d.driverKind === "claudeAgent") ?? available[0]; + const defaultModel = pick?.models.options.find( + (option) => option.isDefault && option.selectable !== false, + )?.id ?? pick?.models.default ?? ""; + return { instanceId: pick?.instanceId ?? "", model: defaultModel }; } let bootSelection = { instanceId: "", model: "" }; const store = new Store(() => bootSelection); @@ -985,7 +1017,9 @@ bus.subscribe((event: RuntimeEvent) => { if (store.bot(bot.id)?.activity !== "dead") store.setActivity(bot.id, "idle"); store.patchBot(bot.id, { unread: true }); if (routineRun?.status !== "failed") { - notify(buildNotification("done", bot, event.threadId, reply)); + // the frame carries the bot's avatar so every desktop client can + // show the notification under that bot's own face + notify(buildNotification("done", bot, event.threadId, reply, { avatarUrl: bot.avatarUrl })); } if (screenPollers.has(bot.id)) { // the last live frame becomes a settled inline screen message — @@ -1154,12 +1188,13 @@ bus.subscribe((event: RuntimeEvent) => { }); function drainQueuedSends() { - drainSteeredMessages(store, (botId, threadId, prompt, userMessage) => + drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds) => // A plain attended turn — no automationSource, no unattended, no comms // depth: exactly what typing the same words into an idle bot would run. - // The messages are already in the transcript; userMessage keeps - // startTurn from appending the joined prompt as a duplicate. - startTurn(botId, prompt, { threadId, userMessage }).catch((err) => { + // Drain just appended the held lines; userMessage keeps startTurn + // from duplicating the last one, and excludeIds drops every drained + // line from the transcript-replay so they are not also in `prompt`. + startTurn(botId, prompt, { threadId, userMessage, excludeMessageIds: excludeIds }).catch((err) => { store.appendMessage(threadId, { role: "bot", kind: "activity", @@ -1284,6 +1319,8 @@ async function startTurn( opts?: { commsDepth?: number; userMessage?: Message; + /** Extra transcript ids to omit (every drained queued line, not just the last). */ + excludeMessageIds?: string[]; /** Routines run in detached tasks; pin the destination for the whole turn. */ threadId?: string; /** Cloud routines run the whole agent inside the bot's Box VM instead @@ -1352,9 +1389,10 @@ async function startTurn( // transcript for API-backed drivers: settled text turns on the ACTIVE // branch only — abandoned forks never reach the model + const skipTranscript = new Set([userMessage.id, ...(opts?.excludeMessageIds ?? [])]); const transcript = store .activePath(threadId) - .filter((m) => m.kind === "text" && m.text && m.id !== userMessage.id) + .filter((m) => m.kind === "text" && m.text && !skipTranscript.has(m.id)) .slice(-40) .map((m) => ({ role: m.role === "user" ? ("user" as const) : ("assistant" as const), text: m.text! })); @@ -1998,16 +2036,46 @@ function startGroupTurn(groupId: string, text: string) { const members = group.memberIds .map((id) => store.bot(id)) .filter((b): b is NonNullable => Boolean(b)); + const availableMembers = members.filter((member) => !member.hidden); + const archived = members.filter((member) => member.hidden); + const mentionedArchived = mentionedBots(text, archived.map(({ name }) => ({ name })))[0]; + if (mentionedArchived) { + store.appendMessage(group.threadId, { + role: "bot", + kind: "activity", + tool: { + name: `${mentionedArchived.name} is archived and can't respond — restore it or mention an active room member.`, + ok: false, + }, + }); + } let responders = roomResponders(text, members, group.defaultResponder); // bot⇄bot channels: chipping in without a tag addresses the last speaker if (!responders.length && group.dm) { const lastSpeakerId = [...store.messagesFor(group.threadId)] .reverse() .find((msg) => msg.kind === "text" && msg.from)?.from?.botId; - const last = members.find((b) => b.id === lastSpeakerId) ?? members[0]; + const last = availableMembers.find((b) => b.id === lastSpeakerId) ?? availableMembers[0]; responders = last ? [last] : []; } - if (!responders.length) return; + if (!responders.length) { + const defaultArchivedId = group.defaultResponder.kind === "member" ? group.defaultResponder.botId : undefined; + const defaultArchived = archived.find((member) => member.id === defaultArchivedId); + let unavailableMessage: string | undefined; + if (!mentionedArchived && !availableMembers.length) { + unavailableMessage = "No active room members can respond — restore an archived bot or add an active member."; + } else if (!mentionedArchived && defaultArchived) { + unavailableMessage = `${defaultArchived.name} is archived and can't respond — restore it or mention an active room member.`; + } + if (unavailableMessage) { + store.appendMessage(group.threadId, { + role: "bot", + kind: "activity", + tool: { name: unavailableMessage, ok: false }, + }); + } + return; + } const prev = groupQueues.get(groupId) ?? Promise.resolve(); const next = prev.then(async () => { @@ -2262,6 +2330,7 @@ async function reloadProviders() { bus.detachAll(); await registry.disposeAll(); await registry.load(instanceConfigs(cfg)); + cachedInstanceDescriptions = null; bus.attach(registry.instances()); // A killed turn's terminal events can die with the old fleet (dispose is // async under the hood), stranding the bot busy — and its screen poller — @@ -3006,6 +3075,38 @@ const server = createServer(async (req, res) => { return json(res, status, { error: error instanceof Error ? error.message : "The GitHub team could not be loaded" }); } } + if (method === "GET" && path === "/api/teams/scout") { + // The scout reads a folder and answers with a suggestion — it creates + // nothing. Bots and the room come into being only when the human sends + // the suggested manifest through /api/teams/import, so "the agent + // proposes, the person imports" is enforced by the route split itself. + // The folder is whatever validateBotCwd accepts: the same local-user + // trust boundary as pointing any bot's working folder at a path. + // Deliberately offline — the community directory lives on its own + // route below, so a slow network can never delay the suggestion. + const validated = validateBotCwd(url.searchParams.get("cwd")); + if (!validated.ok) return json(res, 400, { error: validated.error }); + if (!validated.cwd) return json(res, 400, { error: "scout needs a folder to read" }); + const profile = scoutProject(validated.cwd); + return json(res, 200, { profile, suggestion: suggestTeam(profile) }); + } + if (method === "GET" && path === "/api/teams/scout/directory") { + // Community bots that fit the scouted folder — a separate, lazy call + // so an unreachable directory degrades to "no extra candidates", never + // to a broken scout. + const validated = validateBotCwd(url.searchParams.get("cwd")); + if (!validated.ok) return json(res, 400, { error: validated.error }); + if (!validated.cwd) return json(res, 400, { error: "scout needs a folder to read" }); + let directory: MatchedDirectoryBot[] = []; + try { + directory = matchDirectoryBots(scoutProject(validated.cwd), await fetchBotDirectory()); + } catch (error) { + // an unreachable directory is a fact of life, not an error — but an + // empty section should still be diagnosable from the server log + console.warn("bot directory lookup failed:", error instanceof Error ? error.message : String(error)); + } + return json(res, 200, { directory }); + } if (method === "POST" && path === "/api/teams/import") { // Import is additive-only. A manifest is untrusted input (catalog, // GitHub, a shared file), so it must be structurally unable to reach @@ -3126,8 +3227,15 @@ const server = createServer(async (req, res) => { if (body[key] !== undefined) patch[key] = body[key]; } if (Array.isArray(body.memberIds)) { - const ids = body.memberIds.filter((id: unknown): id is string => typeof id === "string" && Boolean(store.bot(id))); - if (ids.length) patch.memberIds = ids; + // A DM is the pair it was opened for; only real rooms have a roster. + if (existing.dm) return json(res, 400, { error: "direct-message channels cannot change members" }); + const ids = [ + ...new Set( + body.memberIds.filter((id: unknown): id is string => typeof id === "string" && Boolean(store.bot(id))), + ), + ]; + if (!ids.length) return json(res, 400, { error: "a room needs at least one bot" }); + patch.memberIds = ids; } if (body.defaultResponder !== undefined) { const value = body.defaultResponder as { kind?: unknown; botId?: unknown } | null; @@ -3159,6 +3267,17 @@ const server = createServer(async (req, res) => { patch.pinnedMessageId = body.pinnedMessageId; } else return json(res, 400, { error: "pinnedMessageId must be a message id" }); } + // same contract as a bot's sidebar section: null/"" clears, 60 chars max + if (body.section !== undefined) { + if (body.section === null) patch.section = undefined; + else if (typeof body.section !== "string") return json(res, 400, { error: "section must be a string" }); + else { + const trimmed = body.section.trim(); + if (!trimmed) patch.section = undefined; + else if (trimmed.length > 60) return json(res, 400, { error: "section must be at most 60 characters" }); + else patch.section = trimmed; + } + } const group = store.patchGroup(m[1], patch); if (!group) return json(res, 404, { error: "no such room" }); return json(res, 200, { group }); @@ -3587,8 +3706,8 @@ const server = createServer(async (req, res) => { return json(res, 202, { ok: true, steered: true }); } } - const message = queueSteeredMessage(store, bot, text); - return json(res, 202, { ok: true, queued: true, messageId: message.id }); + const queued = queueSteeredMessage(bot, text); + return json(res, 202, { ok: true, queued: true, queueId: queued.id, threadId: bot.threadId }); } await startTurn(bot.id, text); return json(res, 202, { ok: true }); @@ -3719,6 +3838,74 @@ const server = createServer(async (req, res) => { tasks: store.tasks(bot.id).map(wireTask), }); + // A picker model change is one server-owned transition: resolve the + // stable catalog id to this driver's native id, enforce cached admission, + // persist the selection, then move onto a provider-session-isolated task. + // This avoids a PATCH/POST race where the new task could start with the + // previous engine or model. + m = path.match(/^\/api\/bots\/([\w-]+)\/model$/); + if (m && method === "POST") { + const bot = store.bot(m[1]); + if (!bot) return json(res, 404, { error: "no such bot" }); + if (bot.busy) { + return json(res, 409, { error: "this bot is working — let it finish before changing model" }); + } + const parsedBody = modelSwitchBodySchema.safeParse(await readBody(req)); + if (!parsedBody.success) { + return json(res, 400, { error: "instanceId, model, optional canonicalId, and optional effort must be valid" }); + } + const body = parsedBody.data; + const { instanceId, model: requestedModel, canonicalId } = body; + + if (!cachedInstanceDescriptions) { + return json(res, 409, { error: "engine inventory is not loaded — refresh engines first" }); + } + const instances = projectFleetModels(cachedInstanceDescriptions, fleetModelCatalog.snapshot()); + const target = instances.find((instance) => instance.instanceId === instanceId); + if (!target || target.snapshot.state !== "available" || !registry.get(instanceId)) { + return json(res, 409, { + error: target?.snapshot.reason ?? `provider instance "${instanceId}" is unavailable`, + }); + } + const option = target.models.options.find((candidate) => + canonicalId ? candidate.canonicalId === canonicalId : candidate.id === requestedModel + ); + if (!option) { + return json(res, 400, { error: "that model is not in the cached catalog for this engine" }); + } + if (option.selectable === false) { + return json(res, 409, { error: option.reason ?? "that model is not currently selectable" }); + } + if (body.effort !== undefined) { + const allowed: readonly string[] = target.capabilities.effortLevels ?? []; + if (!allowed.includes(body.effort)) { + return json(res, 400, { error: `effort "${body.effort}" is not offered by this bot's engine` }); + } + } + const selection: ModelSelection = { + instanceId, + model: option.id, + }; + if (body.effort !== undefined) selection.effort = body.effort; + if ( + bot.modelSelection.instanceId === selection.instanceId && + bot.modelSelection.model === selection.model && + bot.modelSelection.effort === selection.effort + ) { + return json(res, 200, { bot: botWithThread(bot), task: null, changed: false }); + } + let transition: ReturnType; + try { + transition = store.switchModelAndCreateTask(bot.id, selection); + } catch { + return json(res, 500, { error: "couldn't create a fresh task for that model" }); + } + if (!transition) return json(res, 404, { error: "no such bot" }); + const fresh = botWithThread(transition.bot); + broadcast({ kind: "bot", bot: fresh }); + return json(res, 201, { bot: fresh, task: wireTask(transition.task), changed: true }); + } + m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/); if (m && method === "POST") { const bot = store.bot(m[1]); @@ -3919,12 +4106,35 @@ const server = createServer(async (req, res) => { // ── provider instances (model picker) ── if (method === "GET" && path === "/api/instances") { - // Rescan PATH first: this endpoint is how the app answers "what can I - // run?", and the interesting case is a CLI installed since launch. - // Windows never pushes PATH changes into a live process, so without - // this the answer is frozen at boot and "check again" is a no-op. - resetPathCache(); - return json(res, 200, { instances: await registry.describe() }); + // Cached by default: opening the model picker or restoring app state + // must not fan out to every CLI/provider. Setup screens opt into a live + // provider refresh with ?refresh=1; the fleet-catalog picker has its own + // file-only POST below. + const refreshModels = url.searchParams.get("refresh") === "1"; + if (refreshModels) resetPathCache(); + const instances = projectFleetModels( + await instanceDescriptions(refreshModels), + fleetModelCatalog.snapshot(), + ); + return json(res, 200, { instances }); + } + + if (method === "GET" && path === "/api/model-catalog") { + return json(res, 200, { catalog: fleetModelCatalog.snapshot() }); + } + + if (method === "POST" && path === "/api/model-catalog/refresh") { + // Reload only the guarded projection. Admission/discovery is owned by + // its producer; this action does not probe a provider or model host. + const catalog = fleetModelCatalog.refresh(); + if (!cachedInstanceDescriptions) { + return json(res, 409, { + error: "engine inventory is not loaded — load instances before refreshing the model catalog", + catalog, + }); + } + const instances = projectFleetModels(cachedInstanceDescriptions, catalog); + return json(res, 200, { catalog, instances }); } // ── CLI binary discovery for the Engines "detected" dropdown ── @@ -3987,7 +4197,9 @@ const server = createServer(async (req, res) => { // from the memoized PATH, so resetting after would answer this request // with the pre-reset cache resetPathCache(); - return json(res, 200, { instances: await registry.describe() }); + return json(res, 200, { + instances: projectFleetModels(await instanceDescriptions(true), fleetModelCatalog.snapshot()), + }); } finally { providerConfigBusy = false; } diff --git a/server/notify.test.ts b/server/notify.test.ts index 69ef7e9a0..002aa7a81 100644 --- a/server/notify.test.ts +++ b/server/notify.test.ts @@ -42,6 +42,15 @@ describe("buildNotification", () => { // that conversation, not whatever the bot happens to be showing expect(buildNotification("done", bot, "other-thread", "done")?.threadId).toBe("other-thread"); }); + + it("carries the bot's avatar when one is given", () => { + const avatarUrl = "/api/attachments/123e4567-e89b-12d3-a456-426614174000.webp"; + const frame = buildNotification("done", bot, "thread-1", "pushed the branch", { avatarUrl }); + expect(frame).toMatchObject({ botId: "bot-1", body: "pushed the branch", avatarUrl }); + + // no profile image → the frame stays exactly as before + expect(buildNotification("done", bot, "thread-1", "pushed")?.avatarUrl).toBeUndefined(); + }); }); describe("summarize", () => { diff --git a/server/notify.ts b/server/notify.ts index cebea1e82..21b312e53 100644 --- a/server/notify.ts +++ b/server/notify.ts @@ -19,6 +19,9 @@ export interface Notification { threadId: string; title: string; body: string; + /** The bot's stored profile image, when it has one; clients show it as + * the OS notification's icon so every banner carries its bot's face. */ + avatarUrl?: string; } /** One line, short enough for a lock screen, with the newlines and code @@ -44,6 +47,7 @@ export function buildNotification( bot: NotifyBot, threadId: string, detail: string, + extra?: { avatarUrl?: string }, ): Notification | null { // The toggle means what it says: off is off, including for approvals. // A bot whose notifications you turned off can still block waiting for @@ -66,5 +70,5 @@ export function buildNotification( // badge in the sidebar already carries that much. if (kind === "done" && !body) return null; - return { kind, botId: bot.id, botName: bot.name, threadId, title, body }; + return { kind, botId: bot.id, botName: bot.name, threadId, title, body, ...extra }; } diff --git a/server/project-scout.test.ts b/server/project-scout.test.ts new file mode 100644 index 000000000..66354fdd1 --- /dev/null +++ b/server/project-scout.test.ts @@ -0,0 +1,131 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { parseTeamManifest } from "./team-manifest.ts"; +import { scoutProject, suggestTeam, type ProjectProfile } from "./project-scout.ts"; + +let dirs: string[] = []; + +function project(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "omb-scout-")); + dirs.push(dir); + for (const [path, content] of Object.entries(files)) { + const full = join(dir, path); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); + } + return dir; +} + +afterEach(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs = []; +}); + +describe("scoutProject", () => { + it("names the project from the README and summarizes its first paragraph", () => { + const dir = project({ + "README.md": "[![ci](x)](y)\n# Maus Tracker\n\nTracks every maus in the house.\n\nMore prose.", + "package.json": JSON.stringify({ name: "not-this", description: "not this either" }), + }); + const profile = scoutProject(dir); + expect(profile.name).toBe("Maus Tracker"); + expect(profile.summary).toBe("Tracks every maus in the house."); + }); + + it("falls back to package name, then folder name", () => { + const fromPkg = scoutProject(project({ "package.json": JSON.stringify({ name: "pkg-name" }) })); + expect(fromPkg.name).toBe("pkg-name"); + const bare = project({}); + expect(scoutProject(bare).name).toBe(basename(bare)); + }); + + it("detects roles from dependencies and folders, with evidence", () => { + const dir = project({ + "package.json": JSON.stringify({ + dependencies: { react: "^19", express: "^5" }, + devDependencies: { vitest: "^3", typescript: "^5" }, + }), + "tsconfig.json": "{}", + "Dockerfile": "FROM node", + "docs/guide.md": "# Guide", + "server/index.ts": "", + }); + const profile = scoutProject(dir); + const roles = profile.signals.map((signal) => signal.role); + expect(roles).toEqual(["frontend", "backend", "testing", "infra", "docs"]); + const backend = profile.signals.find((signal) => signal.role === "backend")!; + expect(backend.evidence).toContain("express"); + expect(backend.evidence).toContain("server/"); + expect(profile.stacks).toEqual(expect.arrayContaining(["TypeScript", "React", "Node", "Docker"])); + }); + + it("does not call an empty docs folder a docs project", () => { + const dir = project({ "docs/image.png": "" }); + expect(scoutProject(dir).signals.find((signal) => signal.role === "docs")).toBeUndefined(); + }); + + it("reads python projects without a package.json", () => { + const dir = project({ + "requirements.txt": "fastapi==0.116\npytest==8.0", + "pyproject.toml": "[project]\nname='svc'", + }); + const profile = scoutProject(dir); + expect(profile.signals.map((signal) => signal.role)).toEqual(["backend", "testing"]); + expect(profile.stacks).toContain("Python"); + }); + + it("survives an unreadable folder and malformed files", () => { + const dir = project({ "package.json": "{not json" }); + expect(scoutProject(dir).signals).toEqual([]); + expect(scoutProject(join(dir, "does-not-exist")).stacks).toEqual([]); + }); +}); + +describe("suggestTeam", () => { + const profile: ProjectProfile = { + name: "Maus Tracker", + summary: "Tracks every maus in the house.", + stacks: ["TypeScript", "React"], + signals: [ + { role: "frontend", evidence: ["react", "vite"] }, + { role: "testing", evidence: ["vitest"] }, + ], + }; + + it("always leads with a lead, then one member per signal, each with a reason", () => { + const suggestion = suggestTeam(profile); + expect(suggestion.roomName).toBe("Maus Tracker"); + expect(suggestion.manifest.team.members.map((member) => member.key)).toEqual(["lead", "frontend", "testing"]); + expect(suggestion.reasons.frontend).toContain("react"); + expect(suggestion.manifest.team.description).toBe(profile.summary); + const frontend = suggestion.manifest.team.members[1]!; + expect(frontend.description).toContain("Maus Tracker"); + expect(frontend.description).toContain("react, vite"); + }); + + it("adds a generalist when nothing was detected", () => { + const suggestion = suggestTeam({ name: "Mystery", summary: "", stacks: [], signals: [] }); + expect(suggestion.manifest.team.members.map((member) => member.key)).toEqual(["lead", "builder"]); + }); + + it("caps the lineup at a lead plus five specialists", () => { + const wide: ProjectProfile = { + ...profile, + signals: (["frontend", "backend", "mobile", "data", "testing", "infra", "docs"] as const).map((role) => ({ + role, + evidence: ["x"], + })), + }; + expect(suggestTeam(wide).manifest.team.members).toHaveLength(6); + }); + + it("emits a manifest the importer accepts verbatim", () => { + const suggestion = suggestTeam(profile); + // the round-trip through the real parser is the contract: a suggestion + // is exactly as importable as a shared team file + expect(() => parseTeamManifest(JSON.parse(JSON.stringify(suggestion.manifest)))).not.toThrow(); + }); +}); diff --git a/server/project-scout.ts b/server/project-scout.ts new file mode 100644 index 000000000..468038eef --- /dev/null +++ b/server/project-scout.ts @@ -0,0 +1,371 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { + parseTeamManifest, + TEAM_MANIFEST_FORMAT, + TEAM_MANIFEST_VERSION, + type TeamManifestMember, + type TeamManifestV2, +} from "./team-manifest.ts"; +import type { MausColor } from "./store.ts"; + +/** What the scout can recognize a project needing. One role becomes one + * suggested team member; the lead is always added on top. */ +export type ScoutRole = + | "frontend" + | "backend" + | "mobile" + | "data" + | "testing" + | "infra" + | "docs"; + +export interface ProjectSignal { + role: ScoutRole; + /** the files and dependencies that argued for this role: shown to the + * human reviewing the suggestion, and quoted in the suggested member's + * description — which becomes persona text and reaches the provider */ + evidence: string[]; +} + +export interface ProjectProfile { + /** README h1 > package name > folder name */ + name: string; + /** README first paragraph > package description > "" */ + summary: string; + /** display chips: languages, frameworks, notable tooling */ + stacks: string[]; + signals: ProjectSignal[]; +} + +export interface TeamSuggestion { + roomName: string; + manifest: TeamManifestV2; + /** member key → the one-line reason it was suggested */ + reasons: Record; +} + +// The scout reads, it never writes — and it reads bounded: a handful of +// well-known files by name, top-level directory listings, and nothing +// recursive. A folder full of surprises must cost milliseconds, not minutes. +const MAX_FILE_BYTES = 256_000; +const MAX_DIR_ENTRIES = 400; + +function readText(path: string): string | null { + try { + if (statSync(path).size > MAX_FILE_BYTES) return null; + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +function listNames(dir: string): string[] { + try { + return readdirSync(dir).slice(0, MAX_DIR_ENTRIES); + } catch { + return []; + } +} + +function isDir(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function packageJson(cwd: string): { name?: string; description?: string; deps: Set } { + const raw = readText(join(cwd, "package.json")); + if (!raw) return { deps: new Set() }; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { deps: new Set() }; + const pkg = parsed as Record; + const deps = new Set(); + for (const field of ["dependencies", "devDependencies"]) { + const block = pkg[field]; + if (block && typeof block === "object" && !Array.isArray(block)) { + for (const dep of Object.keys(block)) deps.add(dep); + } + } + return { + name: typeof pkg.name === "string" ? pkg.name : undefined, + description: typeof pkg.description === "string" ? pkg.description : undefined, + deps, + }; + } catch { + return { deps: new Set() }; + } +} + +/** README h1 and the first prose paragraph after it. Badge rows and heading + * lines are skipped so the summary reads like a sentence, not markup. */ +function readme(cwd: string): { title?: string; summary?: string } { + const raw = + readText(join(cwd, "README.md")) ?? readText(join(cwd, "readme.md")) ?? readText(join(cwd, "README")); + if (!raw) return {}; + let title: string | undefined; + let summary: string | undefined; + for (const line of raw.slice(0, 64_000).split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith("#")) { + if (!title) title = trimmed.replace(/^#+\s*/, "").trim() || undefined; + continue; + } + // badge rows, raw HTML, and blockquote callouts (warnings, notices) are + // not the sentence that says what the project is + if (trimmed.startsWith("[![") || trimmed.startsWith("![") || trimmed.startsWith("<") || trimmed.startsWith(">")) continue; + // the summary is rendered as plain text; markdown emphasis would show + // its asterisks + summary = trimmed.replace(/[*_`]/g, "").slice(0, 1_000); + break; + } + return { title, summary }; +} + +interface Detector { + role: ScoutRole; + deps: string[]; + paths: string[]; +} + +// Order is priority: when a suggestion has to be trimmed, the roles that +// define the project's shape survive over the ones that polish it. +const DETECTORS: Detector[] = [ + { + role: "frontend", + deps: ["react", "vue", "svelte", "next", "nuxt", "astro", "vite", "@angular/core", "solid-js"], + paths: ["index.html", "vite.config.ts", "vite.config.js", "next.config.js", "next.config.ts"], + }, + { + role: "backend", + deps: ["express", "fastify", "koa", "hono", "@nestjs/core", "django", "flask", "fastapi"], + paths: ["server", "api", "go.mod"], + }, + { + role: "mobile", + deps: ["react-native", "expo"], + paths: ["ios", "android", "pubspec.yaml"], + }, + { + role: "data", + deps: ["prisma", "drizzle-orm", "knex", "sequelize", "typeorm", "mongoose", "pg", "mysql2", "better-sqlite3", "sqlalchemy"], + paths: ["prisma", "migrations"], + }, + { + role: "testing", + deps: ["vitest", "jest", "mocha", "@playwright/test", "cypress", "pytest"], + paths: ["test", "tests", "__tests__", "e2e"], + }, + { + role: "infra", + deps: [], + paths: ["Dockerfile", "docker-compose.yml", "docker-compose.yaml", "compose.yaml", ".github/workflows", "terraform", "helm"], + }, + { + role: "docs", + deps: [], + paths: ["docs", "mkdocs.yml"], + }, +]; + +const STACK_MARKERS: Array<{ stack: string; deps?: string[]; paths?: string[] }> = [ + { stack: "TypeScript", deps: ["typescript"], paths: ["tsconfig.json"] }, + { stack: "React", deps: ["react"] }, + { stack: "Vue", deps: ["vue"] }, + { stack: "Svelte", deps: ["svelte"] }, + { stack: "Next.js", deps: ["next"] }, + { stack: "Vite", deps: ["vite"] }, + { stack: "Node", paths: ["package.json"] }, + { stack: "Python", paths: ["pyproject.toml", "requirements.txt", "setup.py"] }, + { stack: "Rust", paths: ["Cargo.toml"] }, + { stack: "Go", paths: ["go.mod"] }, + { stack: "Ruby", paths: ["Gemfile"] }, + { stack: "PHP", paths: ["composer.json"] }, + { stack: "Java", paths: ["pom.xml", "build.gradle", "build.gradle.kts"] }, + { stack: "Docker", paths: ["Dockerfile", "docker-compose.yml", "compose.yaml"] }, +]; + +/** Read a folder and describe the project in it: name, one-line summary, + * stack chips, and which team roles the contents argue for. Deterministic + * and offline — the same folder always scouts to the same profile. */ +export function scoutProject(cwd: string): ProjectProfile { + const pkg = packageJson(cwd); + const md = readme(cwd); + const entries = new Set(listNames(cwd)); + + // Python deps live in files, not a lockfile-adjacent field; a cheap + // substring scan of the two conventional files covers the common cases. + const pythonDeps = `${readText(join(cwd, "requirements.txt")) ?? ""}\n${readText(join(cwd, "pyproject.toml")) ?? ""}`.toLowerCase(); + const hasDep = (dep: string) => pkg.deps.has(dep) || (pythonDeps.length > 1 && pythonDeps.includes(dep)); + + const pathEvidence = (path: string): string | null => { + if (path.includes("/")) return isDir(join(cwd, path)) && listNames(join(cwd, path)).length > 0 ? `${path}/` : null; + if (entries.has(path)) return isDir(join(cwd, path)) ? `${path}/` : path; + return null; + }; + + const signals: ProjectSignal[] = []; + for (const detector of DETECTORS) { + const evidence: string[] = []; + for (const dep of detector.deps) if (hasDep(dep)) evidence.push(dep); + for (const path of detector.paths) { + const found = pathEvidence(path); + if (found) evidence.push(found); + } + // docs need substance: a folder with no markdown in it is not a docs site + if (detector.role === "docs" && evidence.length > 0) { + const hasMkdocs = evidence.includes("mkdocs.yml"); + const hasMarkdown = listNames(join(cwd, "docs")).some((name) => name.endsWith(".md")); + if (!hasMkdocs && !hasMarkdown) continue; + } + if (evidence.length > 0) signals.push({ role: detector.role, evidence: evidence.slice(0, 6) }); + } + + const stacks: string[] = []; + for (const marker of STACK_MARKERS) { + const byDep = marker.deps?.some((dep) => hasDep(dep)) ?? false; + const byPath = marker.paths?.some((path) => entries.has(path)) ?? false; + if (byDep || byPath) stacks.push(marker.stack); + } + + return { + name: (md.title ?? pkg.name ?? basename(cwd)).slice(0, 100), + summary: (md.summary ?? pkg.description ?? "").slice(0, 1_000), + stacks, + signals, + }; +} + +interface RoleTemplate { + name: string; + title: string; + color: MausColor; + describe: (profile: ProjectProfile, evidence: string[]) => string; +} + +const stackLine = (profile: ProjectProfile) => + profile.stacks.length > 0 ? ` The stack: ${profile.stacks.join(", ")}.` : ""; + +const ROLE_TEMPLATES: Record = { + frontend: { + name: "Pixel", + title: "Frontend Builder", + color: "pink", + describe: (profile, evidence) => + `You build and refine the user interface of ${profile.name}.${stackLine(profile)} Your turf shows up as ${evidence.join(", ")}. Keep changes small, match the existing component patterns, and check what you built actually renders before calling it done.`, + }, + backend: { + name: "Forge", + title: "Backend Builder", + color: "blue", + describe: (profile, evidence) => + `You own the server side of ${profile.name}: endpoints, business logic, and the contracts the frontend relies on.${stackLine(profile)} Your turf shows up as ${evidence.join(", ")}. Change behavior only alongside the tests that prove it.`, + }, + mobile: { + name: "Pocket", + title: "Mobile Builder", + color: "coral", + describe: (profile, evidence) => + `You keep ${profile.name} working on phones: screens, navigation, and platform quirks.${stackLine(profile)} Your turf shows up as ${evidence.join(", ")}. Test on both platforms before declaring victory.`, + }, + data: { + name: "Schema", + title: "Data Engineer", + color: "teal", + describe: (profile, evidence) => + `You own the data layer of ${profile.name}: models, migrations, and query performance.${stackLine(profile)} Your turf shows up as ${evidence.join(", ")}. Every migration ships with its rollback story.`, + }, + testing: { + name: "Probe", + title: "Test Engineer", + color: "green", + describe: (profile, evidence) => + `You guard ${profile.name} with tests: you reproduce bugs before they are fixed and extend coverage where changes land.${stackLine(profile)} Your turf shows up as ${evidence.join(", ")}. A red test you wrote is worth more than a green suite nobody trusts.`, + }, + infra: { + name: "Anchor", + title: "Infra & CI", + color: "orange", + describe: (profile, evidence) => + `You keep ${profile.name} buildable, shippable, and observable: CI, containers, and deploy paths.${stackLine(profile)} Your turf shows up as ${evidence.join(", ")}. Prefer boring, reproducible steps over clever ones.`, + }, + docs: { + name: "Quill", + title: "Docs Writer", + color: "purple", + describe: (profile, evidence) => + `You keep the documentation of ${profile.name} truthful and current.${stackLine(profile)} Your turf shows up as ${evidence.join(", ")}. When code and docs disagree, you chase down which one is lying.`, + }, +}; + +const LEAD: RoleTemplate = { + name: "Compass", + title: "Project Lead", + color: "yellow", + describe: (profile) => + `You coordinate work on ${profile.name}: break briefs into tasks for the team, keep the room's bulletin current, and review results before they count as done.${stackLine(profile)}${profile.summary ? ` The project, in its own words: ${profile.summary}` : ""}`, +}; + +/** at most the lead plus this many specialists — a suggestion is a starting + * lineup, not a payroll */ +const MAX_SUGGESTED_SPECIALISTS = 5; + +/** Turn a scouted profile into an importable team: a lead plus one member + * per detected role, as a regular v2 manifest. Suggesting is all this does — + * creating bots and the room stays behind the existing import endpoint and + * its human click. */ +export function suggestTeam(profile: ProjectProfile): TeamSuggestion { + const reasons: Record = { + lead: "Every project room needs one member who briefs, splits, and reviews.", + }; + const members: TeamManifestMember[] = [ + { + key: "lead", + name: LEAD.name, + title: LEAD.title, + description: LEAD.describe(profile, []), + appearance: { color: LEAD.color }, + }, + ]; + for (const signal of profile.signals.slice(0, MAX_SUGGESTED_SPECIALISTS)) { + const template = ROLE_TEMPLATES[signal.role]; + members.push({ + key: signal.role, + name: template.name, + title: template.title, + description: template.describe(profile, signal.evidence), + appearance: { color: template.color }, + }); + reasons[signal.role] = `Detected via ${signal.evidence.join(", ")}.`; + } + // no signals at all → the room still gets a working pair of hands + if (members.length === 1) { + members.push({ + key: "builder", + name: "Wrench", + title: "Builder", + description: `You do the hands-on work in ${profile.name}: read the folder, make the change, show the result.${stackLine(profile)}`, + appearance: { color: "blue" }, + }); + reasons.builder = "No specific stack detected — a generalist covers the ground."; + } + + const manifest: TeamManifestV2 = { + format: TEAM_MANIFEST_FORMAT, + version: TEAM_MANIFEST_VERSION, + team: { + name: `${profile.name} team`.slice(0, 100), + members, + }, + }; + if (profile.summary) manifest.team.description = profile.summary.slice(0, 2_000); + + // Lockstep with import: a suggestion must be exactly as valid as a file + // someone shared — same parser, same limits, same normalization. + return { roomName: profile.name, manifest: parseTeamManifest(manifest), reasons }; +} diff --git a/server/steer-e2e.test.ts b/server/steer-e2e.test.ts index 695513012..b5c017d8e 100644 --- a/server/steer-e2e.test.ts +++ b/server/steer-e2e.test.ts @@ -129,13 +129,10 @@ posixOnly("mid-turn steering e2e", () => { const queued = await api("POST", `/api/bots/${created.id}/messages`, { text: "second" }); expect(queued.status).toBe(202); expect(queued.body.queued).toBe(true); - await waitFor( - async () => (await getBot(created.id)).messages.some((m: any) => m.text === "second" && m.queued === true), - "the queued message to appear", - ); + expect((await getBot(created.id)).messages.some((m: any) => m.text === "second")).toBe(false); await api("POST", `/api/bots/${created.id}/interrupt`); await waitFor( - async () => (await getBot(created.id)).messages.some((m: any) => m.text === "second" && m.queued !== true), + async () => (await getBot(created.id)).messages.some((m: any) => m.text === "second"), "the queued message to begin its turn", ); await api("POST", `/api/bots/${created.id}/interrupt`); diff --git a/server/steer-queue.test.ts b/server/steer-queue.test.ts index 114aff0aa..2dcb1edd5 100644 --- a/server/steer-queue.test.ts +++ b/server/steer-queue.test.ts @@ -63,26 +63,39 @@ function fakeStore(bots: BotRecord[]): SteerStore & { messages: Message[] } { } describe("steer-queue module", () => { - it("appends a queued user message to the thread immediately", () => { + it("does not append a queued user message until drain", () => { const bot = fakeBot("bot-a", "thread-a", true); const store = fakeStore([bot]); - const message = queueSteeredMessage(store, bot, "hold that thought"); - expect(message).toMatchObject({ role: "user", kind: "text", text: "hold that thought", queued: true }); - expect(store.messages).toHaveLength(1); + const queued = queueSteeredMessage(bot, "hold that thought"); + expect(queued).toMatchObject({ id: expect.any(String) }); + expect(store.messages).toHaveLength(0); expect(_queuedCount("thread-a")).toBe(1); - // consume it so module state never leaks into another test - drainSteeredMessages(fakeStore([fakeBot("bot-a", "thread-a", false)]), () => {}); + + bot.busy = false; + const run = vi.fn(); + drainSteeredMessages(store, run); + expect(store.messages).toHaveLength(1); + expect(store.messages[0]).toMatchObject({ + role: "user", + kind: "text", + text: "hold that thought", + queueId: queued.id, + }); + expect(store.messages[0]!.queued).toBeUndefined(); + expect(run).toHaveBeenCalledTimes(1); + expect(_queuedCount("thread-a")).toBe(0); }); it("holds the queue while the bot is busy and drains it once when idle", () => { const bot = fakeBot("bot-b", "thread-b", true); const store = fakeStore([bot]); - queueSteeredMessage(store, bot, "first note"); - queueSteeredMessage(store, bot, "second note"); + const first = queueSteeredMessage(bot, "first note"); + const second = queueSteeredMessage(bot, "second note"); const run = vi.fn(); drainSteeredMessages(store, run); expect(run).not.toHaveBeenCalled(); + expect(store.messages).toHaveLength(0); expect(_queuedCount("thread-b")).toBe(2); bot.busy = false; @@ -93,9 +106,11 @@ describe("steer-queue module", () => { expect(threadId).toBe("thread-b"); // ONE turn for the whole burst: the texts joined with newlines expect(prompt).toBe("first note\nsecond note"); - // the last queued message, so the caller appends nothing new + // appended at drain, last message so startTurn adds nothing new + expect(store.messages.map((m) => m.text)).toEqual(["first note", "second note"]); + expect(store.messages.map((m) => m.queueId)).toEqual([first.id, second.id]); expect(userMessage.text).toBe("second note"); - // the affordance is cleared the moment the queue is consumed + expect(run.mock.calls[0][4]).toEqual(store.messages.map((m) => m.id)); expect(store.messages.every((m) => !m.queued)).toBe(true); expect(_queuedCount("thread-b")).toBe(0); @@ -112,25 +127,13 @@ describe("steer-queue module", () => { it("drops the queue of a deleted bot without running it", () => { const bot = fakeBot("bot-d", "thread-d", true); - const store = fakeStore([bot]); - queueSteeredMessage(store, bot, "orphaned"); + queueSteeredMessage(bot, "orphaned"); const run = vi.fn(); drainSteeredMessages(fakeStore([]), run); expect(run).not.toHaveBeenCalled(); expect(_queuedCount("thread-d")).toBe(0); }); - it("skips the run when the queued messages vanished from the store", () => { - const bot = fakeBot("bot-e", "thread-e", true); - const store = fakeStore([bot]); - queueSteeredMessage(store, bot, "gone soon"); - store.messages.length = 0; // the thread was deleted under the queue - bot.busy = false; - const run = vi.fn(); - drainSteeredMessages(store, run); - expect(run).not.toHaveBeenCalled(); - expect(_queuedCount("thread-e")).toBe(0); - }); }); // ── e2e: the real server on the gated fake ACP fleet ─────────────────── @@ -258,7 +261,7 @@ describe("steer-queue e2e (fake ACP fleet)", () => { expect(first.body.queued).toBeUndefined(); expect((await botById(bot.id)).busy).toBe(true); - // sends while busy land in the transcript at once, marked queued + // sends while busy stay off the transcript so they cannot become the leaf const second = await api("POST", `/api/bots/${bot.id}/messages`, { text: "steer two" }); expect(second.status).toBe(202); expect(second.body).toMatchObject({ ok: true, queued: true }); @@ -267,10 +270,9 @@ describe("steer-queue e2e (fake ACP fleet)", () => { let snapshot = await botById(bot.id); expect(snapshot.busy).toBe(true); - const queuedTexts = snapshot.messages - .filter((m: any) => m.role === "user" && m.queued) - .map((m: any) => m.text); - expect(queuedTexts).toEqual(["steer two", "steer three"]); + expect(snapshot.messages.filter((m: any) => m.role === "user").map((m: any) => m.text)).toEqual([ + "first task please", + ]); expect(echoes(snapshot)).toHaveLength(0); // nothing has answered yet // open the gate: turn 1 settles, and the queue drains into ONE turn @@ -290,7 +292,9 @@ describe("steer-queue e2e (fake ACP fleet)", () => { // framing, no rewind replay wrapper expect(replies[1].text).not.toContain("authenticated external webhook"); expect(replies[1].text).not.toContain("[The user rewound"); - // consumed: the queued affordance is gone from both messages + // drain appends the queued lines after the first turn's reply + const userTexts = snapshot.messages.filter((m: any) => m.role === "user").map((m: any) => m.text); + expect(userTexts).toEqual(["first task please", "steer two", "steer three"]); expect(snapshot.messages.some((m: any) => m.queued)).toBe(false); // an idle send with an empty queue runs one normal turn — the drain diff --git a/server/steer-queue.ts b/server/steer-queue.ts index 1869ee2d0..4f60d1c10 100644 --- a/server/steer-queue.ts +++ b/server/steer-queue.ts @@ -1,17 +1,15 @@ // Queue-and-steer for busy 1:1 bots. // -// A message sent to a bot mid-turn used to bounce with a 409. Now it lands -// in the thread immediately — visible, persisted, marked `queued` — and -// waits here until the bot settles. On settle, every queued message for -// the thread drains into ONE follow-up turn whose prompt is the queued -// texts joined with newlines, so a burst of steering notes costs one turn. +// A message sent to a bot mid-turn used to bounce with a 409. Now it waits +// here until the bot settles, then lands in the thread and runs as ONE +// follow-up turn whose prompt is the queued texts joined with newlines. // -// The queue itself is memory-only on purpose: each queued message is -// already an ordinary persisted thread message, so a restart loses only -// the "auto-run on settle" intent, never the words — the same honesty as -// delegations and provider approvals, which also die with the process. -// (The client renders the queued affordance only while the bot is busy, so -// a flag stranded by a restart is invisible rather than a false promise.) +// The queue is memory-only and is NOT in `messages[]` while the current +// turn is running: appending immediately would make the queued line the +// active leaf, so remaining tool/assistant events of *this* turn would +// hang off a user line the model has not seen. Restart loses the queue +// (same as delegations / approvals). The composer shows a pending chip +// until drain appends the words. // // Unlike the delegation drain, an interrupted or failed turn does NOT // discard this queue: delegations are a bot's fan-out (dropping them on @@ -19,6 +17,7 @@ // stop-then-steer (queue a correction, hit Stop, the correction runs) is // the feature. +import { newId } from "./contracts.ts"; import type { BotRecord, Message } from "./store.ts"; /** The slice of Store this module needs — narrow so tests can fake it. */ @@ -38,26 +37,32 @@ interface QueueEntry { const queues = new Map(); // threadId → waiting sends -/** Land a message in the busy bot's active thread now; it auto-sends when - * the turn settles. The `queued` flag is the transcript's "will send when - * this turn finishes" affordance — drain clears it when consumed. */ -export function queueSteeredMessage(store: SteerStore, bot: BotRecord, text: string): Message { +/** Hold a mid-turn send off the transcript until drain. */ +export function queueSteeredMessage(bot: BotRecord, text: string): { id: string } { const threadId = bot.threadId; - const message = store.appendMessage(threadId, { role: "user", kind: "text", text, queued: true }); + const id = newId(); const entry = queues.get(threadId) ?? { botId: bot.id, items: [] }; - entry.items.push({ messageId: message.id, text }); + entry.items.push({ messageId: id, text }); queues.set(threadId, entry); - return message; + return { id }; } -/** Drain every queue whose bot is idle: one run per thread, prompt = the - * queued texts joined with newlines. `userMessage` is the last queued - * message so the caller's startTurn appends nothing new — the messages are - * already in the transcript. Entries are removed BEFORE running so a - * settle racing another settle can never fire the same queue twice. */ +/** Drain every queue whose bot is idle: append the held lines (leaf is now + * the finished turn's last item), then one run per thread whose prompt is + * the texts joined with newlines. `userMessage` is the last appended line + * so startTurn does not duplicate it; `excludeIds` is every drained line + * so transcript-replay adapters do not also see earlier queued texts. + * Entries leave the map BEFORE running so a settle racing another settle + * can never fire the same queue twice. */ export function drainSteeredMessages( store: SteerStore, - run: (botId: string, threadId: string, prompt: string, userMessage: Message) => void | Promise, + run: ( + botId: string, + threadId: string, + prompt: string, + userMessage: Message, + excludeIds: string[], + ) => void | Promise, ): void { // deleting only the entry being visited is safe under Map iteration for (const [threadId, entry] of queues) { @@ -71,17 +76,29 @@ export function drainSteeredMessages( // committed to draining: the entry leaves the map before anything runs, // so a settle racing another settle can never fire the same queue twice queues.delete(threadId); - // clear the affordance before dispatch, so the user never sees - // "queued" on a message the bot is already answering - let last: Message | null = null; + const appended: Message[] = []; for (const item of entry.items) { - last = store.patchMessage(threadId, item.messageId, { queued: undefined }) ?? last; + // queueId is the pending-chip identity from the 202; append still + // assigns a fresh transcript id so replay/exclude keep using message.id. + appended.push( + store.appendMessage(threadId, { + role: "user", + kind: "text", + text: item.text, + queueId: item.messageId, + }), + ); } - // every queued message gone from the store = the thread itself was - // deleted out from under the queue; there is nothing to run against + const last = appended.at(-1); if (!last) continue; const prompt = entry.items.map((item) => item.text).join("\n"); - void run(entry.botId, threadId, prompt, last); + void run( + entry.botId, + threadId, + prompt, + last, + appended.map((message) => message.id), + ); } } diff --git a/server/store.ts b/server/store.ts index 449694b24..cf69a3da4 100644 --- a/server/store.ts +++ b/server/store.ts @@ -103,6 +103,9 @@ export interface Message { * them; a true stranded by a restart is inert because the client only * shows the affordance while the bot is busy. */ queued?: boolean; + /** steer-queue entry this drained user line came from. The client pending + * chip matches on this id, not on equal text. Absent on ordinary sends. */ + queueId?: string; } export type GroupDefaultResponder = @@ -139,6 +142,9 @@ export interface GroupRecord { /** the one message pinned to the top of this room's transcript. A pin id * that no longer resolves (edited away, deleted) simply renders nothing. */ pinnedMessageId?: string; + /** sidebar section heading this room is filed under; shares the bots' + * namespace so one heading can hold a project's room and its people */ + section?: string; } /** One task = one conversation with its own context. @@ -585,7 +591,7 @@ export class Store { ); } - patchGroup(id: string, patch: Partial>): GroupRecord | null { + patchGroup(id: string, patch: Partial>): GroupRecord | null { const group = this.group(id); if (!group) return null; Object.assign(group, patch); @@ -1003,6 +1009,44 @@ export class Store { return task; } + /** Change the engine/model and open its session-isolated task as one + * persisted transition. A failed atomic write restores the exact in-memory + * bot so callers never observe a selection without its matching task. */ + switchModelAndCreateTask( + botId: string, + selection: ModelSelection, + ): { bot: BotRecord; task: TaskRecord } | null { + const bot = this.bot(botId); + if (!bot) return null; + const task: TaskRecord = { + threadId: newId(), + title: UNTITLED_TASK, + createdAt: Date.now(), + resumeCursors: {}, + }; + const previous = { + modelSelection: bot.modelSelection, + tasks: bot.tasks, + threadId: bot.threadId, + resumeCursors: bot.resumeCursors, + }; + bot.modelSelection = { ...selection }; + bot.tasks = [task, ...(bot.tasks ?? [])]; + bot.threadId = task.threadId; + bot.resumeCursors = {}; + try { + this.saveBots(); + } catch (error) { + bot.modelSelection = previous.modelSelection; + bot.tasks = previous.tasks; + bot.threadId = previous.threadId; + bot.resumeCursors = previous.resumeCursors; + throw error; + } + this.emit({ type: "bot", botId }); + return { bot, task }; + } + switchTask(botId: string, threadId: string): BotRecord | null { const bot = this.bot(botId); const task = bot?.tasks?.find((t) => t.threadId === threadId); diff --git a/server/tasks.test.ts b/server/tasks.test.ts index 6c0142679..302b8658b 100644 --- a/server/tasks.test.ts +++ b/server/tasks.test.ts @@ -51,6 +51,48 @@ describe("tasks", () => { expect(store.messagesFor(firstThread).length).toBeGreaterThan(0); }); + it("keeps a changed model on the fresh task without carrying its provider session", async () => { + const { store } = await freshStore(); + const bot = store.createBot(); + const firstThread = bot.threadId; + store.setResumeCursor(bot.id, "claude", "old-provider-session"); + const changes: string[] = []; + store.onChange((change) => { + if (change.type === "bot") changes.push(change.botId); + }); + + const transition = store.switchModelAndCreateTask(bot.id, { + instanceId: "hermes", + model: "litellm-local:minimax-m3-light", + })!; + const task = transition.task; + expect(task.threadId).not.toBe(firstThread); + expect(store.bot(bot.id)?.modelSelection).toEqual({ + instanceId: "hermes", + model: "litellm-local:minimax-m3-light", + }); + expect(store.activeTask(bot.id)?.resumeCursors).toEqual({}); + expect(store.taskByThread(bot.id, firstThread)?.resumeCursors.claude).toBe("old-provider-session"); + expect(changes).toEqual([bot.id]); + }); + + it("rolls back the model and active task when atomic persistence fails", async () => { + const { store } = await freshStore(); + const bot = store.createBot(); + const before = structuredClone(bot); + // The test instance is discarded after this case; replace its runtime + // persistence seam without widening the private Store type. + Reflect.set(store, "saveBots", () => { + throw new Error("disk unavailable"); + }); + + expect(() => store.switchModelAndCreateTask(bot.id, { + instanceId: "hermes", + model: "litellm-local:MiniMax-M3", + })).toThrow("disk unavailable"); + expect(store.bot(bot.id)).toEqual(before); + }); + it("can create a detached routine task without changing the visible conversation", async () => { const { store } = await freshStore(); const bot = store.createBot(); diff --git a/server/testing/fake-acp-cli.ts b/server/testing/fake-acp-cli.ts index ddf42d92b..dfab1b73d 100755 --- a/server/testing/fake-acp-cli.ts +++ b/server/testing/fake-acp-cli.ts @@ -6,6 +6,7 @@ // turn. Failure modes mirror how real ACP agents misbehave: // // FAKE_ACP_MODE happy (default) | empty-reply | exit-early | fail-after-text | hang | no-auth | auth-required | permission +// | interleave (message → tool → message → tool → message) // | no-session-config (reject session/set_mode + set_model // with -32601, i.e. an agent predating those methods) // | ask-peer (spawn the injected "agents" MCP server from @@ -54,6 +55,20 @@ const configOptions = () => }, ] : null; +// cursor-shaped surface: the session advertises `models.availableModels` with +// parameterised ids (`default[]`) that differ from the argv `--model` slugs +// (`auto`). Off unless FAKE_ACP_SESSION_MODELS is set, so every existing mode +// stays byte-identical. Format: "id|Name,id|Name" — the name is optional. +const acpModels = (process.env.FAKE_ACP_SESSION_MODELS ?? "") + .split(",") + .filter(Boolean) + .map((entry) => { + const [modelId, name] = entry.split("|"); + return name ? { modelId, name } : { modelId }; + }); +const sessionModels = () => + acpModels.length ? { currentModelId: acpModels[0].modelId, availableModels: acpModels } : null; + const argv = process.argv.slice(2); const dumpEnv = Object.fromEntries( [ @@ -188,6 +203,17 @@ function playTurn() { out({ jsonrpc: "2.0", method: "session/update", params: { update: { sessionUpdate: "tool_call_update", toolCallId: "tc-1", status: "completed" } } }); } +/** Scripted text → tool → text → tool → text turn for order-contract tests. */ +function playInterleaveTurn() { + out({ jsonrpc: "2.0", method: "session/update", params: { update: { sessionUpdate: "agent_message_chunk", content: { text: "before one" } } } }); + out({ jsonrpc: "2.0", method: "session/update", params: { update: { sessionUpdate: "tool_call", toolCallId: "tc-1", title: "run" } } }); + out({ jsonrpc: "2.0", method: "session/update", params: { update: { sessionUpdate: "tool_call_update", toolCallId: "tc-1", status: "completed" } } }); + out({ jsonrpc: "2.0", method: "session/update", params: { update: { sessionUpdate: "agent_message_chunk", content: { text: "before two" } } } }); + out({ jsonrpc: "2.0", method: "session/update", params: { update: { sessionUpdate: "tool_call", toolCallId: "tc-2", title: "run" } } }); + out({ jsonrpc: "2.0", method: "session/update", params: { update: { sessionUpdate: "tool_call_update", toolCallId: "tc-2", status: "completed" } } }); + out({ jsonrpc: "2.0", method: "session/update", params: { update: { sessionUpdate: "agent_message_chunk", content: { text: "after" } } } }); +} + let buf = ""; process.stdin.on("data", (c) => { buf += c; @@ -248,12 +274,18 @@ function handle(msg: any) { writeFileSync(`${process.env.FAKE_ACP_DUMP}.mcp.json`, JSON.stringify(servers, null, 2)); } const opts = configOptions(); - result(msg.id, opts ? { sessionId: "fake-acp-session", configOptions: opts } : { sessionId: "fake-acp-session" }); + const mdls = sessionModels(); + result(msg.id, { + sessionId: "fake-acp-session", + ...(opts ? { configOptions: opts } : {}), + ...(mdls ? { models: mdls } : {}), + }); break; } case "session/load": { const opts = configOptions(); - result(msg.id, opts ? { configOptions: opts } : {}); + const mdls = sessionModels(); + result(msg.id, { ...(opts ? { configOptions: opts } : {}), ...(mdls ? { models: mdls } : {}) }); break; } // per-session settings (droid sets model/autonomy here, not via argv). @@ -266,6 +298,11 @@ function handle(msg: any) { // an older agent that predates these methods return out({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not found" } }); } + if (mode === "set-model-invalid-params" && msg.method === "session/set_model") { + // an agent whose ACP model namespace does not contain the id it was + // sent — Cursor's answer when handed an argv slug like `auto`. + return out({ jsonrpc: "2.0", id: msg.id, error: { code: -32602, message: "Invalid params" } }); + } const settingId = msg.method === "session/set_mode" ? "modeId" : "modelId"; if (typeof msg.params?.sessionId !== "string" || typeof msg.params?.[settingId] !== "string") { out({ @@ -394,7 +431,8 @@ function handle(msg: any) { }); return; } - if (mode !== "empty-reply") playTurn(); + if (mode === "interleave") playInterleaveTurn(); + else if (mode !== "empty-reply") playTurn(); if (mode === "permission") { // ask the client to approve a tool, then complete once answered pendingPermissionId = 9001; diff --git a/server/testing/fake-codex-app-server.ts b/server/testing/fake-codex-app-server.ts index d1017c928..da8730dba 100755 --- a/server/testing/fake-codex-app-server.ts +++ b/server/testing/fake-codex-app-server.ts @@ -5,7 +5,7 @@ // real app-server, it never exits on its own — the driver kills it. // // FAKE_CODEX_MODE happy (default) | approval | resume | stream | windows-command | -// logged-in-stdout | logged-out | unauthorized +// mcp-elicitation | logged-in-stdout | logged-out | unauthorized // FAKE_CODEX_DUMP path to write {argv, env, calls, decision} as JSON // // Keep this file dependency-free — it runs as a bare `node` subprocess. @@ -73,7 +73,7 @@ process.stdin.on("data", (chunk) => { } // response to our own server->client request (approval decision) - if (msg.id === 100 && (msg.result !== undefined || msg.error !== undefined)) { + if ((msg.id === 100 || msg.id === 101) && (msg.result !== undefined || msg.error !== undefined)) { decision = msg.result ?? { error: msg.error }; finishTurn(); continue; @@ -143,7 +143,20 @@ process.stdin.on("data", (chunk) => { : "ls -la"; notify("item/started", { item: { id: "i1", type: "commandExecution", command } }); notify("item/started", { item: { id: "w1", type: "webSearch", query: "OpenMausBot" } }); - if (mode === "approval" || mode === "windows-command") { + if (mode === "mcp-elicitation") { + out({ + jsonrpc: "2.0", + id: 101, + method: "mcpServer/elicitation/request", + params: { + serverName: "agents", + mode: "form", + _meta: { codex_approval_kind: "mcp_tool_call", tool_params: {} }, + message: 'Allow the agents MCP server to run tool "list_bots"?', + requestedSchema: { type: "object", properties: {} }, + }, + }); + } else if (mode === "approval" || mode === "windows-command") { const approvalCommand = mode === "windows-command" ? command : "rm -rf scratch"; out({ jsonrpc: "2.0", id: 100, method: "execCommandApproval", params: { command: approvalCommand } }); // turn continues from the approval response handler above diff --git a/server/testing/fake-pi-cli.ts b/server/testing/fake-pi-cli.ts index f0ed26ed8..7e7de7af5 100755 --- a/server/testing/fake-pi-cli.ts +++ b/server/testing/fake-pi-cli.ts @@ -5,7 +5,7 @@ // / set_model, and streams a scripted turn in response to `prompt`. Failure // modes mirror how the real CLI misbehaves: // -// FAKE_PI_MODE happy (default) | tooluse | permission | no-models | exit-early +// FAKE_PI_MODE happy (default) | tooluse | permission | interleave | no-models | exit-early // FAKE_PI_MODELS comma-separated provider/model pairs (default "ollama-cloud/glm-5.2,openai/gpt-4o") // FAKE_PI_DUMP path to append {argv, env} JSON, so a test can assert argv shape // and env hygiene (no leaked secrets into the pi child). @@ -91,6 +91,21 @@ const streamPermissionTurn = () => { // wait for the answer before finishing }; +/** Scripted text → tool → text → tool → text turn for order-contract tests. */ +const streamInterleaveTurn = () => { + send({ type: "agent_start" }); + send({ type: "turn_start" }); + send({ type: "message_update", usage: { input: 0, output: 0 }, assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "before one" } }); + send({ type: "tool_execution_start", toolCallId: "call_1", toolName: "bash", args: { command: "echo one" } }); + send({ type: "tool_execution_end", toolCallId: "call_1", toolName: "bash", isError: false }); + send({ type: "message_update", usage: { input: 0, output: 0 }, assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "before two" } }); + send({ type: "tool_execution_start", toolCallId: "call_2", toolName: "bash", args: { command: "echo two" } }); + send({ type: "tool_execution_end", toolCallId: "call_2", toolName: "bash", isError: false }); + send({ type: "message_update", usage: { input: 0, output: 0 }, assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "after" } }); + send({ type: "turn_end", message: { stopReason: "end_turn", usage: { input: 12, output: 3 } }, usage: { input: 12, output: 3 } }); + send({ type: "agent_end" }); +}; + const finishPermissionTurn = () => { send({ type: "tool_execution_start", toolCallId: "call_1", toolName: "bash", args: { command: "echo hi" } }); send({ type: "tool_execution_end", toolCallId: "call_1", toolName: "bash", isError: false }); @@ -147,6 +162,7 @@ function handle(cmd: any) { send({ type: "response", command: "prompt", success: true }); if (mode === "tooluse") streamToolTurn(); else if (mode === "permission") streamPermissionTurn(); + else if (mode === "interleave") streamInterleaveTurn(); else streamTurn(); return; case "extension_ui_response": diff --git a/src/components/ApiKeys.tsx b/src/components/ApiKeys.tsx index 835d757e9..9411b9821 100644 --- a/src/components/ApiKeys.tsx +++ b/src/components/ApiKeys.tsx @@ -101,7 +101,7 @@ function CredentialHelp({ section }: { section: ConfigSection }) { aria-expanded={open} aria-controls={popoverId} onClick={() => setOpen((current) => !current)} - className="flex size-6 items-center justify-center rounded-md text-ink-secondary outline-none transition-colors hover:bg-raised hover:text-ink focus-visible:ring-2 focus-visible:ring-accent/70" + className="flex size-6 items-center justify-center rounded-md text-ink-secondary outline-none transition-colors hover:bg-control hover:text-ink focus-visible:ring-2 focus-visible:ring-accent/70" >