diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8f77f32..7eb808f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -19,9 +19,11 @@ tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } -# Used directly for the sidecar-startup read timeout (tauri's async runtime is -# tokio, but a transitive dep is not usable directly). -tokio = { version = "1", features = ["time"] } +# Used directly for the sidecar-startup read timeout and for the oneshot +# channel that bridges the non-blocking folder picker's callback back to the +# async `pick_directory` command (tauri's async runtime is tokio, but a +# transitive dep is not usable directly). +tokio = { version = "1", features = ["time", "sync"] } [features] # default to a production build without devtools diff --git a/desktop/src-tauri/src/repo.rs b/desktop/src-tauri/src/repo.rs index a6522ec..7568c6f 100644 --- a/desktop/src-tauri/src/repo.rs +++ b/desktop/src-tauri/src/repo.rs @@ -13,13 +13,29 @@ use std::process::Command; use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; +use tokio::sync::oneshot; /// Opens the native folder picker and returns the chosen absolute path (or -/// `None` if the user cancelled). Blocking is fine here: Tauri runs command -/// handlers off the main thread, and the picker is modal by design. +/// `None` if the user cancelled). +/// +/// This command MUST be `async`. Tauri runs synchronous command handlers on the +/// main (UI) thread, and the native folder dialog needs that same thread's event +/// loop to pump. Calling the *blocking* picker from a sync command therefore +/// deadlocks: the main thread blocks waiting for a dialog that can never run, +/// freezing the whole app. Instead we use the non-blocking `pick_folder`, which +/// shows the dialog on the main thread and invokes our callback when the user +/// chooses; the callback hands the result back over a oneshot channel that this +/// async handler (running off the main thread) awaits. #[tauri::command] -pub fn pick_directory(app: AppHandle) -> Result, String> { - let picked = app.dialog().file().blocking_pick_folder(); +pub async fn pick_directory(app: AppHandle) -> Result, String> { + let (tx, rx) = oneshot::channel(); + app.dialog().file().pick_folder(move |picked| { + // The receiver is only dropped if this command was cancelled; ignore. + let _ = tx.send(picked); + }); + let picked = rx + .await + .map_err(|_| "folder picker was cancelled".to_string())?; Ok(picked .and_then(|p| p.into_path().ok()) .map(|p| p.to_string_lossy().to_string())) @@ -27,8 +43,11 @@ pub fn pick_directory(app: AppHandle) -> Result, String> { /// True when `path` is inside a git working tree. Mirrors the engine's /// `isGitRepo` so the UI can validate a selection before a run is started. +/// +/// `async` so the `git` subprocess runs off the main thread and never stalls the +/// UI (e.g. on a cold filesystem cache or a slow/networked working copy). #[tauri::command] -pub fn check_git_repo(path: String) -> bool { +pub async fn check_git_repo(path: String) -> bool { Command::new("git") .args(["-C", &path, "rev-parse", "--is-inside-work-tree"]) .output() @@ -38,9 +57,12 @@ pub fn check_git_repo(path: String) -> bool { /// For each requested tool name, reports whether it is found on PATH. Used by /// the provider-onboarding panel to show "installed / missing" for tools like -/// `codex`, `kiro`, and `gh`. +/// `codex`, `kiro-cli`, and `gh`. +/// +/// `async` so the PATH scan (filesystem stats per candidate) runs off the main +/// thread and keeps the UI responsive. #[tauri::command] -pub fn which_commands(names: Vec) -> HashMap { +pub async fn which_commands(names: Vec) -> HashMap { names .into_iter() .map(|n| { diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 20a5a4d..26cc12c 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -18,15 +18,15 @@ import { startRun, } from "./api.js"; import { - MODEL_CATALOG, + PRESETS, buildRunEnv, - editsFiles, - findModel, + getPreset, loadSettings, modelLabel, saveSettings, usesAdvancedRunners, type ModelChoice, + type RunnerPreset, } from "./settings.js"; import type { RunMessage, SessionRecord, TraceResponse } from "./types.js"; @@ -346,9 +346,9 @@ async function renderModels(): Promise { ); view.append(page); - // Availability: HTTP providers are unlocked by a stored API key (Tauri - // keychain); CLI agents by an installed command. In a browser we can't see - // either, so nothing is gated there. + // Availability: Responses presets are unlocked by a stored API key (Tauri + // keychain); CLI presets by an installed command (and, for Kiro, a key too). + // In a browser we can't see either, so nothing is gated there. let storedKeys = new Set(); let knowKeys = false; let installed: Record = {}; @@ -360,70 +360,111 @@ async function renderModels(): Promise { /* unknown — show everything as selectable */ } try { - installed = await detectCommands(["codex", "kiro", "gh"]); + installed = await detectCommands(["codex", "kiro-cli", "gh"]); } catch { - /* unknown — don't flag CLI agents as missing */ + /* unknown — don't flag CLI presets as missing */ } } - function providerMissing(p: (typeof MODEL_CATALOG)[number]): boolean { - if (p.kind === "cli") return isTauri() && installed[p.command ?? ""] !== true; - return knowKeys && !storedKeys.has(p.apiKeyEnv ?? ""); + /** Whether a preset's command is known-missing (desktop only). */ + function commandMissing(p: RunnerPreset): boolean { + return p.kind === "cli" && isTauri() && installed[p.command ?? ""] !== true; } - function providerMissingLabel(p: (typeof MODEL_CATALOG)[number]): string { - return p.kind === "cli" ? `install ${p.command}` : `add ${p.apiKeyEnv}`; + /** Whether a preset's required API key is known-missing. */ + function keyMissing(p: RunnerPreset): boolean { + const env = p.kind === "http-responses" ? p.apiKeyEnv : p.requiresEnv; + return env !== undefined && knowKeys && !storedKeys.has(env); } + function presetMissing(p: RunnerPreset): boolean { + return commandMissing(p) || keyMissing(p); + } + function presetMissingLabel(p: RunnerPreset): string { + if (commandMissing(p)) return `install ${p.command}`; + const env = p.kind === "http-responses" ? p.apiKeyEnv : p.requiresEnv; + return `add ${env}`; + } + + // One picker per role: a preset dropdown + (for configurable presets) an + // editable model id. Replaces the old provider/model matrix with the + // first-class presets. + function rolePicker(role: "writer" | "reviewer", note: HTMLElement): HTMLElement { + const wrap = h("div", { class: "preset-pick" }); + const select = h("select", { "aria-label": `${role} preset` }) as HTMLSelectElement; + for (const preset of PRESETS) { + const missing = presetMissing(preset); + const opt = h("option", { value: preset.id }, [ + missing ? `${preset.label} (${presetMissingLabel(preset)})` : preset.label, + ]) as HTMLOptionElement; + if (settings[role].preset === preset.id) opt.selected = true; + select.append(opt); + } - function modelSelect(role: "writer" | "reviewer", onChange: () => void): HTMLSelectElement { - const current = settings[role]; - const select = h("select", { "aria-label": `${role} model` }) as HTMLSelectElement; - for (const provider of MODEL_CATALOG) { - const missing = providerMissing(provider); - const group = h("optgroup", { label: missing ? `${provider.label} (${providerMissingLabel(provider)})` : provider.label }) as HTMLOptGroupElement; - for (const m of provider.models) { - const opt = h("option", { value: `${provider.id}::${m.id}` }, [m.label]) as HTMLOptionElement; - if (current.provider === provider.id && current.model === m.id) opt.selected = true; - group.append(opt); + const modelInput = h("input", { + type: "text", + class: "model-id", + "aria-label": `${role} model id`, + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + + function syncModelInput(): void { + const preset = getPreset(settings[role].preset); + if (preset?.configurableModel) { + modelInput.hidden = false; + modelInput.placeholder = preset.defaultModel; + modelInput.value = settings[role].model || preset.defaultModel; + } else { + modelInput.hidden = true; } - select.append(group); } + select.addEventListener("change", () => { - const [p, m] = select.value.split("::"); - settings[role] = { provider: p!, model: m! } as ModelChoice; + // Reset the model to the new preset's default when switching presets. + settings[role] = { preset: select.value, model: "" } as ModelChoice; + persist(); + syncModelInput(); + setNote(note, role); + }); + modelInput.addEventListener("input", () => { + settings[role] = { preset: settings[role].preset, model: modelInput.value.trim() }; persist(); - onChange(); + setNote(note, role); }); - return select; + + syncModelInput(); + wrap.append(select, modelInput); + return wrap; } function noteFor(role: "writer" | "reviewer", choice: ModelChoice): HTMLElement { const note = h("div", { class: "model-note" }); - const found = findModel(choice); - if (!found) return note; - const { provider } = found; - if (provider.kind === "http" && knowKeys && !storedKeys.has(provider.apiKeyEnv ?? "")) { + const preset = getPreset(choice.preset); + if (!preset) return note; + + if (keyMissing(preset)) { + const env = preset.kind === "http-responses" ? preset.apiKeyEnv : preset.requiresEnv; note.className = "model-note warn"; - const link = h("button", { type: "button", class: "linklike" }, [`Add ${provider.apiKeyEnv}`]); + const link = h("button", { type: "button", class: "linklike" }, [`Add ${env}`]); link.addEventListener("click", () => navigate("secrets")); - note.append(h("span", {}, [`No key stored for ${provider.label}. `]), link); + note.append(h("span", {}, [`No key stored for ${preset.label}. `]), link); return note; } - if (provider.kind === "cli" && isTauri() && installed[provider.command ?? ""] !== true) { + if (commandMissing(preset)) { note.className = "model-note warn"; - note.append(h("span", {}, [`${provider.command} is not installed — install it to use ${provider.label}.`])); + note.append(h("span", {}, [`${preset.command} is not installed — install it to use ${preset.label}.`])); return note; } if (role === "writer") { - if (editsFiles(choice)) { + if (preset.editsFiles) { note.className = "model-note ok"; - note.append(h("span", {}, ["Edits files directly — runs can produce real, committable changes."])); + note.append(h("span", {}, [`${preset.authHint} Edits files directly — runs can produce real, committable changes.`])); } else { note.className = "model-note warn"; - note.append(h("span", {}, ["HTTP models return a diff but don't edit files. Pick a CLI writer (Codex / Kiro) to change a repo."])); + note.append(h("span", {}, ["Returns a diff but doesn't edit files. Pick a CLI writer (Codex CLI / Kiro CLI) to change a repo."])); } return note; } - note.append(h("span", {}, [provider.kind === "http" ? `Uses ${provider.apiKeyEnv}` : `Local command: ${provider.command}`])); + note.append(h("span", {}, [preset.authHint])); return note; } @@ -434,8 +475,8 @@ async function renderModels(): Promise { target.className = fresh.className; target.replaceChildren(...Array.from(fresh.childNodes)); } - const writerSelect = modelSelect("writer", () => setNote(writerNote, "writer")); - const reviewerSelect = modelSelect("reviewer", () => setNote(reviewerNote, "reviewer")); + const writerSelect = rolePicker("writer", writerNote); + const reviewerSelect = rolePicker("reviewer", reviewerNote); setNote(writerNote, "writer"); setNote(reviewerNote, "reviewer"); @@ -485,24 +526,42 @@ async function renderModels(): Promise { ]), ); + // -- Kiro CLI trusted tools (only meaningful for the kiro-cli preset). + const kiroTrustInput = h("input", { + type: "text", + value: settings.kiroTrustTools, + placeholder: "fs_read,fs_write,execute_bash (empty = --trust-all-tools)", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + kiroTrustInput.addEventListener("input", () => { + settings.kiroTrustTools = kiroTrustInput.value; + persist(); + }); + const kiroTrustField = h("label", { class: "inline-label" }, [ + "Kiro CLI trusted tools", + kiroTrustInput, + ]); + const modelsCard = h("div", { class: "card" }, [ - h("h3", {}, ["Models"]), + h("h3", {}, ["Runner presets"]), h("div", { class: "model-grid" }, [ h("div", { class: "model-pick" }, [ - h("label", { class: "model-pick-head" }, [icon(ICONS.write, "mp-ico"), "Writer", h("span", { class: "desc" }, ["Writes the code"])]), + h("label", { class: "model-pick-head" }, [icon(ICONS.write, "mp-ico"), "Writer", h("span", { class: "desc" }, ["Writes the code (actor)"])]), writerSelect, writerNote, ]), h("div", { class: "model-pick" }, [ - h("label", { class: "model-pick-head" }, [icon(ICONS.review, "mp-ico"), "Reviewer", h("span", { class: "desc" }, ["Reviews the code"])]), + h("label", { class: "model-pick-head" }, [icon(ICONS.review, "mp-ico"), "Reviewer", h("span", { class: "desc" }, ["Reviews the code (critic)"])]), reviewerSelect, reviewerNote, ]), ]), + kiroTrustField, h("div", { class: "desc keys-hint" }, [ isTauri() - ? "HTTP models are unlocked by the API keys you store under Secrets; CLI agents must be installed locally." - : "HTTP models are unlocked by the API keys provided in the engine's environment.", + ? "Codex CLI / Kiro CLI must be installed locally; the OpenAI Responses preset and Kiro CLI need their API keys stored under Secrets." + : "API-key presets are unlocked by the keys provided in the engine's environment; CLI presets must be installed on the engine host.", ]), advancedDetails, ]); @@ -669,7 +728,7 @@ async function renderModels(): Promise { const envPanel = h("div", { class: "env-checks" }); if (isTauri()) { envPanel.append(h("div", { class: "desc" }, ["Detected tools:"])); - for (const name of ["codex", "kiro", "gh"]) { + for (const name of ["codex", "kiro-cli", "gh"]) { const ok = installed[name] === true; envPanel.append(h("span", { class: `tool ${ok ? "ok" : "missing"}` }, [`${name}: ${ok ? "installed" : "missing"}`])); } @@ -686,7 +745,144 @@ async function renderModels(): Promise { envPanel, ]); - page.append(repoCard, modelsCard, optionsCard, publishCard); + // -- Phone updates (Telegram). Desktop-only: stores the bot token + chat id + // as secrets the engine reads at startup. The engine connects OUTBOUND to + // Telegram (long-polling), so nothing inbound is ever exposed. + const tgCard = h("div", { class: "card" }, [ + h("h3", {}, ["Phone updates (Telegram)"]), + h("div", { class: "desc" }, [ + "Get a message when a run finishes, and reply with a new goal to keep the engine working. The engine only makes outbound calls to Telegram — it stays loopback-only and is never exposed.", + ]), + ]); + if (!isTauri()) { + tgCard.append( + h("div", { class: "desc" }, [ + "Desktop only. In a browser, set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in the engine's environment.", + ]), + ); + } else { + let hasToken = storedKeys.has("TELEGRAM_BOT_TOKEN"); + let hasChat = storedKeys.has("TELEGRAM_CHAT_ID"); + const tgStatus = h("div", { class: "hint" }); + const paintTgStatus = (): void => { + tgStatus.textContent = `Bot token: ${hasToken ? "stored" : "not set"} · Chat ID: ${hasChat ? "stored" : "not set"}`; + }; + paintTgStatus(); + const tokenInput = h("input", { + type: "password", + autocomplete: "off", + placeholder: hasToken ? "•••••• stored — leave blank to keep" : "123456:ABC-DEF… (from @BotFather)", + }) as HTMLInputElement; + const chatInput = h("input", { + type: "text", + autocomplete: "off", + spellcheck: "false", + placeholder: hasChat ? "stored — leave blank to keep" : "your numeric chat id", + }) as HTMLInputElement; + const tgError = h("div", { class: "hint", hidden: "true" }); + const saveBtn = h("button", { type: "button", class: "primary" }, ["Save & restart engine"]); + const removeBtn = h("button", { type: "button", class: "ghost" }, ["Remove"]); + + // Re-reads the keychain index so the cached has* flags + status reflect what + // is actually stored after a mutation, rather than an optimistic guess. + const refreshTgStored = async (): Promise => { + try { + const keys = new Set(await listSecretKeys()); + hasToken = keys.has("TELEGRAM_BOT_TOKEN"); + hasChat = keys.has("TELEGRAM_CHAT_ID"); + } catch { + /* leave cached flags as-is if the index can't be read */ + } + paintTgStatus(); + }; + + saveBtn.addEventListener("click", async () => { + tgError.hidden = true; + const tok = tokenInput.value.trim(); + const chat = chatInput.value.trim(); + // Both secrets are required for the relay to work — count an already-stored + // value as satisfied so a user editing one field need not re-enter both. + if (!tok && !hasToken) { + tgError.textContent = "Enter a bot token from @BotFather."; + tgError.className = "hint error"; + tgError.hidden = false; + return; + } + if (!chat && !hasChat) { + tgError.textContent = "Enter your chat id (message @userinfobot to find it)."; + tgError.className = "hint error"; + tgError.hidden = false; + return; + } + if (chat && !/^-?\d+$/.test(chat)) { + tgError.textContent = "Chat ID must be a number (message @userinfobot to find yours)."; + tgError.className = "hint error"; + tgError.hidden = false; + return; + } + saveBtn.setAttribute("disabled", "true"); + saveBtn.textContent = "Saving…"; + try { + if (tok) await setSecret("TELEGRAM_BOT_TOKEN", tok); + if (chat) await setSecret("TELEGRAM_CHAT_ID", chat); + tokenInput.value = ""; + chatInput.value = ""; + const restarted = await restartEngineGuarded(); + await refreshTgStored(); + tgError.textContent = restarted + ? "Saved. The relay will start polling for your messages." + : "Saved — restart the engine (Secrets page) to apply."; + tgError.className = "hint ok"; + tgError.hidden = false; + } catch (err) { + await refreshTgStored(); + tgError.textContent = `Failed: ${(err as Error).message}`; + tgError.className = "hint error"; + tgError.hidden = false; + } finally { + saveBtn.removeAttribute("disabled"); + saveBtn.textContent = "Save & restart engine"; + } + }); + + removeBtn.addEventListener("click", async () => { + tgError.hidden = true; + removeBtn.setAttribute("disabled", "true"); + try { + await deleteSecret("TELEGRAM_BOT_TOKEN"); + await deleteSecret("TELEGRAM_CHAT_ID"); + const restarted = await restartEngineGuarded(); + await refreshTgStored(); + tgError.textContent = restarted + ? "Telegram notifications disabled." + : "Removed — restart the engine (Secrets page) to apply."; + tgError.className = "hint"; + tgError.hidden = false; + } catch (err) { + await refreshTgStored(); + tgError.textContent = `Failed: ${(err as Error).message}`; + tgError.className = "hint error"; + tgError.hidden = false; + } finally { + removeBtn.removeAttribute("disabled"); + } + }); + + tgCard.append( + tgStatus, + h("div", { class: "field-grid" }, [ + h("label", { class: "inline-label" }, ["Bot token", tokenInput]), + h("label", { class: "inline-label" }, ["Chat ID", chatInput]), + ]), + h("div", { class: "actions" }, [saveBtn, removeBtn]), + tgError, + h("small", { class: "desc" }, [ + "Saving restarts the engine to apply (you'll be warned if runs are active). The token + chat id are stored in your OS keychain.", + ]), + ); + } + + page.append(repoCard, modelsCard, optionsCard, publishCard, tgCard); } // --- Monitor view ----------------------------------------------------------- @@ -1166,25 +1362,25 @@ async function renderSecrets(): Promise { const listEl = h("ul", { class: "secret-list" }); card.append(h("h3", {}, ["Stored keys"]), listEl); - // Models-by-key: makes it explicit which models each environment key unlocks, - // so the user can see exactly what a key buys them before (or after) adding it. - const catalogCard = h("div", { class: "card" }, [h("h3", {}, ["Models by environment key"])]); + // Presets-by-key: makes it explicit which environment key unlocks which + // preset, so the user can see exactly what a key buys them before/after adding it. + const catalogCard = h("div", { class: "card" }, [h("h3", {}, ["Presets by environment key"])]); const catalogList = h("div", { class: "key-catalog" }); catalogCard.append(catalogList); function renderCatalog(stored: Set): void { catalogList.innerHTML = ""; - for (const provider of MODEL_CATALOG) { - if (provider.kind !== "http" || !provider.apiKeyEnv) continue; // CLI agents have no key - const has = stored.has(provider.apiKeyEnv); + for (const preset of PRESETS) { + const env = preset.kind === "http-responses" ? preset.apiKeyEnv : preset.requiresEnv; + if (!env) continue; // e.g. codex-cli authenticates via `codex login` + const has = stored.has(env); const head = h("div", { class: "key-cat-head" }, [ icon(ICONS.key, "key-cat-ico"), - h("code", {}, [provider.apiKeyEnv]), - h("span", { class: "key-cat-prov" }, [provider.label]), + h("code", {}, [env]), + h("span", { class: "key-cat-prov" }, [preset.label]), h("span", { class: `key-cat-status ${has ? "on" : "off"}` }, [has ? "stored" : "not stored"]), ]); - const models = h("div", { class: "key-cat-models" }); - for (const m of provider.models) models.append(h("span", { class: "model-tag" }, [m.label])); + const models = h("div", { class: "key-cat-models" }, [h("span", { class: "desc" }, [preset.description])]); catalogList.append(h("div", { class: `key-cat${has ? " on" : ""}` }, [head, models])); } } @@ -1339,6 +1535,30 @@ function escapeHtml(s: string): string { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); } +/** + * Restarts the engine, but first warns if runs are active (a restart re-spawns + * the sidecar and ABORTS in-flight runs). Returns true if the restart actually + * happened. Shared by the Secrets screen and the Telegram settings card so both + * use the same guard rather than silently killing runs. + */ +async function restartEngineGuarded(): Promise { + let active = 0; + try { + active = await activeRunCount(); + } catch { + /* unknown — treat as nothing to lose */ + } + if (active > 0) { + const ok = window.confirm( + `${active} run${active === 1 ? " is" : "s are"} still active. ` + + `Restarting the engine will abort ${active === 1 ? "it" : "them"}. Continue?`, + ); + if (!ok) return false; + } + await restartEngine(); + return true; +} + async function checkEngine(): Promise { const ok = await health(); engineLabel.textContent = ok ? "engine connected" : "engine offline"; diff --git a/desktop/src/settings.ts b/desktop/src/settings.ts index 7f1c2d6..39bb2f6 100644 --- a/desktop/src/settings.ts +++ b/desktop/src/settings.ts @@ -1,124 +1,124 @@ /** - * Run settings + the model catalog. + * Run settings + the runner-preset catalog. * * The engine itself is vendor-neutral: a run is configured purely by env vars * (LOOPWRIGHT_RUNNERS + role bindings + publishing flags) plus an optional * repo path. This module is the UI-side source of truth that turns a friendly - * "which model writes / which model reviews" choice into those env vars, and + * "which preset writes / which preset reviews" choice into those env vars, and * remembers every run-shaping preference between runs so the New run screen can * stay a minimal goal box while nothing is lost. * - * HTTP models are grouped by the *environment key* (API key env var) that - * unlocks them; CLI agents (Codex, Kiro) are grouped by the local command that - * must be installed. CLI actors are the ones that actually edit files on disk — - * HTTP runners only return a diff the engine does not apply. + * The catalog is a small, opinionated set of first-class RUNNER PRESETS rather + * than a generic provider/model matrix: + * + * - codex-cli `codex exec --json --model ` — a local CLI agent + * that edits files; best with a ChatGPT/Codex entitlement + * or an OpenAI key. + * - openai-responses the OpenAI Responses API (`/v1/responses`) for a model + * id like gpt-5.5 — returns a diff, does not edit files. + * - kiro-cli `kiro-cli chat --no-interactive --trust-tools=…` with + * KIRO_API_KEY — a local CLI agent that edits files. + * + * CLI presets are the ones that actually edit files on disk; the Responses + * preset only returns a diff the engine does not apply. */ -export type ProviderKind = "http" | "cli"; +/** Wire format each preset maps to (matches the engine's RunnerProfile.kind). */ +export type PresetKind = "cli" | "http-responses"; -export interface CatalogModel { - /** model id sent to the provider (the runner profile's `model`) */ +export interface RunnerPreset { + /** stable preset id, e.g. "codex-cli" */ id: string; - /** human label shown in the UI */ + /** display name */ label: string; -} + kind: PresetKind; + /** one-line description shown under the picker */ + description: string; + /** default model id (the run uses this unless the user overrides it) */ + defaultModel: string; + /** whether the user can edit the model id (false for account-driven CLIs) */ + configurableModel: boolean; + /** true for presets that edit files directly (can produce real changes) */ + editsFiles: boolean; + /** how this preset authenticates, shown in the UI */ + authHint: string; -export interface Provider { - /** stable provider id, e.g. "openai" or "codex" */ - id: string; - /** display name, e.g. "OpenAI" */ - label: string; - kind: ProviderKind; - /** http: OpenAI-compatible base URL the HttpRunner targets */ + /** http-responses: base URL the ResponsesRunner targets */ baseUrl?: string; - /** http: env var name that holds this provider's API key */ + /** http-responses: request path (default "/responses") */ + path?: string; + /** http-responses: env var holding the API key (also used for auth status) */ apiKeyEnv?: string; + /** cli: the command that must be on PATH (used for install detection) */ command?: string; - /** true for CLI actors that edit files directly (can produce real changes) */ - editsFiles?: boolean; - /** models this provider offers */ - models: CatalogModel[]; + /** an env var the preset needs to authenticate (used for auth status) */ + requiresEnv?: string; } /** - * Real providers and models. HTTP providers are keyed by the environment - * variable that holds the API key; CLI providers are local file-editing agents. - * No placeholder/demo entries: every option here is something a user can - * actually run once the matching key is stored or the command is installed. + * The three first-class presets. Every entry is something a user can actually + * run once the matching key is stored and/or the command is installed. */ -export const MODEL_CATALOG: Provider[] = [ - { - id: "openai", - label: "OpenAI", - kind: "http", - baseUrl: "https://api.openai.com/v1", - apiKeyEnv: "OPENAI_API_KEY", - models: [ - { id: "gpt-4o", label: "GPT-4o" }, - { id: "gpt-4o-mini", label: "GPT-4o mini" }, - { id: "gpt-4.1", label: "GPT-4.1" }, - { id: "gpt-4.1-mini", label: "GPT-4.1 mini" }, - { id: "o3", label: "o3" }, - { id: "o4-mini", label: "o4-mini" }, - ], - }, - { - id: "anthropic", - label: "Anthropic", - kind: "http", - baseUrl: "https://api.anthropic.com/v1", - apiKeyEnv: "ANTHROPIC_API_KEY", - models: [ - { id: "claude-3-7-sonnet-latest", label: "Claude 3.7 Sonnet" }, - { id: "claude-3-5-sonnet-latest", label: "Claude 3.5 Sonnet" }, - { id: "claude-3-5-haiku-latest", label: "Claude 3.5 Haiku" }, - ], - }, +export const PRESETS: RunnerPreset[] = [ { - id: "google", - label: "Google", - kind: "http", - baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", - apiKeyEnv: "GEMINI_API_KEY", - models: [ - { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, - { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, - { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, - ], - }, - { - id: "codex", + id: "codex-cli", label: "Codex CLI", kind: "cli", - command: "codex", + description: "Runs `codex exec --json --model ` locally and edits files directly.", + defaultModel: "gpt-5.5", + configurableModel: true, editsFiles: true, - models: [{ id: "codex", label: "Codex (edits files)" }], + authHint: "Sign in with `codex login` (ChatGPT Business / Codex) or set OPENAI_API_KEY.", + command: "codex", }, { - id: "kiro", + id: "openai-responses", + label: "OpenAI Responses API", + kind: "http-responses", + description: "Calls the OpenAI Responses API (/v1/responses). Returns a diff; does not edit files.", + defaultModel: "gpt-5.5", + configurableModel: true, + editsFiles: false, + authHint: "Needs OPENAI_API_KEY stored under Secrets.", + baseUrl: "https://api.openai.com/v1", + path: "/responses", + apiKeyEnv: "OPENAI_API_KEY", + }, + { + id: "kiro-cli", label: "Kiro CLI", kind: "cli", - command: "kiro", + description: "Runs `kiro-cli chat --no-interactive` headlessly and edits files directly.", + defaultModel: "", + configurableModel: false, editsFiles: true, - models: [{ id: "kiro", label: "Kiro (edits files)" }], + authHint: "Needs the kiro-cli command installed and KIRO_API_KEY stored under Secrets.", + command: "kiro-cli", + requiresEnv: "KIRO_API_KEY", }, ]; -/** A concrete model choice: which provider + which model id. */ +/** A concrete preset choice: which preset + (for configurable presets) model id. */ export interface ModelChoice { - provider: string; + preset: string; model: string; } export interface RunSettings { /** absolute path of the local git repo the run builds against ("" = engine cwd) */ repo: string; - /** model that writes the code (actor role) */ + /** preset that writes the code (actor role) */ writer: ModelChoice; - /** model that reviews the code (critic role) */ + /** preset that reviews the code (critic role) */ reviewer: ModelChoice; + /** + * Tools kiro-cli is trusted with in headless mode (`--trust-tools=`). + * Empty falls back to `--trust-all-tools`. Only used by the kiro-cli preset. + */ + kiroTrustTools: string; + /** how many tasks run at once */ maxParallel: number; /** isolate each task in its own git worktree */ @@ -157,15 +157,16 @@ export interface RunSettings { advancedCritic: string; } -const STORAGE_KEY = "loopwright.runSettings.v1"; - -/** First provider that has at least one model — the baseline for defaults. */ -const firstProvider = MODEL_CATALOG[0]!; +const STORAGE_KEY = "loopwright.runSettings.v2"; +/** Prior storage key; read once on upgrade so saved prefs aren't lost. */ +const LEGACY_STORAGE_KEY = "loopwright.runSettings.v1"; export const DEFAULT_SETTINGS: RunSettings = { repo: "", - writer: { provider: firstProvider.id, model: "gpt-4o-mini" }, - reviewer: { provider: firstProvider.id, model: "gpt-4o" }, + // Writer edits files -> a CLI preset. Reviewer just reads -> Responses is fine. + writer: { preset: "codex-cli", model: "gpt-5.5" }, + reviewer: { preset: "openai-responses", model: "gpt-5.5" }, + kiroTrustTools: "fs_read,fs_write,execute_bash", maxParallel: 2, worktrees: true, mechanicalGate: true, @@ -184,26 +185,31 @@ export const DEFAULT_SETTINGS: RunSettings = { advancedCritic: "", }; -export function getProvider(id: string): Provider | undefined { - return MODEL_CATALOG.find((p) => p.id === id); -} - -export function findModel(choice: ModelChoice): { provider: Provider; model: CatalogModel } | undefined { - const provider = getProvider(choice.provider); - if (!provider) return undefined; - const model = provider.models.find((m) => m.id === choice.model); - if (!model) return undefined; - return { provider, model }; +export function getPreset(id: string): RunnerPreset | undefined { + return PRESETS.find((p) => p.id === id); } -/** True when the choice resolves to a CLI agent that edits files on disk. */ +/** True when the choice resolves to a CLI preset that edits files on disk. */ export function editsFiles(choice: ModelChoice): boolean { - return getProvider(choice.provider)?.editsFiles === true; + return getPreset(choice.preset)?.editsFiles === true; } -/** Just the model label, e.g. "GPT-4o mini" (falls back to the raw id). */ +/** + * A short, human label for a choice: the model id for presets with a + * configurable model (e.g. "gpt-5.5"), otherwise the preset label. + */ export function modelLabel(choice: ModelChoice): string { - return findModel(choice)?.model.label ?? choice.model ?? "—"; + const preset = getPreset(choice.preset); + if (!preset) return choice.model || "—"; + if (preset.configurableModel) return (choice.model || preset.defaultModel) || preset.label; + return preset.label; +} + +/** The effective model id a choice will run with (falls back to the default). */ +export function effectiveModel(choice: ModelChoice): string { + const preset = getPreset(choice.preset); + if (!preset) return choice.model; + return preset.configurableModel ? (choice.model || preset.defaultModel) : preset.defaultModel; } /** True when an advanced runner-profile override is active. */ @@ -211,24 +217,46 @@ export function usesAdvancedRunners(settings: RunSettings): boolean { return settings.advancedRunners.trim() !== ""; } +/** Normalizes a possibly-legacy/partial choice into a valid preset choice. */ +function coerceChoice(value: unknown, fallback: ModelChoice): ModelChoice { + if (value === null || typeof value !== "object") return { ...fallback }; + const v = value as Record; + // Tolerate the legacy { provider, model } shape by mapping provider->preset. + const presetId = typeof v.preset === "string" ? v.preset : typeof v.provider === "string" ? v.provider : ""; + const preset = getPreset(presetId); + if (!preset) return { ...fallback }; + const model = typeof v.model === "string" ? v.model : ""; + return { preset: preset.id, model }; +} + /** Loads saved settings, falling back to defaults and tolerating bad/legacy data. */ export function loadSettings(): RunSettings { try { - const raw = localStorage.getItem(STORAGE_KEY); + let raw = localStorage.getItem(STORAGE_KEY); + // Upgrade path: if there's no v2 payload yet, migrate the v1 one so a user's + // repo / publishing / run preferences survive. The provider->preset mapping + // happens in coerceChoice; everything else carries over by key. The migrated + // result is resaved under the v2 key below so this only runs once. + let migrating = false; + if (!raw) { + const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); + if (legacy) { + raw = legacy; + migrating = true; + } + } if (!raw) return { ...DEFAULT_SETTINGS }; - const parsed = JSON.parse(raw) as Partial; + const parsed = JSON.parse(raw) as Partial & Record; const merged: RunSettings = { ...DEFAULT_SETTINGS, ...parsed, - writer: { ...DEFAULT_SETTINGS.writer, ...(parsed.writer ?? {}) }, - reviewer: { ...DEFAULT_SETTINGS.reviewer, ...(parsed.reviewer ?? {}) }, + writer: coerceChoice(parsed.writer, DEFAULT_SETTINGS.writer), + reviewer: coerceChoice(parsed.reviewer, DEFAULT_SETTINGS.reviewer), }; - // Coerce persisted scalars: tampered or legacy localStorage could hold the - // wrong type (e.g. maxParallel "abc", worktrees null), which would otherwise - // flow into buildRunEnv() and fail run startup with invalid config. const str = (v: unknown, d: string): string => (typeof v === "string" ? v : d); const bool = (v: unknown, d: boolean): boolean => (typeof v === "boolean" ? v : d); merged.repo = str(merged.repo, DEFAULT_SETTINGS.repo); + merged.kiroTrustTools = str(merged.kiroTrustTools, DEFAULT_SETTINGS.kiroTrustTools); merged.maxParallel = Number.isFinite(merged.maxParallel) && merged.maxParallel >= 1 ? Math.floor(merged.maxParallel) @@ -248,10 +276,9 @@ export function loadSettings(): RunSettings { merged.advancedRunners = str(merged.advancedRunners, DEFAULT_SETTINGS.advancedRunners); merged.advancedActor = str(merged.advancedActor, DEFAULT_SETTINGS.advancedActor); merged.advancedCritic = str(merged.advancedCritic, DEFAULT_SETTINGS.advancedCritic); - // Drop selections that no longer exist in the catalog so the UI never shows - // a stale/removed model as active. - if (!findModel(merged.writer)) merged.writer = { ...DEFAULT_SETTINGS.writer }; - if (!findModel(merged.reviewer)) merged.reviewer = { ...DEFAULT_SETTINGS.reviewer }; + // Persist the migrated payload under the new key so the legacy read only + // ever happens once. + if (migrating) saveSettings(merged); return merged; } catch { return { ...DEFAULT_SETTINGS }; @@ -269,40 +296,57 @@ export function saveSettings(settings: RunSettings): void { /** A runner profile as accepted by LOOPWRIGHT_RUNNERS. */ interface RunnerProfile { id: string; - kind: ProviderKind; + kind: "cli" | "http-responses"; model: string; options: Record; } -function profileFor(id: string, choice: ModelChoice): RunnerProfile { - const provider = getProvider(choice.provider) ?? firstProvider; - if (provider.kind === "cli") { - if (provider.id === "kiro") { - return { - id, - kind: "cli", - model: "", - options: { command: "kiro", args: ["--headless", "--prompt", "{{prompt}}"], promptVia: "arg", output: { mode: "last-line" } }, - }; - } - // Codex (default CLI shape) +function profileFor(settings: RunSettings, id: string, choice: ModelChoice): RunnerProfile { + const preset = getPreset(choice.preset) ?? getPreset(DEFAULT_SETTINGS.writer.preset)!; + + if (preset.id === "kiro-cli") { + const trust = settings.kiroTrustTools.trim(); + const trustArg = trust ? `--trust-tools=${trust}` : "--trust-all-tools"; return { id, kind: "cli", model: "", + options: { + command: "kiro-cli", + args: ["chat", "--no-interactive", trustArg, "{{prompt}}"], + promptVia: "arg", + // Headless Kiro prints the assistant's answer to stdout; the engine's + // JSON extraction tolerates surrounding log lines for structured calls. + output: { mode: "stdout" }, + }, + }; + } + + if (preset.kind === "cli") { + // codex-cli + return { + id, + kind: "cli", + model: effectiveModel(choice), options: { command: "codex", - args: ["exec", "--json", "{{prompt}}"], + args: ["exec", "--json", "--model", "{{model}}", "{{prompt}}"], promptVia: "arg", output: { mode: "json-stream", textPath: "msg.text", typeField: "type", type: "item.completed" }, }, }; } + + // openai-responses return { id, - kind: "http", - model: choice.model, - options: { baseUrl: provider.baseUrl, apiKeyEnv: provider.apiKeyEnv }, + kind: "http-responses", + model: effectiveModel(choice), + options: { + baseUrl: preset.baseUrl, + path: preset.path ?? "/responses", + apiKeyEnv: preset.apiKeyEnv, + }, }; } @@ -323,7 +367,10 @@ export function buildRunEnv(settings: RunSettings): Record { actor = settings.advancedActor.trim(); critic = settings.advancedCritic.trim(); } else { - runnersJson = JSON.stringify([profileFor("writer", settings.writer), profileFor("reviewer", settings.reviewer)]); + runnersJson = JSON.stringify([ + profileFor(settings, "writer", settings.writer), + profileFor(settings, "reviewer", settings.reviewer), + ]); actor = "writer"; critic = "reviewer"; } diff --git a/src/notify/telegram.ts b/src/notify/telegram.ts new file mode 100644 index 0000000..893dbda --- /dev/null +++ b/src/notify/telegram.ts @@ -0,0 +1,291 @@ +/** + * Telegram notify + control relay (loopback-safe). + * + * This is the "phone updates" layer. It does TWO things, both over OUTBOUND + * HTTPS to api.telegram.org — it never opens an inbound port, so the engine + * stays loopback-only and is never exposed publicly: + * + * 1. Push: when a run finishes, send the final status (and key links such as + * a PR url) to a single allowlisted Telegram chat. + * 2. Control: long-poll Telegram for replies from that same chat. A message + * is treated as a new goal and submitted as a run — reusing the most + * recent run's preset/repo configuration — so the user can keep the engine + * working from their phone. Only messages from the configured chat id are + * ever acted on. + * + * The Telegram transport (`fetch`) and the run submitter are injected, so the + * relay is unit-testable without a network or a live engine. + */ + +import type { RunFinishedInfo, StartRunBody, SubmitResult } from "../server/server.js"; + +export type { RunFinishedInfo, SubmitResult }; + +/** The slice of the server the relay needs to drive runs from chat messages. */ +export interface RunSubmitter { + submitRun(body: StartRunBody): Promise; + /** the most recent run's env + repo, reused for chat-initiated runs */ + lastRunConfig(): { env: Record; repoDir?: string } | undefined; +} + +/** Minimal fetch shape the relay depends on (global fetch satisfies it). */ +export type TelegramFetch = ( + url: string, + init?: { method?: string; headers?: Record; body?: string; signal?: AbortSignal }, +) => Promise<{ ok: boolean; status: number; json(): Promise; text(): Promise }>; + +export interface TelegramConfig { + /** bot token from @BotFather */ + botToken: string; + /** the single chat id allowed to receive notifications and issue commands */ + chatId: string; + /** Telegram Bot API base (override for tests); default https://api.telegram.org */ + apiBase?: string; + /** injectable transport; defaults to the global fetch */ + fetchImpl?: TelegramFetch; + log?: (line: string) => void; + /** + * When true (default), the first poll discards any backlog so the relay never + * acts on messages sent before it started. Tests set false to act immediately. + */ + skipBacklog?: boolean; + /** long-poll timeout in seconds for getUpdates (default 50) */ + pollTimeoutSec?: number; +} + +/** Builds the final-status message for a finished run. Exported for tests. */ +export function formatFinalStatus(info: RunFinishedInfo): string { + const goalLine = `“${info.goal}”`; + if (info.phase === "error") { + return [`❌ Loopwright run failed`, goalLine, info.error ?? "unknown error", `session ${info.sessionId}`].join("\n"); + } + + const r = info.result; + const needsHuman = r?.needsHuman.length ?? 0; + const integrationBad = Boolean(r?.integration && r.integration.ok === false); + const head = needsHuman > 0 || integrationBad ? "⚠️ Loopwright: needs attention" : "✅ Loopwright: completed"; + + const lines: string[] = [head, goalLine]; + if (r) { + lines.push(`green ${r.green.length} · unverified ${r.unverified.length} · needs-human ${r.needsHuman.length}`); + if (r.integration) { + lines.push(`integration: ${r.integration.ok ? "merged + verified" : "failed (conflicts or verification)"}`); + } + if (r.publish?.pushed) lines.push(`pushed → ${r.publish.remote}/${r.publish.pushBranch}`); + if (r.publish?.pr?.url) lines.push(`PR: ${r.publish.pr.url}`); + } + lines.push(`session ${info.sessionId}`); + if (needsHuman > 0 || integrationBad) { + lines.push("Reply with a new goal to keep me working."); + } + return lines.join("\n"); +} + +interface TgUpdate { + update_id: number; + message?: { text?: string; chat?: { id?: number | string } }; +} + +const HELP_TEXT = + "Loopwright relay\n" + + "Send any message and I'll run it as a new goal, reusing the repo + presets " + + "from your last run. Commands: /help."; + +export class TelegramRelay { + private readonly botToken: string; + private readonly chatId: string; + private readonly apiBase: string; + private readonly fetchImpl: TelegramFetch; + private readonly log: (line: string) => void; + private readonly pollTimeoutSec: number; + + private submitter: RunSubmitter | undefined; + private offset: number | undefined; + private primed: boolean; + private running = false; + private abort: AbortController | undefined; + + constructor(cfg: TelegramConfig) { + this.botToken = cfg.botToken; + this.chatId = String(cfg.chatId); + this.apiBase = (cfg.apiBase ?? "https://api.telegram.org").replace(/\/$/, ""); + const globalFetch = (globalThis as { fetch?: TelegramFetch }).fetch; + const resolved = cfg.fetchImpl ?? globalFetch; + if (!resolved) throw new Error("TelegramRelay has no fetch implementation available."); + this.fetchImpl = resolved; + this.log = cfg.log ?? (() => {}); + this.pollTimeoutSec = cfg.pollTimeoutSec ?? 50; + // primed=true means "act on the next batch immediately" (no backlog skip). + this.primed = cfg.skipBacklog === false; + } + + /** Supplies the run submitter (the engine server). Call before start(). */ + attach(submitter: RunSubmitter): void { + this.submitter = submitter; + } + + /** Notifier hook: invoked by the server when a run reaches a terminal state. */ + runFinished(info: RunFinishedInfo): void { + void this.sendMessage(formatFinalStatus(info)).catch((err) => + this.log(`telegram: failed to send final status: ${String((err as Error)?.message ?? err)}`), + ); + } + + /** Begins the long-poll control loop (does nothing if already running). */ + start(): void { + if (this.running) return; + this.running = true; + this.log("telegram: relay started (long-polling for messages)"); + void this.loop(); + } + + /** Stops the control loop and aborts any in-flight long poll. */ + stop(): void { + this.running = false; + this.abort?.abort(); + } + + private async loop(): Promise { + while (this.running) { + try { + await this.pollOnce(); + } catch (err) { + if (!this.running) break; + this.log(`telegram: poll error: ${String((err as Error)?.message ?? err)}`); + await delay(3000); + } + } + } + + /** + * Performs one getUpdates long-poll and handles the returned messages. The + * first batch is discarded (offset advanced only) unless backlog handling was + * enabled, so the relay never acts on messages sent before it started. + */ + async pollOnce(): Promise { + const updates = await this.getUpdates(); + const handleNow = this.primed; + for (const u of updates) { + // Acknowledge (advance the offset past) an update only AFTER it is handled + // successfully. If handling throws, leave it unacked and stop so Telegram + // re-delivers it on the next poll rather than silently dropping it. + if (handleNow) { + try { + await this.handleUpdate(u); + } catch (err) { + this.log( + `telegram: failed to handle update ${u.update_id}: ${String((err as Error)?.message ?? err)}`, + ); + break; + } + } + this.offset = u.update_id + 1; + } + this.primed = true; + } + + /** Acts on a single update: ignores other chats; runs the text as a goal. */ + async handleUpdate(u: TgUpdate): Promise { + const msg = u.message; + const text = msg?.text?.trim(); + if (!text) return; + // Allowlist: only the configured chat may receive output or issue commands. + if (String(msg?.chat?.id ?? "") !== this.chatId) { + this.log(`telegram: ignoring message from unauthorized chat ${String(msg?.chat?.id)}`); + return; + } + + if (text === "/start" || text === "/help") { + await this.sendMessage(HELP_TEXT); + return; + } + + if (!this.submitter) { + await this.sendMessage("Relay is not ready yet — try again in a moment."); + return; + } + const last = this.submitter.lastRunConfig(); + if (!last) { + await this.sendMessage( + "I don't have a run configuration yet. Start one run from the desktop app first, then I'll reuse its repo + presets for messages from here.", + ); + return; + } + + const result = await this.submitter.submitRun({ + goal: text, + env: last.env, + ...(last.repoDir ? { repoDir: last.repoDir } : {}), + }); + if (result.ok) { + await this.sendMessage(`▶️ Starting run\n“${text}”\nsession ${result.sessionId}`); + } else { + await this.sendMessage(`Couldn't start that run (${result.status}): ${result.error}`); + } + } + + /** Long-polls Telegram for new updates. Returns [] on a non-ok response. */ + private async getUpdates(): Promise { + this.abort = new AbortController(); + const params = new URLSearchParams({ + timeout: String(this.pollTimeoutSec), + allowed_updates: JSON.stringify(["message"]), + }); + if (this.offset !== undefined) params.set("offset", String(this.offset)); + const url = `${this.apiBase}/bot${this.botToken}/getUpdates?${params.toString()}`; + const res = await this.fetchImpl(url, { method: "GET", signal: this.abort.signal }); + if (!res.ok) { + // Throw so the loop applies its backoff: a persistent error (e.g. 401 on a + // bad token) would otherwise spin tightly, hammering Telegram. + throw new Error(`getUpdates returned ${res.status}`); + } + const body = (await res.json()) as { ok?: boolean; result?: TgUpdate[] }; + return Array.isArray(body.result) ? body.result : []; + } + + /** Sends a text message to the configured chat. Resolves true on success. */ + async sendMessage(text: string): Promise { + const url = `${this.apiBase}/bot${this.botToken}/sendMessage`; + const res = await this.fetchImpl(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ chat_id: this.chatId, text, disable_web_page_preview: true }), + }); + if (!res.ok) { + this.log(`telegram: sendMessage returned ${res.status}: ${await safeText(res)}`); + return false; + } + return true; + } +} + +/** + * Builds a relay from the engine environment, or returns undefined when Telegram + * is not configured. Accepts both the explicit LOOPWRIGHT_TELEGRAM_* names and + * the plain TELEGRAM_* secret names (which the desktop shell can store and + * inject without the reserved LOOPWRIGHT_ prefix). + */ +export function createTelegramRelayFromEnv( + env: Record, + log?: (line: string) => void, +): TelegramRelay | undefined { + const botToken = env.LOOPWRIGHT_TELEGRAM_BOT_TOKEN || env.TELEGRAM_BOT_TOKEN; + const chatId = env.LOOPWRIGHT_TELEGRAM_CHAT_ID || env.TELEGRAM_CHAT_ID; + if (!botToken || !chatId) return undefined; + return new TelegramRelay({ botToken, chatId, ...(log ? { log } : {}) }); +} + +async function safeText(res: { text(): Promise }): Promise { + try { + return await res.text(); + } catch { + return ""; + } +} + +async function delay(ms: number): Promise { + return new Promise((resolve) => { + const t = setTimeout(resolve, ms); + if (typeof t.unref === "function") t.unref(); + }); +} diff --git a/src/runners/agentRunner.ts b/src/runners/agentRunner.ts index adf658f..e731e72 100644 --- a/src/runners/agentRunner.ts +++ b/src/runners/agentRunner.ts @@ -8,16 +8,17 @@ * * Adding a new backend = implement AgentRunner. Concrete runners are named by * their MECHANISM, never by a product/vendor: - * - CliRunner drives a headless command-line agent as a subprocess - * - HttpRunner calls an OpenAI-compatible HTTP endpoint - * - MockRunner deterministic, for tests + * - CliRunner drives a headless command-line agent as a subprocess + * - HttpRunner calls an OpenAI-compatible chat-completions endpoint + * - ResponsesRunner calls the OpenAI Responses API (`/v1/responses`) + * - MockRunner deterministic, for tests * Supporting another provider is usually just a new RunnerProfile; a genuinely * new transport is a new runner class. The engine never changes. */ import { z } from "zod"; -export type RunnerKind = "cli" | "http" | "mock"; +export type RunnerKind = "cli" | "http" | "http-responses" | "mock"; /** * Validates a runner profile loaded from configuration. The {@link RunnerProfile} @@ -27,7 +28,7 @@ export type RunnerKind = "cli" | "http" | "mock"; export const RunnerProfileSchema = z .object({ id: z.string().min(1), - kind: z.enum(["cli", "http", "mock"]), + kind: z.enum(["cli", "http", "http-responses", "mock"]), model: z.string().default(""), options: z.record(z.unknown()).optional(), }) diff --git a/src/runners/responsesRunner.ts b/src/runners/responsesRunner.ts new file mode 100644 index 0000000..6f10e6e --- /dev/null +++ b/src/runners/responsesRunner.ts @@ -0,0 +1,286 @@ +import { z } from "zod"; +import type { + AgentRunner, + RunRequest, + RunResult, + RunnerProfile, +} from "./agentRunner.js"; +import { redactAndTruncate } from "../engine/redaction.js"; +import type { FetchLike, HttpRunnerDeps } from "./httpRunner.js"; + +/** + * Runner for the OpenAI **Responses API** (`POST {baseUrl}/responses`). + * + * This is intentionally a SEPARATE runner from {@link HttpRunner}, which speaks + * the older Chat Completions shape (`messages` -> `choices[].message.content`). + * The Responses API has a different request body (`input` + `instructions`) and + * a different result shape (`output[].content[].text`, with an `output_text` + * convenience), so mixing the two behind one runner would be fragile. Keeping + * them distinct means a profile's `kind` ("http" vs "http-responses") selects + * the exact wire format, and each can evolve independently. + * + * Everything provider-specific is profile data: + * - where to call: `baseUrl` + `path` (default `/responses`) + * - auth by reference: `apiKeyEnv` names an env var holding the key (sent as + * `Authorization: Bearer ...`); custom `headers` values may reference + * `${VAR}` from the parent env + * - the model comes from the profile's `model` + * - quota detection: HTTP status codes (default 429) and/or a body regex + * - a hard request timeout and bounded output capture + * + * Like the other network runner, transport/HTTP failures resolve to a result + * with diagnostics in `meta` rather than throwing, so one bad call never + * crashes a run; the role layer treats empty/unparseable output as a retry. + */ + +const ENV_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; + +function expandEnvRefs(value: string, source: NodeJS.ProcessEnv): string { + return value.replace(ENV_REF_RE, (_m, name: string) => source[name] ?? ""); +} + +export const ResponsesRunnerOptionsSchema = z + .object({ + /** base URL of the API, e.g. "https://api.openai.com/v1" */ + baseUrl: z.string().url(), + /** request path appended to baseUrl */ + path: z.string().default("/responses"), + /** name of the env var holding the API key (secret by reference, not value) */ + apiKeyEnv: z.string().optional(), + /** extra headers; values may contain ${VAR} expanded from the parent env */ + headers: z.record(z.string()).default({}), + /** hard request timeout before the call is aborted */ + timeoutMs: z.number().int().positive().default(5 * 60_000), + /** rolling cap on captured response text */ + maxOutputChars: z.number().int().positive().default(2_000_000), + /** sampling temperature, omitted from the body when unset */ + temperature: z.number().optional(), + /** max_output_tokens, omitted from the body when unset */ + maxOutputTokens: z.number().int().positive().optional(), + /** + * Reasoning effort for reasoning models, e.g. "low" | "medium" | "high". + * Sent as `reasoning: { effort }` when set; omitted otherwise. + */ + reasoningEffort: z.enum(["minimal", "low", "medium", "high"]).optional(), + /** merged into the request body (e.g. provider-specific knobs) */ + extraBody: z.record(z.unknown()).default({}), + quota: z + .object({ + /** HTTP status codes that indicate an exhausted usage/rate window */ + statusCodes: z.array(z.number().int()).default([429]), + /** case-insensitive regex tested against an error response body */ + pattern: z + .string() + .optional() + .refine( + (p) => { + if (p === undefined) return true; + try { + new RegExp(p, "i"); + return true; + } catch { + return false; + } + }, + { message: "quota.pattern must be a valid regular expression" }, + ), + }) + .default({ statusCodes: [429] }), + }) + .strict(); + +export type ResponsesRunnerOptions = z.infer; + +/** + * Extracts the assistant text from a Responses API result. Prefers the + * top-level `output_text` convenience (a string, or an array of strings some + * gateways emit), then falls back to concatenating every `output_text` part + * found under `output[].content[]`. Anything unexpected yields "". + */ +export function extractResponsesText(body: unknown): string { + if (body === null || typeof body !== "object") return ""; + const b = body as Record; + + // 1) `output_text` convenience field. + const ot = b["output_text"]; + if (typeof ot === "string" && ot.length > 0) return ot; + if (Array.isArray(ot)) { + const joined = ot.filter((x): x is string => typeof x === "string").join(""); + if (joined.length > 0) return joined; + } + + // 2) Walk output[] -> content[] -> { type: "output_text", text }. + const output = b["output"]; + if (!Array.isArray(output)) return ""; + const parts: string[] = []; + for (const item of output) { + if (item === null || typeof item !== "object") continue; + const content = (item as Record)["content"]; + if (!Array.isArray(content)) continue; + for (const part of content) { + if (part === null || typeof part !== "object") continue; + const p = part as Record; + // Accept the canonical "output_text" and tolerate a bare {text}. + if ((p["type"] === undefined || p["type"] === "output_text") && typeof p["text"] === "string") { + parts.push(p["text"] as string); + } + } + } + return parts.join(""); +} + +/** Runner for the OpenAI Responses API. Transport is injectable for tests. */ +export class ResponsesRunner implements AgentRunner { + readonly profile: RunnerProfile; + private readonly opts: ResponsesRunnerOptions; + private readonly fetchImpl: FetchLike; + private readonly env: NodeJS.ProcessEnv; + private readonly quotaPattern?: RegExp; + + constructor(profile: RunnerProfile, deps: HttpRunnerDeps = {}) { + this.profile = profile; + // Validate eagerly so a bad profile fails at construction, not mid-run. + this.opts = ResponsesRunnerOptionsSchema.parse(profile.options ?? {}); + this.quotaPattern = + this.opts.quota.pattern !== undefined + ? new RegExp(this.opts.quota.pattern, "i") + : undefined; + const globalFetch = (globalThis as { fetch?: FetchLike }).fetch; + const resolved = deps.fetch ?? globalFetch; + if (!resolved) { + throw new Error( + `ResponsesRunner "${profile.id}" has no fetch implementation available ` + + `(global fetch missing; pass one via deps.fetch).`, + ); + } + this.fetchImpl = resolved; + this.env = deps.env ?? process.env; + } + + async run(req: RunRequest): Promise { + const url = this.joinUrl(this.opts.baseUrl, this.opts.path); + const headers = this.buildHeaders(); + const body = JSON.stringify(this.buildBody(req)); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs); + const started = Date.now(); + + const onAbort = (): void => controller.abort(); + if (req.signal) { + if (req.signal.aborted) controller.abort(); + else req.signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + const res = await this.fetchImpl(url, { + method: "POST", + headers, + body, + signal: controller.signal, + }); + const durationMs = Date.now() - started; + + if (!res.ok) { + const errText = await safeText(res); + const quotaExhausted = + this.opts.quota.statusCodes.includes(res.status) || + (this.quotaPattern !== undefined && this.quotaPattern.test(errText)); + return { + text: "", + quotaExhausted, + meta: { + runnerId: this.profile.id, + model: this.profile.model, + status: res.status, + durationMs, + error: redactAndTruncate(errText, 2_000), + }, + }; + } + + const json = await res.json(); + const raw = extractResponsesText(json); + const text = + raw.length > this.opts.maxOutputChars ? raw.slice(0, this.opts.maxOutputChars) : raw; + + return { + text, + quotaExhausted: false, + meta: { + runnerId: this.profile.id, + model: this.profile.model, + status: res.status, + durationMs, + usage: (json as { usage?: unknown })?.usage ?? null, + }, + }; + } catch (err) { + const cancelled = req.signal?.aborted ?? false; + const aborted = controller.signal.aborted; + return { + text: "", + quotaExhausted: false, + meta: { + runnerId: this.profile.id, + model: this.profile.model, + durationMs: Date.now() - started, + timedOut: aborted && !cancelled, + cancelled, + error: cancelled + ? "request cancelled" + : aborted + ? "request timed out" + : String((err as Error).message ?? err), + }, + }; + } finally { + clearTimeout(timer); + req.signal?.removeEventListener("abort", onAbort); + } + } + + private joinUrl(base: string, path: string): string { + const b = base.endsWith("/") ? base.slice(0, -1) : base; + const p = path.startsWith("/") ? path : `/${path}`; + return `${b}${p}`; + } + + private buildHeaders(): Record { + const headers: Record = { "content-type": "application/json" }; + for (const [k, v] of Object.entries(this.opts.headers)) { + headers[k] = expandEnvRefs(v, this.env); + } + if (this.opts.apiKeyEnv) { + const key = this.env[this.opts.apiKeyEnv]; + if (key) headers["authorization"] = `Bearer ${key}`; + } + return headers; + } + + private buildBody(req: RunRequest): Record { + const body: Record = { + // Spread caller-supplied knobs FIRST so they can never override the core + // request fields (model/input) set below — otherwise a stray extraBody + // value could silently diverge the sent prompt/model from the request. + ...this.opts.extraBody, + model: this.profile.model, + // The Responses API takes `input` (the user turn) and a separate + // `instructions` (the system framing), unlike Chat Completions' messages. + input: req.prompt, + }; + if (req.system) body["instructions"] = req.system; + if (this.opts.temperature !== undefined) body["temperature"] = this.opts.temperature; + if (this.opts.maxOutputTokens !== undefined) body["max_output_tokens"] = this.opts.maxOutputTokens; + if (this.opts.reasoningEffort !== undefined) body["reasoning"] = { effort: this.opts.reasoningEffort }; + return body; + } +} + +async function safeText(res: { text(): Promise }): Promise { + try { + return await res.text(); + } catch { + return ""; + } +} diff --git a/src/runners/runnerFactory.ts b/src/runners/runnerFactory.ts index fffcf8b..14d441f 100644 --- a/src/runners/runnerFactory.ts +++ b/src/runners/runnerFactory.ts @@ -1,6 +1,7 @@ import type { AgentRunner, RunnerProfile } from "./agentRunner.js"; import { CliRunner } from "./cliRunner.js"; import { HttpRunner } from "./httpRunner.js"; +import { ResponsesRunner } from "./responsesRunner.js"; import { MockRunner } from "./mockRunner.js"; /** @@ -30,6 +31,8 @@ export const createRunner: RunnerFactory = (profile) => { return MockRunner.fromProfile(profile); case "http": return new HttpRunner(profile); + case "http-responses": + return new ResponsesRunner(profile); default: { // Exhaustiveness guard: a new RunnerKind must be handled above. const exhaustive: never = profile.kind; diff --git a/src/server/index.ts b/src/server/index.ts index 493db86..0a8fe8a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -25,6 +25,7 @@ import { loadConfig } from "../config.js"; import { openStore } from "../storage/store.js"; import { reconcileInterruptedSessions } from "../session.js"; import { createServer, isLoopbackHost } from "./server.js"; +import { createTelegramRelayFromEnv } from "../notify/telegram.js"; function envFlag(v: string | undefined): boolean { return v !== undefined && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase()); @@ -49,15 +50,29 @@ async function main(): Promise { // The supervising process (Tauri) may pin the token via env; otherwise a // fresh random one is generated and reported on the readiness line. const token = process.env.LOOPWRIGHT_TOKEN || randomUUID(); + + // Optional phone updates: a Telegram relay that pushes final run status and + // accepts replies (as new goals) over OUTBOUND long-polling only — no inbound + // port, so the engine stays loopback-only. Absent config => no relay. + const relay = createTelegramRelayFromEnv(process.env, (line) => console.error(line)); + const server = createServer({ store, config, token, // When a graceful shutdown finishes (signal or POST /api/shutdown), exit. onShutdown: () => process.exit(0), + ...(relay ? { notifier: relay } : {}), ...(staticDir ? { staticDir } : {}), }); + // The relay needs to launch chat-initiated runs through the same validated + // path the UI uses; hand it the server once both exist. + if (relay) { + relay.attach(server); + relay.start(); + } + const port = Number.parseInt(process.env.LOOPWRIGHT_PORT ?? "0", 10) || 0; const host = process.env.LOOPWRIGHT_HOST ?? "127.0.0.1"; @@ -85,6 +100,7 @@ async function main(): Promise { const shutdown = (): void => { if (stopping) return; stopping = true; + relay?.stop(); void server.stop().then(() => process.exit(0)); }; process.on("SIGINT", shutdown); diff --git a/src/server/server.ts b/src/server/server.ts index 163b63e..32860c6 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -60,6 +60,30 @@ export type RunGoalImpl = ( opts?: RunGoalOptions, ) => Promise; +/** Outcome of submitting a run programmatically (HTTP route or relay). */ +export type SubmitResult = + | { ok: true; sessionId: string } + | { ok: false; status: number; error: string }; + +/** Info passed to a notifier when a run reaches a terminal state. */ +export interface RunFinishedInfo { + sessionId: string; + goal: string; + phase: "done" | "error"; + result?: SessionResult; + error?: string; +} + +/** + * Optional sink for terminal run status (e.g. the Telegram relay). It is + * deliberately outbound-only and best-effort: the server never lets a notifier + * error affect a run, and a notifier is the ONLY way run status leaves the + * loopback boundary. + */ +export interface ServerNotifier { + runFinished(info: RunFinishedInfo): void; +} + export interface CreateServerOptions { /** durable store shared by runs (writes) and the trace endpoint (reads) */ store: Store; @@ -96,6 +120,12 @@ export interface CreateServerOptions { * force-closing any that remain (idle keep-alive sockets, slow clients). */ shutdownGraceMs?: number; + /** + * Optional outbound notifier for terminal run status (e.g. a Telegram relay). + * The server keeps no inbound exposure; this is the only channel by which run + * status leaves the process. + */ + notifier?: ServerNotifier; } export interface LoopwrightServer { @@ -107,6 +137,13 @@ export interface LoopwrightServer { /** listen on a port (0 = ephemeral) and resolve with the bound port */ start(port?: number, host?: string): Promise; stop(): Promise; + /** + * Starts a run programmatically (same path as POST /api/runs), without going + * through HTTP. Used by the Telegram relay to launch chat-initiated runs. + */ + submitRun(body: StartRunBody): Promise; + /** Env + repo of the most recent run, for chat-initiated runs to reuse. */ + lastRunConfig(): { env: Record; repoDir?: string } | undefined; } const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" }; @@ -210,8 +247,11 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { const retainMs = opts.retainMs ?? 5 * 60_000; const onShutdown = opts.onShutdown; const shutdownGraceMs = opts.shutdownGraceMs ?? 3_000; + const notifier = opts.notifier; const hub = new RunHub(opts.maxBufferPerRun); let activeRuns = 0; + /** env + repo of the most recent run, reused for chat-initiated runs */ + let lastRun: { env: Record; repoDir?: string } | undefined; /** abort controllers for in-flight runs, keyed by session id (for cancel) */ const controllers = new Map(); /** open SSE responses, so a graceful shutdown can end them deterministically */ @@ -347,8 +387,23 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { } catch (err) { return send(res, 400, { error: `invalid JSON body: ${String((err as Error).message)}` }, cors); } + const result = await submitRun(body); + if (!result.ok) return send(res, result.status, { error: result.error }, cors); + return send(res, 202, { sessionId: result.sessionId }, cors); + } + + /** + * Core run-start logic, shared by the HTTP route and programmatic callers + * (the Telegram relay). Validates the request and kicks the run off in the + * background, returning a structured result instead of writing to a response. + * The fire-and-forget run streams progress over the hub and, on completion, + * notifies any configured outbound notifier. + */ + async function submitRun(body: StartRunBody): Promise { + if (closing) return { ok: false, status: 503, error: "server is shutting down" }; + const goal = (body.goal ?? "").trim(); - if (!goal) return send(res, 400, { error: "goal is required" }, cors); + if (!goal) return { ok: false, status: 400, error: "goal is required" }; // Resolve per-run config from the merged env, but never let a caller // redirect persistence away from the server's store. @@ -358,62 +413,51 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { try { runConfig = loadConfig(mergedEnv); } catch (err) { - return send(res, 400, { error: `invalid config: ${String((err as Error).message)}` }, cors); + return { ok: false, status: 400, error: `invalid config: ${String((err as Error).message)}` }; } runConfig.dbPath = config.dbPath; // Effective repo: the per-run body value wins, falling back to the env - // default. The body is untyped JSON, so reject a non-string up front (a - // number would otherwise throw on .trim() and turn a bad request into a - // 500). It must be an absolute path (the field is documented as such, and a - // relative path would resolve against the server's cwd, not the caller's), - // and it must be a git working tree — validate before the run so it fails - // with a clear 400 rather than deep inside worktree setup. + // default. The body is untyped JSON, so reject a non-string up front. It + // must be an absolute path and a git working tree — validated before the + // run so it fails clearly rather than deep inside worktree setup. if (body.repoDir !== undefined && typeof body.repoDir !== "string") { - return send(res, 400, { error: "repoDir must be a string" }, cors); + return { ok: false, status: 400, error: "repoDir must be a string" }; } const repoDir = (typeof body.repoDir === "string" ? body.repoDir.trim() : "") || runConfig.repoDir.trim(); if (repoDir) { if (!path.isAbsolute(repoDir)) { - return send(res, 400, { error: `repoDir must be an absolute path (got "${repoDir}")` }, cors); + return { ok: false, status: 400, error: `repoDir must be an absolute path (got "${repoDir}")` }; } if (!(await isGitRepo(repoDir))) { - return send( - res, - 400, - { error: `repoDir "${repoDir}" is not a git repository (run git init or pick another folder)` }, - cors, - ); + return { + ok: false, + status: 400, + error: `repoDir "${repoDir}" is not a git repository (run git init or pick another folder)`, + }; } } // A caller-supplied session id becomes part of git branch names and - // worktree paths, so reject anything outside the bounded safe format before - // it reaches the filesystem/git. New runs without an id get a safe UUID. + // worktree paths, so reject anything outside the bounded safe format. if (body.sessionId !== undefined && !isValidSessionId(body.sessionId)) { - return send( - res, - 400, - { error: "sessionId must match /^[A-Za-z0-9_-]{1,64}$/" }, - cors, - ); + return { ok: false, status: 400, error: "sessionId must match /^[A-Za-z0-9_-]{1,64}$/" }; } const sessionId = body.sessionId ?? randomUUID(); if (hub.has(sessionId) && hub.phase(sessionId) === "running") { - return send(res, 409, { error: `session ${sessionId} is already running` }, cors); + return { ok: false, status: 409, error: `session ${sessionId} is already running` }; } // Admission control: cap concurrent background runs so repeated clicks, a // buggy UI, or a leaked token can't kick off unbounded expensive work. if (activeRuns >= maxActiveRuns) { - return send( - res, - 429, - { error: `too many active runs (max ${maxActiveRuns}); wait for one to finish` }, - cors, - ); + return { ok: false, status: 429, error: `too many active runs (max ${maxActiveRuns}); wait for one to finish` }; } + // Remember this run's shaping so chat-initiated runs (Telegram) can reuse + // the same presets + repo by supplying only a new goal. + lastRun = { env: { ...(body.env ?? {}) }, ...(repoDir ? { repoDir } : {}) }; + // Live wiring: the observer streams transitions/attempts/outcomes; the // tapping store streams lifecycle + runner-call events; `log` streams the // engine's human-readable lines. All flow through the same hub channel. @@ -447,9 +491,9 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { // Fire-and-forget: the run proceeds in the background and the client // follows it over SSE. Errors (including cancellation) surface as a terminal - // status message. The settle promise is tracked so a graceful shutdown can - // wait for an aborted run to finish persisting its failure and cleaning up - // its worktree. + // status message AND (best-effort) to the outbound notifier. The settle + // promise is tracked so a graceful shutdown can wait for an aborted run to + // finish persisting its failure and cleaning up its worktree. const run = runGoalImpl(goal, runConfig, { store: tappedStore, sessionId, @@ -461,12 +505,12 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { }) .then((result) => { hub.publish(sessionId, "status", { phase: "done", result } satisfies RunStatusData); + notifySafe({ sessionId, goal, phase: "done", result }); }) .catch((err: unknown) => { - hub.publish(sessionId, "status", { - phase: "error", - error: String((err as Error)?.message ?? err), - } satisfies RunStatusData); + const error = String((err as Error)?.message ?? err); + hub.publish(sessionId, "status", { phase: "error", error } satisfies RunStatusData); + notifySafe({ sessionId, goal, phase: "error", error }); }) .finally(() => { settle(); @@ -474,7 +518,17 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { }); activeRunPromises.add(run); - send(res, 202, { sessionId }, cors); + return { ok: true, sessionId }; + } + + /** Best-effort outbound notification; a notifier error never affects a run. */ + function notifySafe(info: RunFinishedInfo): void { + if (!notifier) return; + try { + notifier.runFinished(info); + } catch { + /* never let a notification failure surface into the run */ + } } function streamRun( @@ -665,5 +719,11 @@ export function createServer(opts: CreateServerOptions): LoopwrightServer { stop(): Promise { return closeServer(); }, + submitRun(body: StartRunBody): Promise { + return submitRun(body); + }, + lastRunConfig(): { env: Record; repoDir?: string } | undefined { + return lastRun; + }, }; } diff --git a/test/responsesRunner.test.ts b/test/responsesRunner.test.ts new file mode 100644 index 0000000..230f821 --- /dev/null +++ b/test/responsesRunner.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; +import { + ResponsesRunner, + extractResponsesText, +} from "../src/runners/responsesRunner.js"; +import type { FetchLike } from "../src/runners/httpRunner.js"; +import type { RunnerProfile } from "../src/runners/agentRunner.js"; + +const profile = (options: Record): RunnerProfile => ({ + id: "p", + kind: "http-responses", + model: "gpt-5.5", + options, +}); + +/** A fake fetch that records the call and returns a scripted response. */ +function fakeFetch( + impl: (url: string, init: { headers: Record; body: string }) => + | { ok: boolean; status: number; json?: unknown; text?: string }, +): { fetch: FetchLike; calls: Array<{ url: string; headers: Record; body: any }> } { + const calls: Array<{ url: string; headers: Record; body: any }> = []; + const fetch: FetchLike = async (url, init) => { + calls.push({ url, headers: init.headers, body: JSON.parse(init.body) }); + const r = impl(url, init); + return { + ok: r.ok, + status: r.status, + json: async () => r.json, + text: async () => r.text ?? "", + }; + }; + return { fetch, calls }; +} + +/** A realistic Responses API success body (no output_text convenience field). */ +const outputBody = (text: string, usage?: unknown) => ({ + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text }], + }, + ], + ...(usage ? { usage } : {}), +}); + +describe("extractResponsesText", () => { + it("prefers the output_text convenience field", () => { + expect(extractResponsesText({ output_text: "hi", output: [] })).toBe("hi"); + }); + + it("joins an output_text array", () => { + expect(extractResponsesText({ output_text: ["a", "b"] })).toBe("ab"); + }); + + it("walks output[].content[] for output_text parts", () => { + expect(extractResponsesText(outputBody("from-content"))).toBe("from-content"); + }); + + it("returns empty string for unexpected shapes", () => { + expect(extractResponsesText(null)).toBe(""); + expect(extractResponsesText({ choices: [] })).toBe(""); + }); +}); + +describe("ResponsesRunner option validation", () => { + it("rejects a profile without a valid baseUrl", () => { + expect(() => new ResponsesRunner(profile({}), { fetch: fakeFetch(() => ({ ok: true, status: 200 })).fetch })) + .toThrow(); + expect(() => new ResponsesRunner(profile({ baseUrl: "nope" }), { fetch: fakeFetch(() => ({ ok: true, status: 200 })).fetch })) + .toThrow(); + }); + + it("rejects unknown options (strict schema)", () => { + expect( + () => + new ResponsesRunner(profile({ baseUrl: "https://api.openai.com/v1", bogus: 1 }), { + fetch: fakeFetch(() => ({ ok: true, status: 200 })).fetch, + }), + ).toThrow(); + }); +}); + +describe("ResponsesRunner request shaping", () => { + it("posts to /responses with input + instructions and the profile model", async () => { + const { fetch, calls } = fakeFetch(() => ({ ok: true, status: 200, json: outputBody("hello") })); + const runner = new ResponsesRunner( + profile({ baseUrl: "https://api.openai.com/v1", temperature: 0.3, maxOutputTokens: 256 }), + { fetch }, + ); + const res = await runner.run({ prompt: "do it", cwd: ".", system: "you are X" }); + + expect(res.text).toBe("hello"); + expect(calls[0]?.url).toBe("https://api.openai.com/v1/responses"); + expect(calls[0]?.body.model).toBe("gpt-5.5"); + expect(calls[0]?.body.input).toBe("do it"); + expect(calls[0]?.body.instructions).toBe("you are X"); + expect(calls[0]?.body.temperature).toBe(0.3); + expect(calls[0]?.body.max_output_tokens).toBe(256); + }); + + it("sends reasoning.effort when configured", async () => { + const { fetch, calls } = fakeFetch(() => ({ ok: true, status: 200, json: outputBody("ok") })); + const runner = new ResponsesRunner( + profile({ baseUrl: "https://api.openai.com/v1", reasoningEffort: "high" }), + { fetch }, + ); + await runner.run({ prompt: "p", cwd: "." }); + expect(calls[0]?.body.reasoning).toEqual({ effort: "high" }); + }); + + it("reads the API key from the referenced env var (secret by reference)", async () => { + const { fetch, calls } = fakeFetch(() => ({ ok: true, status: 200, json: outputBody("ok") })); + const runner = new ResponsesRunner( + profile({ baseUrl: "https://api.openai.com/v1", apiKeyEnv: "MY_KEY", headers: { "x-org": "${MY_ORG}" } }), + { fetch, env: { MY_KEY: "secret-123", MY_ORG: "acme" } }, + ); + await runner.run({ prompt: "p", cwd: "." }); + expect(calls[0]?.headers["authorization"]).toBe("Bearer secret-123"); + expect(calls[0]?.headers["x-org"]).toBe("acme"); + }); + + it("surfaces provider usage in meta for the cost ledger", async () => { + const { fetch } = fakeFetch(() => ({ ok: true, status: 200, json: outputBody("ok", { total_tokens: 99 }) })); + const runner = new ResponsesRunner(profile({ baseUrl: "https://api.openai.com/v1" }), { fetch }); + const res = await runner.run({ prompt: "p", cwd: "." }); + expect(res.meta?.usage).toEqual({ total_tokens: 99 }); + }); +}); + +describe("ResponsesRunner quota + error handling", () => { + it("flags quotaExhausted on HTTP 429", async () => { + const { fetch } = fakeFetch(() => ({ ok: false, status: 429, text: "rate limit" })); + const runner = new ResponsesRunner(profile({ baseUrl: "https://api.openai.com/v1" }), { fetch }); + const res = await runner.run({ prompt: "p", cwd: "." }); + expect(res.quotaExhausted).toBe(true); + expect(res.text).toBe(""); + expect(res.meta?.status).toBe(429); + }); + + it("flags quotaExhausted when the error body matches the configured pattern", async () => { + const { fetch } = fakeFetch(() => ({ ok: false, status: 400, text: "insufficient_quota for org" })); + const runner = new ResponsesRunner( + profile({ baseUrl: "https://api.openai.com/v1", quota: { statusCodes: [], pattern: "insufficient_quota" } }), + { fetch }, + ); + const res = await runner.run({ prompt: "p", cwd: "." }); + expect(res.quotaExhausted).toBe(true); + }); + + it("resolves transport failures to a result with diagnostics instead of throwing", async () => { + const fetch: FetchLike = async () => { + throw new Error("ECONNREFUSED"); + }; + const runner = new ResponsesRunner(profile({ baseUrl: "https://api.openai.com/v1" }), { fetch }); + const res = await runner.run({ prompt: "p", cwd: "." }); + expect(res.text).toBe(""); + expect(String(res.meta?.error)).toContain("ECONNREFUSED"); + }); +}); diff --git a/test/telegram.test.ts b/test/telegram.test.ts new file mode 100644 index 0000000..66ecc63 --- /dev/null +++ b/test/telegram.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from "vitest"; +import { + TelegramRelay, + formatFinalStatus, + createTelegramRelayFromEnv, + type TelegramFetch, + type RunSubmitter, +} from "../src/notify/telegram.js"; +import type { SessionResult } from "../src/session.js"; +import type { StartRunBody } from "../src/server/server.js"; + +interface TgUpdate { + update_id: number; + message?: { text?: string; chat?: { id?: number | string } }; +} + +/** A fake Telegram transport: scripts getUpdates batches, records sendMessage. */ +function fakeTelegram(batches: TgUpdate[][]) { + const sent: Array<{ chat_id: string; text: string }> = []; + const getUpdatesUrls: string[] = []; + let batch = 0; + const fetchImpl: TelegramFetch = async (url, init) => { + if (url.includes("/getUpdates")) { + getUpdatesUrls.push(url); + const result = batches[batch] ?? []; + batch += 1; + return { ok: true, status: 200, json: async () => ({ ok: true, result }), text: async () => "" }; + } + if (url.includes("/sendMessage")) { + sent.push(JSON.parse(init?.body ?? "{}")); + return { ok: true, status: 200, json: async () => ({ ok: true }), text: async () => "" }; + } + return { ok: false, status: 404, json: async () => ({}), text: async () => "not found" }; + }; + return { fetchImpl, sent, getUpdatesUrls }; +} + +function fakeSubmitter(last?: { env: Record; repoDir?: string }) { + const calls: StartRunBody[] = []; + const submitter: RunSubmitter & { calls: StartRunBody[] } = { + calls, + submitRun: async (body) => { + calls.push(body); + return { ok: true, sessionId: "sess-1" }; + }, + lastRunConfig: () => last, + }; + return submitter; +} + +const sessionResult = (over: Partial): SessionResult => + ({ + goal: "g", + plan: {} as never, + results: [], + green: [], + unverified: [], + needsHuman: [], + skipped: [], + allVerified: true, + ...over, + }) as SessionResult; + +describe("formatFinalStatus", () => { + it("formats a completed run with counts", () => { + const msg = formatFinalStatus({ + sessionId: "abc", + goal: "Add /healthz", + phase: "done", + result: sessionResult({ green: ["t1", "t2"], allVerified: true }), + }); + expect(msg).toContain("✅ Loopwright: completed"); + expect(msg).toContain("“Add /healthz”"); + expect(msg).toContain("green 2"); + expect(msg).toContain("session abc"); + }); + + it("flags needs-attention and includes a PR link when present", () => { + const msg = formatFinalStatus({ + sessionId: "abc", + goal: "g", + phase: "done", + result: sessionResult({ + needsHuman: ["t3"], + publish: { pushed: true, remote: "origin", branch: "b", pushBranch: "b", pr: { created: true, url: "https://gh/pr/1" } } as never, + }), + }); + expect(msg).toContain("⚠️ Loopwright: needs attention"); + expect(msg).toContain("PR: https://gh/pr/1"); + expect(msg).toContain("Reply with a new goal"); + }); + + it("formats a failed run", () => { + const msg = formatFinalStatus({ sessionId: "abc", goal: "g", phase: "error", error: "boom" }); + expect(msg).toContain("❌ Loopwright run failed"); + expect(msg).toContain("boom"); + }); +}); + +describe("TelegramRelay.runFinished", () => { + it("sends the final status to the configured chat", async () => { + const tg = fakeTelegram([]); + const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl }); + relay.runFinished({ sessionId: "s", goal: "do it", phase: "done", result: sessionResult({}) }); + // runFinished fires the send without awaiting; let the microtask settle. + await new Promise((r) => setTimeout(r, 0)); + expect(tg.sent).toHaveLength(1); + expect(tg.sent[0]?.chat_id).toBe("42"); + expect(tg.sent[0]?.text).toContain("Loopwright"); + }); +}); + +describe("TelegramRelay control channel", () => { + it("runs a message from the allowlisted chat as a new goal, reusing last config", async () => { + const tg = fakeTelegram([]); + const submitter = fakeSubmitter({ env: { LOOPWRIGHT_RUNNERS: "[]" }, repoDir: "/repo" }); + const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl, skipBacklog: false }); + relay.attach(submitter); + + await relay.handleUpdate({ update_id: 1, message: { text: "Fix the bug", chat: { id: 42 } } }); + + expect(submitter.calls).toHaveLength(1); + expect(submitter.calls[0]).toMatchObject({ goal: "Fix the bug", repoDir: "/repo", env: { LOOPWRIGHT_RUNNERS: "[]" } }); + expect(tg.sent[0]?.text).toContain("▶️ Starting run"); + expect(tg.sent[0]?.text).toContain("sess-1"); + }); + + it("ignores messages from any other chat", async () => { + const tg = fakeTelegram([]); + const submitter = fakeSubmitter({ env: {}, repoDir: "/repo" }); + const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl, skipBacklog: false }); + relay.attach(submitter); + + await relay.handleUpdate({ update_id: 1, message: { text: "do it", chat: { id: 999 } } }); + expect(submitter.calls).toHaveLength(0); + expect(tg.sent).toHaveLength(0); + }); + + it("replies with help and does not start a run for /help", async () => { + const tg = fakeTelegram([]); + const submitter = fakeSubmitter({ env: {}, repoDir: "/repo" }); + const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl, skipBacklog: false }); + relay.attach(submitter); + + await relay.handleUpdate({ update_id: 1, message: { text: "/help", chat: { id: 42 } } }); + expect(submitter.calls).toHaveLength(0); + expect(tg.sent[0]?.text).toContain("Loopwright relay"); + }); + + it("asks the user to start from desktop when there is no run config yet", async () => { + const tg = fakeTelegram([]); + const submitter = fakeSubmitter(undefined); + const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl, skipBacklog: false }); + relay.attach(submitter); + + await relay.handleUpdate({ update_id: 1, message: { text: "do something", chat: { id: 42 } } }); + expect(submitter.calls).toHaveLength(0); + expect(tg.sent[0]?.text).toContain("don't have a run configuration"); + }); +}); + +describe("TelegramRelay polling", () => { + it("skips the initial backlog, then handles later messages, advancing the offset", async () => { + // First poll returns a backlog message (must be skipped); second returns a new one. + const tg = fakeTelegram([ + [{ update_id: 5, message: { text: "old", chat: { id: 42 } } }], + [{ update_id: 6, message: { text: "new goal", chat: { id: 42 } } }], + ]); + const submitter = fakeSubmitter({ env: {}, repoDir: "/repo" }); + const relay = new TelegramRelay({ botToken: "T", chatId: "42", fetchImpl: tg.fetchImpl }); + relay.attach(submitter); + + await relay.pollOnce(); // backlog: advances offset, no handling + expect(submitter.calls).toHaveLength(0); + + await relay.pollOnce(); // handles "new goal" + expect(submitter.calls).toHaveLength(1); + expect(submitter.calls[0]?.goal).toBe("new goal"); + + // The second getUpdates must carry offset=6 (5 + 1) to ack the backlog. + expect(tg.getUpdatesUrls[1]).toContain("offset=6"); + }); +}); + +describe("createTelegramRelayFromEnv", () => { + it("returns undefined when not configured", () => { + expect(createTelegramRelayFromEnv({})).toBeUndefined(); + }); + it("builds a relay from TELEGRAM_* secret names", () => { + const relay = createTelegramRelayFromEnv({ TELEGRAM_BOT_TOKEN: "T", TELEGRAM_CHAT_ID: "42" }); + expect(relay).toBeInstanceOf(TelegramRelay); + }); +});