From 72698fa6afa71b9635f99884d017672372f64c0a Mon Sep 17 00:00:00 2001 From: TheLastAirbenderAng Date: Fri, 3 Jul 2026 06:56:32 +0800 Subject: [PATCH] feat: add image generation, session tools, and background jobs - image_gen: generate images via agy built-in generate_image - setup: report agy install + auth status - session_transfer: expose the current agy conversation id - pre_finish_review: advisory pre-finish review (opencode has no blocking stop hook) - background jobs: detached runs with a file-backed job store, plus job_status/job_result/job_cancel and a background flag on the delegation tools - cross-platform process cleanup (taskkill /T on Windows) --- CLAUDE.md | 9 + src/jobs.ts | 258 +++++++++++++++++ src/runner.ts | 80 +++++- src/server.ts | 403 ++++++++++++++++++++++++++- src/tools.ts | 242 ++++++++++++++-- test/image_gen.test.ts | 240 ++++++++++++++++ test/jobs.test.ts | 510 ++++++++++++++++++++++++++++++++++ test/runner.test.ts | 24 +- test/server.test.ts | 17 ++ test/session_transfer.test.ts | 78 ++++++ test/setup.test.ts | 63 +++++ test/tools.test.ts | 34 ++- 12 files changed, 1917 insertions(+), 41 deletions(-) create mode 100644 src/jobs.ts create mode 100644 test/image_gen.test.ts create mode 100644 test/jobs.test.ts create mode 100644 test/session_transfer.test.ts create mode 100644 test/setup.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 38ff5e5..2613d07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,3 +15,12 @@ come back. Prefer delegating over doing it yourself when: Do NOT delegate: small single-file edits, questions you can answer from context already loaded, or tasks needing tools only you have. + +## Pre-finish review + +Before you declare a task **complete / fixed / passing**, call +`pre_finish_review` on the work-in-progress. Pass the diff or plan as `content`, +or the changed file paths as `files` (optional `focus` like "security"). It runs +an adversarial second-opinion pass and returns findings as **advisory text** — +non-blocking, but it catches bugs, edge cases, and unstated assumptions you may +have missed. Weigh the findings with judgement; they do not gate completion. diff --git a/src/jobs.ts b/src/jobs.ts new file mode 100644 index 0000000..ccff86a --- /dev/null +++ b/src/jobs.ts @@ -0,0 +1,258 @@ +/** + * Background job management for agy-bridge: a pid-persisting file job-store + * with atomic (temp+rename) writes and serialized read-modify-write, plus + * detached-run / cancel / orphan-scan helpers. The store is a local-dev aid, + * not a durability guarantee — on an ungraceful server exit a finished job may + * be mislabeled until the next orphan scan corrects it. + */ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import type { Config } from "./config.js"; +import { + runAgy, + treeKill, + type RunnerDeps, + type RunRequest, + type TreeKillExecFn, + type PidProbe, + defaultTreeKillExec, + defaultPidProbe, +} from "./runner.js"; + +export const JOBS_DIR = path.join(homedir(), ".agy-bridge"); +export const JOBS_FILE = path.join(JOBS_DIR, "jobs.json"); + +export type JobStatus = "running" | "done" | "failed" | "cancelled"; + +export interface JobRecord { + id: string; + status: JobStatus; + pid?: number; + conversationId?: string; + cwd: string; + prompt: string; + startedAt: string; + endedAt?: string; + outputPath?: string; + /** agy output text, populated when status becomes `done`. */ + output?: string; + /** Failure reason, populated when status becomes `failed`. */ + error?: string; +} + +export interface JobStore { + load(): Promise; + save(records: JobRecord[]): Promise; + get(id: string): Promise; + upsert(record: JobRecord): Promise; +} + +export interface JobStoreDeps { + storePath: string; + readFile: (p: string) => Promise; + writeFile: (p: string, data: string) => Promise; + rename: (src: string, dest: string) => Promise; + mkdir: (p: string) => Promise; +} + +export const defaultJobStoreDeps: JobStoreDeps = { + storePath: JOBS_FILE, + readFile: (p) => readFile(p, "utf8"), + writeFile: (p, data) => writeFile(p, data, "utf8"), + rename: (src, dest) => rename(src, dest), + mkdir: async (p) => { + await mkdir(p, { recursive: true }); + }, +}; + +/** + * Build a job store backed by `deps.storePath`. All mutations are funneled + * through a serialized promise chain (one RMW at a time) and written via temp + * file + rename so a crash mid-write cannot corrupt or truncate the store. + */ +export function createJobStore(deps: JobStoreDeps = defaultJobStoreDeps): JobStore { + let chain: Promise = Promise.resolve(); + const serialize = (fn: () => Promise): Promise => { + // Run `fn` after the prior op settles (success OR failure); keep the chain + // alive regardless so one rejection can't deadlock subsequent writes. + const next = chain.then(fn, fn); + chain = next.catch(() => {}); + return next; + }; + + const loadRaw = async (): Promise => { + let raw: string; + try { + raw = await deps.readFile(deps.storePath); + } catch { + return []; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as JobRecord[]) : []; + } catch { + return []; + } + }; + + const atomicWrite = async (data: string): Promise => { + const tmp = `${deps.storePath}.${process.pid}.${randomUUID()}.tmp`; + await deps.writeFile(tmp, data); + await deps.rename(tmp, deps.storePath); + }; + + const persist = async (records: JobRecord[]): Promise => { + await deps.mkdir(path.dirname(deps.storePath)); + await atomicWrite(JSON.stringify(records, null, 2)); + }; + + return { + load: () => serialize(loadRaw), + save: (records) => serialize(() => persist(records)), + get: (id) => serialize(async () => (await loadRaw()).find((r) => r.id === id)), + upsert: (record) => + serialize(async () => { + const records = await loadRaw(); + const idx = records.findIndex((r) => r.id === record.id); + if (idx === -1) records.push(record); + else records[idx] = record; + await persist(records); + }), + }; +} + +export const defaultJobStore = createJobStore(); + +/** + * Spawn agy detached and return a job id immediately, WITHOUT awaiting the + * run. The pid is persisted as soon as `spawnChild` returns it (which happens + * synchronously during runAgy's first step), so the record is `running` with a + * real pid before this function resolves. On completion the record moves to + * `done` (carrying agy's conversation id from runAgy's session-map read) or + * `failed` (carrying the error message). The completion write is fire-and- + * forget — callers that need to observe it poll `job_result`. + */ +export async function runAgyBackground( + req: RunRequest, + cfg: Config, + deps: RunnerDeps, + store: JobStore = defaultJobStore, + now: () => string = () => new Date().toISOString(), +): Promise { + const id = randomUUID(); + const startedAt = now(); + const realSpawn = deps.spawnChild; + let capturedPid: number | undefined; + + const wrappedDeps: RunnerDeps = { + ...deps, + spawnChild: (file, args, cwd) => { + const child = realSpawn(file, args, cwd); + capturedPid = child.pid(); + return child; + }, + }; + + // Kick off the run; spawnChild runs synchronously during runAgy's first step, + // so `capturedPid` is populated before the first await suspends runAgy. + const runP = runAgy(req, cfg, wrappedDeps); + await store.upsert({ + id, + status: "running", + pid: capturedPid, + cwd: req.cwd, + prompt: req.prompt, + startedAt, + }); + + void runP + .then(async (result) => { + await store.upsert({ + id, + status: "done", + pid: capturedPid, + conversationId: result.sessionId, + cwd: req.cwd, + prompt: req.prompt, + startedAt, + endedAt: now(), + output: result.output, + }); + }) + .catch(async (err) => { + await store.upsert({ + id, + status: "failed", + pid: capturedPid, + cwd: req.cwd, + prompt: req.prompt, + startedAt, + endedAt: now(), + error: (err as Error).message, + }); + }); + + return id; +} + +export interface CancelResult { + cancelled: boolean; + status: JobStatus; +} + +/** + * Cancel a running job by tree-killing its pid. Idempotent: a job already + * done/failed/cancelled is a no-op returning its current status. Killing is + * best-effort — a pid that already exited is treated as cancelled. + */ +export async function cancelJob( + id: string, + store: JobStore = defaultJobStore, + opts: { exec?: TreeKillExecFn; platform?: NodeJS.Platform; now?: () => string } = {}, +): Promise { + const exec = opts.exec ?? defaultTreeKillExec; + const platform = opts.platform ?? process.platform; + const now = opts.now ?? (() => new Date().toISOString()); + const job = await store.get(id); + if (!job) throw new Error(`Unknown job id: ${id}`); + if (job.status !== "running") return { cancelled: false, status: job.status }; + if (job.pid !== undefined) { + try { + await treeKill(job.pid, "SIGTERM", exec, platform); + } catch { + // best-effort — mark cancelled regardless + } + } + await store.upsert({ ...job, status: "cancelled", endedAt: now() }); + return { cancelled: true, status: "cancelled" }; +} + +/** + * On startup, mark any `running` job whose pid is no longer alive as `failed`. + * Corrects records left dangling by an ungraceful server exit. Returns the + * number of jobs reclassified. + */ +export async function scanOrphans( + store: JobStore = defaultJobStore, + probe: PidProbe = defaultPidProbe, + now: () => string = () => new Date().toISOString(), +): Promise { + const records = await store.load(); + let fixed = 0; + for (let i = 0; i < records.length; i++) { + const r = records[i]; + if (r.status === "running" && r.pid !== undefined && !probe(r.pid)) { + records[i] = { + ...r, + status: "failed", + endedAt: now(), + error: "process exited without reporting (orphaned on restart)", + }; + fixed++; + } + } + if (fixed > 0) await store.save(records); + return fixed; +} diff --git a/src/runner.ts b/src/runner.ts index 21fdf28..372d80d 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -29,6 +29,8 @@ export interface RunResult { export interface ChildHandle { stdout(): string; stderr(): string; + /** The spawned process pid (undefined before spawn / on spawn error). */ + pid(): number | undefined; /** Settles when the process is fully done (exit + closed pipes, or spawn error). */ wait(): Promise<{ code: number | null; error?: NodeJS.ErrnoException }>; /** Signals the whole process group so web-search helpers can't outlive agy. */ @@ -73,6 +75,69 @@ export const execWithClosedStdin: ExecFn = (file, args, options) => { const MAX_STDOUT_CHARS = 64 * 1024 * 1024; const MAX_STDERR_CHARS = 1024 * 1024; +/** + * Subprocess executor used by treeKill (taskkill on Windows). Injectable so the + * kill path is unit-testable without spawning real processes. + */ +export type TreeKillExecFn = ( + file: string, + args: string[], +) => Promise<{ stdout: string; stderr: string }>; + +export const defaultTreeKillExec: TreeKillExecFn = (file, args) => + execFileAsync(file, args, { timeout: 10_000, maxBuffer: 64 * 1024 }); + +/** + * Cross-platform process-tree kill. Replaces the Windows-broken + * `process.kill(-pid)` (Node throws on negative pids under win32, orphaning + * grandchildren like web-search helpers). Windows has no signal semantics for + * a process group, so it always force-kills the tree via `taskkill /T /F`; + * POSIX stays signal-aware via the negative-pid group kill, falling back to a + * child-only kill if the group is already gone. Both branches are no-ops once + * the target has exited. + */ +export async function treeKill( + pid: number, + signal: NodeJS.Signals = "SIGTERM", + exec: TreeKillExecFn = defaultTreeKillExec, + platform: NodeJS.Platform = process.platform, +): Promise { + if (platform === "win32") { + try { + await exec("taskkill", ["/PID", String(pid), "/T", "/F"]); + } catch { + // process tree already gone — nothing to do + } + return; + } + try { + process.kill(-pid, signal); // whole process group + } catch { + try { + process.kill(pid, signal); // group leader already reaped — child only + } catch { + // already gone + } + } +} + +/** + * Liveness probe (signal 0): true when `pid` is still running. On POSIX and + * Windows alike, `process.kill(pid, 0)` throws ESRCH when the pid is dead and + * EPERM when it exists but is owned by another user — EPERM counts as alive. + * Injectable so the orphan scan is unit-testable with a deterministic stub. + */ +export type PidProbe = (pid: number) => boolean; + +export const defaultPidProbe: PidProbe = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return (e as NodeJS.ErrnoException).code === "EPERM"; + } +}; + function spawnDetached(file: string, args: string[], cwd: string): ChildHandle { const child = spawn(file, args, { cwd, detached: true }); child.stdin?.end(); @@ -112,20 +177,15 @@ function spawnDetached(file: string, args: string[], cwd: string): ChildHandle { return { stdout: () => out, stderr: () => err, + pid: () => child.pid, wait: () => done, kill: (signal) => { // No-op once the child exited: its (negative) PID may already belong to - // an unrelated process group. + // an unrelated process group. treeKill is fire-and-forget here to keep + // the synchronous `void` contract; callers that need to await the kill + // (e.g. job cancellation) call `treeKill` directly. if (exited || child.pid === undefined) return; - try { - process.kill(-child.pid, signal); // whole process group - } catch { - try { - child.kill(signal); - } catch { - // already gone - } - } + void treeKill(child.pid, signal).catch(() => {}); }, }; } diff --git a/src/server.ts b/src/server.ts index 234ca08..c492519 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,32 +1,93 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { existsSync } from "node:fs"; +import { copyFile, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { z } from "zod"; import { loadConfig, type Config } from "./config.js"; import { ModelRegistry } from "./models.js"; import { runAgy, defaultDeps, execWithClosedStdin, + SESSIONS_FILE, type RunnerDeps, type RunResult, + type RunRequest, + type TreeKillExecFn, + defaultTreeKillExec, } from "./runner.js"; import { CooldownRegistry, QuotaError } from "./quota.js"; -import { TOOLS, type ToolDef } from "./tools.js"; +import { + TOOLS, + SESSION_TRANSFER_TOOL, + BACKGROUND_CAPABLE, + resolveSessionTransfer, + parseImageGenReply, + mimeTypeFor, + type ToolDef, +} from "./tools.js"; +import { + runAgyBackground, + cancelJob, + scanOrphans, + createJobStore, + defaultJobStore, + type JobStore, +} from "./jobs.js"; + +type TextBlock = { type: "text"; text: string }; +type ImageBlock = { type: "image"; data: string; mimeType: string }; +type ContentBlock = TextBlock | ImageBlock; interface ToolResponse { [key: string]: unknown; - content: { type: "text"; text: string }[]; + content: ContentBlock[]; isError?: boolean; } +/** + * Filesystem hooks for image_gen post-processing. Injectable so the pure + * parse + footer logic is testable without touching real files; defaults read + * + copy real files. Both gracefully degrade (text-only) on failure. + */ +export interface ImageGenDeps { + copyFile(src: string, dest: string): Promise; + readFileBytes(p: string): Promise; +} + +export const defaultImageGenDeps: ImageGenDeps = { + copyFile: (src, dest) => copyFile(src, dest), + readFileBytes: (p) => readFile(p), +}; + interface HandlerExtra { signal?: AbortSignal; } +/** + * Spawns a detached background run and returns its job id immediately. + * Injectable so the background branch is unit-testable without touching agy. + */ +export type BackgroundRunner = ( + req: RunRequest, + cfg: Config, + deps: RunnerDeps, + store: JobStore, +) => Promise; + +export const defaultBackgroundRunner: BackgroundRunner = (req, cfg, deps, store) => + runAgyBackground(req, cfg, deps, store); + export function createToolHandler( tool: ToolDef, cfg: Config, registry: ModelRegistry, deps: RunnerDeps = defaultDeps, cooldowns: CooldownRegistry = new CooldownRegistry(), + imageGenDeps: ImageGenDeps = defaultImageGenDeps, + jobStore: JobStore = defaultJobStore, + backgroundRunner: BackgroundRunner = defaultBackgroundRunner, ): (args: Record, extra?: HandlerExtra) => Promise { return async (args, extra) => { try { @@ -36,6 +97,34 @@ export function createToolHandler( const timeoutSec = cfg.perToolTimeouts[tool.name] ?? (cfg.timeoutExplicit ? cfg.timeoutSec : tool.timeoutSec); + // Background path: spawn detached, return a job id at once (no await on agy). + if (args.background === true && BACKGROUND_CAPABLE.has(tool.name)) { + const jobId = await backgroundRunner( + { prompt, cwd, model: args.model as string | undefined, conversationId, timeoutSec }, + cfg, + deps, + jobStore, + ); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + job_id: jobId, + status: "running", + message: + "Task started in the background. Poll with job_status / job_result; " + + "cancel with job_cancel.", + }, + null, + 2, + ), + }, + ], + }; + } + const resolution = conversationId ? { models: [undefined], note: undefined } : await registry.resolveChain({ @@ -86,6 +175,10 @@ export function createToolHandler( if (attempts.length) meta.push(`failover: ${attempts.join("; ")}`); if (result.sessionId) meta.push(`session: ${result.sessionId} (use follow_up to continue)`); + if (tool.name === "image_gen") { + return buildImageGenResponse(result, args, meta, imageGenDeps); + } + return { content: [ { type: "text", text: `${result.output}\n\n---\n[agy-bridge] ${meta.join(" | ")}` }, @@ -106,6 +199,252 @@ export function createToolHandler( }; } +/** + * image_gen post-processing: parse the IMAGE_PATH contract (marker, then a + * Windows-safe absolute-path scrape), optionally copy to `output`, and attach + * an MCP image content block. Deterministic default: when no marker AND no + * confident absolute path is found, returns the reply text plus a WARNING and + * attaches no image block — never guesses a path. Mirrors references/.../ + * agy-run.sh cmd_image (parse + fallback + copy pattern). + */ +export async function buildImageGenResponse( + result: RunResult, + args: Record, + meta: string[], + deps: ImageGenDeps = defaultImageGenDeps, +): Promise { + const parsed = parseImageGenReply(result.output); + const output = args.output as string | undefined; + const footer = [...meta]; + const content: ContentBlock[] = []; + + if (parsed.srcPath) { + footer.push(`image_path: ${parsed.srcPath}`); + footer.push( + parsed.viaMarker ? "parsed via: IMAGE_PATH marker" : "parsed via: fallback path scrape", + ); + if (output) { + try { + await deps.copyFile(parsed.srcPath, output); + footer.push(`copied to: ${output}`); + } catch (err) { + footer.push(`copy to ${output} failed: ${(err as Error).message}`); + } + } + try { + const bytes = await deps.readFileBytes(parsed.srcPath); + content.push({ + type: "image", + data: bytes.toString("base64"), + mimeType: mimeTypeFor(parsed.srcPath), + }); + } catch { + footer.push("image block skipped: file unreadable"); + } + } else { + footer.push( + "WARNING: agy did not include an IMAGE_PATH line and no absolute image path was " + + "found in its reply. No image was copied or attached.", + ); + } + + content.unshift({ + type: "text", + text: `${result.output}\n\n---\n[agy-bridge] ${footer.join(" | ")}`, + }); + return { content }; +} + +type AuthStatus = "api-key" | "oauth" | "missing" | "unknown"; + +/** + * Mirrors references/antigravity-plugin-cc/.../agy-run.sh:31-39 (auth_status). + * Heuristic only — the real token lives inside the closed agy binary, not the + * environment; this probe is kept local (config.ts reads NO auth var). + */ +function detectAuth(): AuthStatus { + if (process.env.ANTIGRAVITY_API_KEY) return "api-key"; + const home = homedir(); + if ( + existsSync(path.join(home, ".config", "antigravity")) || + existsSync(path.join(home, ".gemini", "antigravity-cli")) + ) { + return "oauth"; + } + return "missing"; +} + +type VersionExecFn = (file: string, args: string[]) => Promise<{ stdout: string; stderr: string }>; + +/** + * `setup` health-check tool — runs `agy --version` only (NO `-p`, no model call). + * Returns JSON { installed, path, version, auth, error } mirroring agy-run.sh + * cmd_check. Registered OUTSIDE the runAgy loop. + */ +export function createSetupHandler( + cfg: Config, + execVersion: VersionExecFn = (file, args) => + execWithClosedStdin(file, args, { + cwd: process.cwd(), + timeout: 15_000, + maxBuffer: 64 * 1024, + }), +): (args: Record) => Promise { + return async () => { + let installed = true; + let version = ""; + let auth: AuthStatus = "unknown"; + let error = ""; + try { + const { stdout } = await execVersion(cfg.agyPath, ["--version"]); + version = (stdout.trim().split(/\r?\n/)[0] ?? "").trim(); + auth = detectAuth(); + } catch (err) { + installed = false; + auth = "unknown"; + const e = err as NodeJS.ErrnoException; + error = + e?.code === "ENOENT" + ? `agy binary not found at "${cfg.agyPath}"; install with: curl -fsSL https://antigravity.google/cli/install.sh | bash` + : (e?.message ?? String(err)); + } + const payload = { + installed, + path: installed ? cfg.agyPath : "", + version, + auth, + error, + }; + return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] }; + }; +} + +type SessionsReader = () => Promise; + +/** + * `session_transfer` — resolves the agy conversation id for a cwd from agy's + * sessions cache and returns the id + a resume command. Makes NO agy run. + * Missing/empty/unparseable cache → session_id null (never throws). + */ +export function createSessionTransferHandler( + readSessions: SessionsReader = () => readFile(SESSIONS_FILE, "utf8"), +): (args: Record) => Promise { + return async (args) => { + const cwd = (args.cwd as string | undefined) ?? process.cwd(); + let mapJson = ""; + try { + mapJson = await readSessions(); + } catch { + mapJson = ""; // missing/empty cache → session_id null, no throw + } + const { session_id, resume_command } = resolveSessionTransfer(mapJson, cwd); + const lines: string[] = []; + if (session_id) { + lines.push(`session_id: ${session_id}`); + lines.push(`resume_command: ${resume_command}`); + lines.push(""); + lines.push("Run the resume command in a terminal to continue this agy conversation."); + } else { + lines.push("session_id: null"); + lines.push(`No agy conversation recorded for: ${path.resolve(cwd)}`); + lines.push("Run agy once in this directory to start a conversation."); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + }; +} + +/** + * `job_status` — returns a job's status (and timing) by id. Never throws: + * an unknown id yields an isError response with a clear message. + */ +export function createJobStatusHandler( + store: JobStore = defaultJobStore, +): (args: Record) => Promise { + return async (args) => { + const id = args.id as string; + const job = await store.get(id); + if (!job) { + return { + content: [{ type: "text", text: `Error: unknown job id "${id}".` }], + isError: true, + }; + } + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + id: job.id, + status: job.status, + startedAt: job.startedAt, + endedAt: job.endedAt, + }, + null, + 2, + ), + }, + ], + }; + }; +} + +/** + * `job_result` — returns the stored output for a finished job. `running` jobs + * return a `{status:"running"}` poll marker; `done` returns the agy output + * plus a session footer; `failed`/`cancelled` return their reason. + */ +export function createJobResultHandler( + store: JobStore = defaultJobStore, +): (args: Record) => Promise { + return async (args) => { + const id = args.id as string; + const job = await store.get(id); + if (!job) { + return { + content: [{ type: "text", text: `Error: unknown job id "${id}".` }], + isError: true, + }; + } + if (job.status === "done") { + const footer = job.conversationId + ? `\n\n---\n[agy-bridge] session: ${job.conversationId} (use follow_up to continue)` + : ""; + return { content: [{ type: "text", text: `${job.output ?? ""}${footer}` }] }; + } + if (job.status === "failed") { + return { + content: [{ type: "text", text: `Job ${job.id} failed: ${job.error ?? "unknown error"}` }], + isError: true, + }; + } + if (job.status === "cancelled") { + return { content: [{ type: "text", text: `Job ${job.id} was cancelled.` }] }; + } + return { content: [{ type: "text", text: JSON.stringify({ id: job.id, status: "running" }) }] }; + }; +} + +/** + * `job_cancel` — tree-kills a running job's pid and marks it cancelled. + * Idempotent: cancelling an already-done/failed/cancelled job is a no-op that + * returns the current status. treeKill's exec is injectable for testing. + */ +export function createJobCancelHandler( + store: JobStore = defaultJobStore, + exec: TreeKillExecFn = defaultTreeKillExec, +): (args: Record) => Promise { + return async (args) => { + const id = args.id as string; + try { + const { cancelled, status } = await cancelJob(id, store, { exec }); + return { content: [{ type: "text", text: JSON.stringify({ id, cancelled, status }) }] }; + } catch (err) { + return { content: [{ type: "text", text: (err as Error).message }], isError: true }; + } + }; +} + export function createServer(): McpServer { const cfg = loadConfig(); const registry = new ModelRegistry(async () => { @@ -117,14 +456,72 @@ export function createServer(): McpServer { return stdout; }); const cooldowns = new CooldownRegistry(); + const jobStore = createJobStore(); + // Orphan scan: reclassify `running` jobs whose pid died during a prior + // ungraceful exit as `failed`. Fire-and-forget — must not block server start. + void scanOrphans(jobStore).catch(() => {}); const server = new McpServer({ name: "agy-bridge", version: "0.4.0" }); for (const tool of TOOLS) { server.registerTool( tool.name, { description: tool.description, inputSchema: tool.schema }, - createToolHandler(tool, cfg, registry, defaultDeps, cooldowns), + createToolHandler(tool, cfg, registry, defaultDeps, cooldowns, defaultImageGenDeps, jobStore), ); } + // Non-runAgy tools — registered OUTSIDE the runAgy loop; none call `agy -p`. + server.registerTool( + SESSION_TRANSFER_TOOL.name, + { + description: SESSION_TRANSFER_TOOL.description, + inputSchema: SESSION_TRANSFER_TOOL.schema, + }, + createSessionTransferHandler(), + ); + server.registerTool( + "setup", + { + description: + "Health check for the Antigravity CLI (agy): reports install path, version, and auth " + + "status (api-key/oauth/missing/unknown). Runs `agy --version` only — makes NO model call.", + inputSchema: {}, + }, + createSetupHandler(cfg), + ); + // Background-job lifecycle tools. + const jobInputSchema = { id: z.string().describe("The job id returned by a background call.") }; + server.registerTool( + "job_status", + { + description: + "Get the status of a background job (running/done/failed/cancelled) by its job_id. " + + "Returned immediately by delegate/analyze_files/deep_search/web_lookup when called with " + + "background:true. Cheap to poll — does NOT run agy.", + inputSchema: jobInputSchema, + }, + createJobStatusHandler(jobStore), + ); + server.registerTool( + "job_result", + { + description: + "Retrieve the output of a finished background job by its job_id. A `running` job returns " + + "{status:'running'} (poll again later); a `done` job returns the agy output text plus a " + + "session footer; `failed`/`cancelled` return their reason.", + inputSchema: jobInputSchema, + }, + createJobResultHandler(jobStore), + ); + server.registerTool( + "job_cancel", + { + description: + "Cancel a running background job by tree-killing its process group (Windows `taskkill " + + "/T /F`, POSIX negative-pid kill) and mark it cancelled. Idempotent on already-finished " + + "jobs (returns the current status, no-op).", + inputSchema: jobInputSchema, + }, + createJobCancelHandler(jobStore), + ); return server; } diff --git a/src/tools.ts b/src/tools.ts index 6c8e216..8af00cf 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -9,6 +9,35 @@ export function resolveFiles(files: string[], cwd: string): string[] { return files.map((f) => (path.isAbsolute(f) ? f : path.resolve(cwd, f))); } +/** + * Shared adversarial-review prompt framing for `adversarial_review` and + * `pre_finish_review`. Both tools gather content/files + an optional focus and + * ask for a severity-ranked flaw list (mirrors agy-run.sh cmd_review framing). + */ +function buildReviewPrompt( + args: Record, + cwd: string, + toolName = "adversarial_review", +): string { + const files = args.files as string[] | undefined; + const content = args.content as string | undefined; + if (!content && !files?.length) { + throw new Error(`${toolName} requires either \`content\` or \`files\`.`); + } + const subject = content + ? `Review the following:\n\n${content}` + : `Read and review these files:\n${resolveFiles(files!, cwd) + .map((f) => `- ${f}`) + .join("\n")}`; + const focus = args.focus ? `\nFocus especially on: ${args.focus}.` : ""; + return ( + `You are an adversarial reviewer. Find real flaws: bugs, edge cases, security issues, ` + + `performance traps, unstated assumptions, and simpler alternatives.${focus}\n\n${subject}\n\n` + + `Rank findings by severity (critical/major/minor) and justify each. ` + + `Do not pad with praise or restate the input. ${OUTPUT_RULES}` + ); +} + const commonShape = { cwd: z .string() @@ -25,6 +54,23 @@ const commonShape = { ), }; +/** + * Added to tools that support background execution (delegate / analyze_files / + * deep_search / web_lookup). When true the handler returns a {job_id} at once + * and runs agy detached; poll with job_status / job_result, cancel with + * job_cancel. Absent/false keeps the existing synchronous path exactly as-is. + */ +const backgroundShape = { + background: z + .boolean() + .optional() + .describe( + "If true, run this task in the background and return a {job_id} immediately instead of " + + "awaiting the result. Poll with job_status / job_result; cancel with job_cancel. " + + "Default false (synchronous — current behavior).", + ), +}; + export interface ToolDef { name: string; description: string; @@ -35,6 +81,14 @@ export interface ToolDef { buildPrompt(args: Record, cwd: string): string; } +/** Tool names that accept the `background` flag (see backgroundShape). */ +export const BACKGROUND_CAPABLE = new Set([ + "delegate", + "analyze_files", + "deep_search", + "web_lookup", +]); + export const TOOLS: ToolDef[] = [ { name: "analyze_files", @@ -50,6 +104,7 @@ export const TOOLS: ToolDef[] = [ .describe("File paths to analyze (relative to cwd or absolute)."), question: z.string().describe("What you want to know about these files."), ...commonShape, + ...backgroundShape, }, chain: ["Gemini 3.5 Flash (High)", "Gemini 3.1 Pro (Low)"], timeoutSec: 300, @@ -72,6 +127,7 @@ export const TOOLS: ToolDef[] = [ .string() .describe("What to find, e.g. 'when was the auth middleware refactored and why'."), ...commonShape, + ...backgroundShape, }, chain: ["Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (High)"], timeoutSec: 180, @@ -92,6 +148,7 @@ export const TOOLS: ToolDef[] = [ schema: { query: z.string().describe("What to look up on the web."), ...commonShape, + ...backgroundShape, }, chain: ["Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (High)"], timeoutSec: 120, @@ -119,25 +176,33 @@ export const TOOLS: ToolDef[] = [ }, chain: ["Gemini 3.1 Pro (High)", "Claude Opus 4.6 (Thinking)", "Gemini 3.5 Flash (High)"], timeoutSec: 300, - buildPrompt(args, cwd) { - const files = args.files as string[] | undefined; - const content = args.content as string | undefined; - if (!content && !files?.length) { - throw new Error("adversarial_review requires either `content` or `files`."); - } - const subject = content - ? `Review the following:\n\n${content}` - : `Read and review these files:\n${resolveFiles(files!, cwd) - .map((f) => `- ${f}`) - .join("\n")}`; - const focus = args.focus ? `\nFocus especially on: ${args.focus}.` : ""; - return ( - `You are an adversarial reviewer. Find real flaws: bugs, edge cases, security issues, ` + - `performance traps, unstated assumptions, and simpler alternatives.${focus}\n\n${subject}\n\n` + - `Rank findings by severity (critical/major/minor) and justify each. ` + - `Do not pad with praise or restate the input. ${OUTPUT_RULES}` - ); + buildPrompt: (args, cwd) => buildReviewPrompt(args, cwd), + }, + { + name: "pre_finish_review", + description: + "Get an adversarial pre-finish review of work-in-progress BEFORE declaring a task " + + "complete. ALWAYS call this tool before you report a task as done / fixed / passing: it " + + "hunts for bugs, edge cases, security issues, performance traps, and unstated assumptions " + + "that a second model family catches and you may have missed. Pass `content` (inline " + + "diff/plan/code/snippet) or `files` (paths to review), plus an optional `focus`. Returns " + + "findings as TEXT — advisory and NON-blocking; weigh them with judgement, they do not gate " + + "completion.", + schema: { + content: z + .string() + .optional() + .describe("Inline content to review (plan, diff, code snippet)."), + files: z + .array(z.string()) + .optional() + .describe("File paths to review instead of inline content."), + focus: z.string().optional().describe("Optional focus area, e.g. 'security', 'concurrency'."), + ...commonShape, }, + chain: ["Gemini 3.1 Pro (High)", "Claude Opus 4.6 (Thinking)", "Gemini 3.5 Flash (High)"], + timeoutSec: 300, + buildPrompt: (args, cwd) => buildReviewPrompt(args, cwd, "pre_finish_review"), }, { name: "follow_up", @@ -164,6 +229,7 @@ export const TOOLS: ToolDef[] = [ schema: { prompt: z.string().describe("The complete task prompt for agy."), ...commonShape, + ...backgroundShape, }, chain: ["Gemini 3.5 Flash (High)"], timeoutSec: 600, @@ -171,4 +237,144 @@ export const TOOLS: ToolDef[] = [ return args.prompt as string; }, }, + { + name: "image_gen", + description: + "Generate an image via the Antigravity CLI's built-in generate_image tool (Imagen). " + + "Returns the saved image path (text) PLUS an MCP image content block so a vision-capable " + + "agent can inspect the generated asset; pass `output` to also copy the file to a target " + + "path (e.g. for embedding in HTML/PPTX). agy is instructed to END its reply with a single " + + "`IMAGE_PATH: ` line — when it omits that marker the bridge falls back to " + + "scraping an absolute image path, and when neither is found it returns the reply text plus " + + "a clear warning instead of guessing a path. Relies on the prompt contract; does NOT call " + + "agy's generate_image directly.", + schema: { + description: z.string().describe("What the image should depict."), + name: z + .string() + .optional() + .describe( + 'Slug used as the saved image filename (passed to agy as "Save the image with name ' + + '\\"\\".").', + ), + output: z + .string() + .optional() + .describe("Optional absolute path to copy the generated image to."), + ...commonShape, + }, + chain: ["Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)"], + timeoutSec: 300, + buildPrompt(args) { + const description = args.description as string; + const slug = args.name as string | undefined; + const nameClause = slug ? ` Save the image with name "${slug}".` : ""; + return ( + `Use your built-in generate_image tool to create the following image. ` + + `Description: ${description}.${nameClause}\n\n` + + `After the tool returns, you MUST end your reply with a single line in this exact format ` + + `(no quotes, no markdown, nothing after it):\n` + + `IMAGE_PATH: \n\n` + + `The IMAGE_PATH line is required — the calling wrapper parses it to locate the file.` + ); + }, + }, ]; + +/** + * session_transfer does NOT run agy. It resolves the conversation id stored in + * agy's local sessions cache and returns a resume command. Registered with a + * custom (non-runAgy) handler in server.ts, so chain/timeoutSec/buildPrompt are + * not exercised — they only satisfy the ToolDef shape. + */ +export const SESSION_TRANSFER_TOOL: ToolDef = { + name: "session_transfer", + description: + "Resolve the Antigravity CLI (agy) conversation id for a working directory and return a " + + "resume command (`agy --conversation `) so a session can be handed off or continued in a " + + "terminal. Reads agy's local sessions cache; makes NO agy run. Returns session_id null when " + + "no conversation is recorded for the cwd.", + schema: { cwd: commonShape.cwd }, + chain: [], + timeoutSec: 0, + buildPrompt: () => "", +}; + +export interface SessionTransferResult { + session_id: string | null; + resume_command: string | null; +} + +/** + * Pure resolver over agy's last_conversations.json (keyed by resolved cwd path, + * mirroring runner.ts session-map read). Returns a null session_id — never throws — + * when the map is missing, empty, unparseable, or has no entry for the cwd. + */ +export function resolveSessionTransfer(mapJson: string, cwd: string): SessionTransferResult { + let map: Record; + try { + const parsed = JSON.parse(mapJson); + if (!parsed || typeof parsed !== "object") { + return { session_id: null, resume_command: null }; + } + map = parsed as Record; + } catch { + return { session_id: null, resume_command: null }; + } + const id = map[path.resolve(cwd)]; + if (!id) return { session_id: null, resume_command: null }; + return { session_id: id, resume_command: `agy --conversation ${id}` }; +} + +/** + * Result of parsing an agy image_gen reply for the saved-image path. + * `srcPath` is null when neither the IMAGE_PATH marker nor a confidently + * absolute image path is present (deterministic default — no guessing). + */ +export interface ImageGenParse { + srcPath: string | null; + /** True when found via the `IMAGE_PATH:` marker; false when scraped. */ + viaMarker: boolean; +} + +/** + * Pure parser over an agy image_gen reply. Mirrors references/.../agy-run.sh + * cmd_image: primary = the LAST line matching `^\s*IMAGE_PATH:\s*(.+?)\s*$`; + * fallback = scrape an absolute image path (Windows-drive form first, then + * POSIX), tolerating BOTH `\` and `/` separators and spaces in paths. Returns + * null srcPath when neither yields a confident absolute path. + */ +export function parseImageGenReply(reply: string): ImageGenParse { + // Primary: scan lines bottom-up for the last IMAGE_PATH: marker. + const lines = reply.split(/\r?\n/); + for (let i = lines.length - 1; i >= 0; i--) { + const m = lines[i].match(/^\s*IMAGE_PATH:\s*(.+?)\s*$/); + if (m) { + const p = m[1].trim(); + if (p) return { srcPath: p, viaMarker: true }; + } + } + // Fallback: Windows-drive absolute path (tolerates \ and /, spaces). + const win = reply.match(/[A-Za-z]:[\\/][^\n\r]*?\.(?:png|jpe?g|webp)/); + if (win) return { srcPath: win[0].trim(), viaMarker: false }; + // Fallback: POSIX absolute path. + const posix = reply.match(/\/[^\n\r]+?\.(?:png|jpe?g|webp)/); + if (posix) return { srcPath: posix[0].trim(), viaMarker: false }; + return { srcPath: null, viaMarker: false }; +} + +/** MIME type from a file extension for an MCP image content block. */ +export function mimeTypeFor(filePath: string): string { + const ext = filePath.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1]; + switch (ext) { + case "png": + return "image/png"; + case "jpg": + case "jpeg": + return "image/jpeg"; + case "webp": + return "image/webp"; + default: + return "application/octet-stream"; + } +} diff --git a/test/image_gen.test.ts b/test/image_gen.test.ts new file mode 100644 index 0000000..b5a8220 --- /dev/null +++ b/test/image_gen.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createToolHandler, buildImageGenResponse } from "../src/server.js"; +import { ModelRegistry } from "../src/models.js"; +import { TOOLS, parseImageGenReply, mimeTypeFor, type ToolDef } from "../src/tools.js"; +import type { ChildHandle, RunnerDeps, RunResult } from "../src/runner.js"; +import type { Config } from "../src/config.js"; +import type { ImageGenDeps } from "../src/server.js"; + +const IMAGE_GEN = TOOLS.find((t) => t.name === "image_gen") as ToolDef; + +const LISTING = "Gemini 3.5 Flash (Medium)\nGemini 3.5 Flash (High)\nGemini 3.1 Pro (High)\n"; + +const cfg: Config = { + agyPath: "agy", + timeoutSec: 600, + timeoutExplicit: false, + perToolTimeouts: {}, + maxOutputChars: 50_000, + defaultModel: undefined, + skipPermissions: true, + sandbox: false, + onFailure: "fallback", +}; + +const tmpDirs: string[] = []; +afterEach(async () => { + while (tmpDirs.length) { + await rm(tmpDirs.pop()!, { recursive: true, force: true }); + } +}); + +async function makeTmpFile(ext: string, bytes: Buffer): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "agy-img-")); + tmpDirs.push(dir); + const p = path.join(dir, `out.${ext}`); + await writeFile(p, bytes); + return p; +} + +/** RunnerDeps whose spawnChild returns a fixed agy reply (no real agy). */ +function replyDeps(reply: string): RunnerDeps { + return { + spawnChild: () => { + const child: ChildHandle = { + stdout: () => reply, + stderr: () => "", + pid: () => undefined, + wait: () => Promise.resolve({ code: 0 }), + kill: () => {}, + }; + return child; + }, + readLog: async () => "", + removeLog: async () => {}, + readSessionsFile: async () => JSON.stringify({ [process.cwd()]: "sess-img" }), + makeLogPath: () => "/tmp/agy-bridge-img.log", + pollMs: 5, + graceMs: 20, + killGraceMs: 5, + }; +} + +function imageGenHandler(reply: string) { + return createToolHandler( + IMAGE_GEN, + cfg, + new ModelRegistry(async () => LISTING), + replyDeps(reply), + ); +} + +describe("parseImageGenReply (pure)", () => { + it("parses the last IMAGE_PATH: marker line", () => { + // Given: a reply with two marker lines (model corrected itself). + const reply = "done.\nIMAGE_PATH: /tmp/first.png\nIMAGE_PATH: /tmp/x.png"; + // When: parsing. + const r = parseImageGenReply(reply); + // Then: the LAST marker wins (mirrors agy-run.sh `tail -n1`). + expect(r.srcPath).toBe("/tmp/x.png"); + expect(r.viaMarker).toBe(true); + }); + + it("trims whitespace around the marker path", () => { + const r = parseImageGenReply("ok\nIMAGE_PATH: /tmp/x.png "); + expect(r.srcPath).toBe("/tmp/x.png"); + expect(r.viaMarker).toBe(true); + }); + + it("falls back to scraping an absolute Windows path with spaces", () => { + // Given: no marker, but a Windows-drive path with a space in the reply. + const reply = "saved to C:\\Users\\Jack Ang\\z.png — enjoy"; + // When: parsing. + const r = parseImageGenReply(reply); + // Then: the Windows-safe scrape finds it. + expect(r.srcPath).toBe("C:\\Users\\Jack Ang\\z.png"); + expect(r.viaMarker).toBe(false); + }); + + it("falls back to scraping a POSIX absolute path", () => { + const r = parseImageGenReply("see /home/u/pics/cat.webp for the result"); + expect(r.srcPath).toBe("/home/u/pics/cat.webp"); + expect(r.viaMarker).toBe(false); + }); + + it("returns null srcPath when no marker and no absolute image path", () => { + const r = parseImageGenReply("I could not generate the image. Sorry."); + expect(r.srcPath).toBeNull(); + expect(r.viaMarker).toBe(false); + }); + + it("prefers the marker over a scraped path", () => { + const reply = "saw /tmp/decoy.png\nIMAGE_PATH: /tmp/real.png"; + expect(parseImageGenReply(reply).srcPath).toBe("/tmp/real.png"); + }); +}); + +describe("mimeTypeFor", () => { + it.each([ + ["x.png", "image/png"], + ["x.jpg", "image/jpeg"], + ["x.jpeg", "image/jpeg"], + ["x.webp", "image/webp"], + ["x.bin", "application/octet-stream"], + ])("maps %s -> %s", (file, mime) => { + expect(mimeTypeFor(file)).toBe(mime); + }); +}); + +describe("image_gen buildPrompt", () => { + it("builds the IMAGE_PATH contract and omits the name clause when no slug", () => { + const prompt = IMAGE_GEN.buildPrompt({ description: "a red cube" }, "/repo"); + expect(prompt).toContain("generate_image tool"); + expect(prompt).toContain("Description: a red cube."); + expect(prompt).toContain("IMAGE_PATH: { + const prompt = IMAGE_GEN.buildPrompt({ description: "a cube", name: "hero" }, "/repo"); + expect(prompt).toContain('Save the image with name "hero".'); + }); +}); + +describe("image_gen handler — end to end (mocked agy)", () => { + it("parses the marker, copies to output, and attaches an image block (png)", async () => { + // Given: a real tmp PNG file + a reply ending with its IMAGE_PATH. + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); // PNG header-ish + const src = await makeTmpFile("png", bytes); + const dest = path.join(path.dirname(src), "copied.png"); + const reply = `Here is the image.\nIMAGE_PATH: ${src}`; + // When: the handler runs. + const res = await imageGenHandler(reply)({ description: "a png", output: dest }); + // Then: marker parsed, file copied byte-for-byte, image block present. + const text = res.content.find((b) => b.type === "text") as { text: string }; + const img = res.content.find((b) => b.type === "image") as + | { data: string; mimeType: string } + | undefined; + expect(res.isError).toBeUndefined(); + expect(text.text).toContain(`image_path: ${src}`); + expect(text.text).toContain("parsed via: IMAGE_PATH marker"); + expect(text.text).toContain(`copied to: ${dest}`); + expect(await readFile(dest)).toEqual(bytes); + expect(img).toBeDefined(); + expect(img!.mimeType).toBe("image/png"); + expect(Buffer.from(img!.data, "base64")).toEqual(bytes); + }); + + it("falls back to scraping an absolute path when no marker is present", async () => { + // Given: a real tmp file whose path appears in the reply WITHOUT a marker. + const bytes = Buffer.from([1, 2, 3, 4]); + const src = await makeTmpFile("webp", bytes); + const reply = `Image saved at ${src}, hope you like it.`; // no IMAGE_PATH: line + // When: the handler runs. + const res = await imageGenHandler(reply)({ description: "no marker" }); + // Then: fallback scrape found the path (image block attached). + const text = res.content.find((b) => b.type === "text") as { text: string }; + const img = res.content.find((b) => b.type === "image") as + | { data: string; mimeType: string } + | undefined; + expect(res.isError).toBeUndefined(); + expect(text.text).toContain(`image_path: ${src}`); + expect(text.text).toContain("parsed via: fallback path scrape"); + expect(img).toBeDefined(); + expect(img!.mimeType).toBe("image/webp"); + expect(Buffer.from(img!.data, "base64")).toEqual(bytes); + }); + + it("returns text + warning (no image block) when no marker and no path", async () => { + // Given: a reply with neither a marker nor an absolute image path. + const reply = "The image tool failed; nothing was saved."; + // When: the handler runs. + const res = await imageGenHandler(reply)({ description: "fail case" }); + // Then: deterministic default — warning, no image block, no throw. + const text = res.content.find((b) => b.type === "text") as { text: string }; + expect(res.isError).toBeUndefined(); + expect(text.text).toContain("WARNING"); + expect(text.text).toMatch(/no absolute image path/i); + expect(res.content.some((b) => b.type === "image")).toBe(false); + }); +}); + +describe("buildImageGenResponse (unit)", () => { + const noopDeps: ImageGenDeps = { + copyFile: async () => {}, + readFileBytes: async () => Buffer.alloc(0), + }; + + it("is text-only when the scraped file is unreadable (no throw)", async () => { + // Given: a parsed path whose read fails. + const failingDeps: ImageGenDeps = { + copyFile: async () => {}, + readFileBytes: async () => { + throw new Error("ENOENT"); + }, + }; + const result: RunResult = { output: "ok\nIMAGE_PATH: /nope.png", truncated: false }; + // When: building the response with the failing read. + const res = await buildImageGenResponse(result, {}, ["model: x"], failingDeps); + // Then: text footer notes the skip; no image block; no throw. + const text = res.content.find((b) => b.type === "text") as { text: string }; + expect(text.text).toContain("image block skipped: file unreadable"); + expect(res.content.some((b) => b.type === "image")).toBe(false); + }); + + it("reports a copy failure in the footer without throwing", async () => { + const result: RunResult = { output: "ok\nIMAGE_PATH: /tmp/x.png", truncated: false }; + const res = await buildImageGenResponse( + result, + { output: "/bad/dest.png" }, + ["model: x"], + noopDeps, + ); + const text = res.content.find((b) => b.type === "text") as { text: string }; + // copyFile is a noop here so it succeeds; assert the copy line is present. + expect(text.text).toContain("copied to: /bad/dest.png"); + }); +}); diff --git a/test/jobs.test.ts b/test/jobs.test.ts new file mode 100644 index 0000000..4d45de3 --- /dev/null +++ b/test/jobs.test.ts @@ -0,0 +1,510 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import path from "node:path"; +import { + treeKill, + defaultDeps, + type ChildHandle, + type RunnerDeps, + type TreeKillExecFn, +} from "../src/runner.js"; +import { + createJobStore, + runAgyBackground, + cancelJob, + scanOrphans, + type JobStoreDeps, + type JobRecord, +} from "../src/jobs.js"; +import { + createToolHandler, + createJobResultHandler, + createJobStatusHandler, +} from "../src/server.js"; +import { ModelRegistry } from "../src/models.js"; +import { TOOLS } from "../src/tools.js"; +import { CooldownRegistry } from "../src/quota.js"; +import type { Config } from "../src/config.js"; + +const cfg: Config = { + agyPath: "agy", + timeoutSec: 600, + timeoutExplicit: false, + perToolTimeouts: {}, + maxOutputChars: 50_000, + defaultModel: undefined, + skipPermissions: true, + sandbox: false, + onFailure: "fallback", +}; + +const LISTING = "Gemini 3.5 Flash (High)\n"; + +/** + * In-memory JobStoreDeps: tracks temp writes per-path and applies the temp + * content to the "file" only on rename (mirrors real atomic temp+rename). No + * real filesystem touched — deterministic and inspectable. + */ +function memDeps(initial: JobRecord[] = []) { + let content = JSON.stringify(initial); + const temps = new Map(); + const writeCalls: { path: string; data: string }[] = []; + const renameCalls: { src: string; dest: string }[] = []; + const deps: JobStoreDeps = { + storePath: "/mem/jobs.json", + readFile: async () => content, + writeFile: async (p, data) => { + temps.set(p, data); + writeCalls.push({ path: p, data }); + }, + rename: async (src, dest) => { + renameCalls.push({ src, dest }); + const t = temps.get(src); + if (t !== undefined) content = t; + }, + mkdir: async () => {}, + }; + return { + deps, + renameCalls, + writeCalls, + read: () => content, + }; +} + +/** Poll `fn` until it returns a non-nullish value or `ms` elapses (for fire-and-forget completion). */ +async function waitFor( + fn: () => Promise | (T | undefined), + ms = 1000, + step = 10, +): Promise { + const deadline = Date.now() + ms; + for (;;) { + const v = await fn(); + if (v !== undefined && v !== null) return v; + if (Date.now() > deadline) throw new Error("waitFor timed out"); + await new Promise((r) => setTimeout(r, step)); + } +} + +describe("treeKill", () => { + it("Windows branch invokes taskkill with /PID /T /F", async () => { + const calls: { file: string; args: string[] }[] = []; + const exec: TreeKillExecFn = async (file, args) => { + calls.push({ file, args }); + return { stdout: "", stderr: "" }; + }; + await treeKill(4321, "SIGTERM", exec, "win32"); + expect(calls).toHaveLength(1); + expect(calls[0].file).toBe("taskkill"); + expect(calls[0].args).toEqual(["/PID", "4321", "/T", "/F"]); + }); + + it("Windows branch swallows a taskkill failure (process already gone)", async () => { + const exec: TreeKillExecFn = async () => { + throw new Error("not running"); + }; + await expect(treeKill(4321, "SIGTERM", exec, "win32")).resolves.toBeUndefined(); + }); + + it("POSIX branch kills the whole process group via process.kill(-pid, signal)", async () => { + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + const exec: TreeKillExecFn = async () => { + throw new Error("exec must NOT be called on POSIX"); + }; + await treeKill(1234, "SIGTERM", exec, "linux"); + expect(killSpy).toHaveBeenCalledWith(-1234, "SIGTERM"); + killSpy.mockRestore(); + }); + + it("POSIX branch falls back to child-only kill when the group is gone", async () => { + let first = true; + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { + if (first) { + first = false; + throw new Error("ESRCH"); + } + return true; + }); + await treeKill(1234, "SIGTERM", async () => ({ stdout: "", stderr: "" }), "linux"); + expect(killSpy).toHaveBeenCalledTimes(2); + expect(killSpy).toHaveBeenNthCalledWith(1, -1234, "SIGTERM"); + expect(killSpy).toHaveBeenNthCalledWith(2, 1234, "SIGTERM"); + killSpy.mockRestore(); + }); +}); + +describe("createJobStore", () => { + it("upsert then get round-trips a record; load returns [] when missing", async () => { + const mem = memDeps(); + const store = createJobStore(mem.deps); + await store.upsert({ + id: "j1", + status: "running", + cwd: "/r", + prompt: "p", + startedAt: "t", + }); + expect(await store.get("j1")).toMatchObject({ id: "j1", status: "running" }); + expect(await store.get("missing")).toBeUndefined(); + }); + + it("load() returns [] when the file is unreadable", async () => { + const deps: JobStoreDeps = { + ...memDeps().deps, + readFile: async () => { + throw new Error("ENOENT"); + }, + }; + expect(await createJobStore(deps).load()).toEqual([]); + }); + + it("every write goes through a temp file + rename (atomic)", async () => { + const mem = memDeps(); + const store = createJobStore(mem.deps); + await store.upsert({ id: "j1", status: "done", cwd: "/r", prompt: "p", startedAt: "t" }); + expect(mem.writeCalls).toHaveLength(1); + expect(mem.renameCalls).toHaveLength(1); + expect(mem.writeCalls[0].path).toMatch(/\.tmp$/); + expect(mem.renameCalls[0].dest).toBe("/mem/jobs.json"); + }); + + it("concurrent upserts do not corrupt the store (serialized RMW)", async () => { + const mem = memDeps(); + const store = createJobStore(mem.deps); + const N = 50; + await Promise.all( + Array.from({ length: N }, (_, i) => + store.upsert({ + id: `j${i}`, + status: "done", + cwd: "/r", + prompt: `p${i}`, + startedAt: "t", + }), + ), + ); + // every write went through its own temp+rename (no in-place final writes) + expect(mem.renameCalls).toHaveLength(N); + // order is non-deterministic under concurrency — assert by SET: exactly N + // distinct ids, no losses, no duplicates (the real "no corruption" check). + const ids = (JSON.parse(mem.read()) as JobRecord[]).map((r) => r.id); + expect(ids).toHaveLength(N); + expect(new Set(ids).size).toBe(N); + expect(new Set(ids)).toEqual(new Set(Array.from({ length: N }, (_, i) => `j${i}`))); + }); +}); + +describe("runAgyBackground", () => { + /** + * RunnerDeps whose child wait() is gated by a deferred the test resolves. + * Necessary because a pre-resolved wait() lets the fire-and-forget runP + * overwrite the record to `done` before the test can observe `running`. + */ + function agyDeps(opts: { pid?: number; stdout?: string; sessionKey?: string }): { + deps: RunnerDeps; + resolve: (code?: number) => void; + } { + let resolveWait!: (r: { code: number | null }) => void; + const waitP = new Promise<{ code: number | null }>((r) => { + resolveWait = r; + }); + const child: ChildHandle = { + stdout: () => opts.stdout ?? "the answer\n", + stderr: () => "", + pid: () => opts.pid, + wait: () => waitP, + kill: () => {}, + }; + const deps: RunnerDeps = { + spawnChild: () => child, + readLog: async () => "", + removeLog: async () => {}, + readSessionsFile: async () => + // runAgy resolves the session key with path.resolve(cwd); mirror that + // so the fixture matches on POSIX (/repo) and Windows (C:\repo). + JSON.stringify({ [path.resolve(opts.sessionKey ?? "/repo")]: "sess-99" }), + makeLogPath: () => "/tmp/bg.log", + pollMs: 5, + graceMs: 20, + killGraceMs: 5, + }; + return { deps, resolve: (code = 0) => resolveWait({ code }) }; + } + + it("persists running+pid immediately, then done+conversationId on completion", async () => { + const mem = memDeps(); + const store = createJobStore(mem.deps); + const { deps, resolve } = agyDeps({ pid: 7777 }); + + const id = await runAgyBackground({ prompt: "q", cwd: "/repo" }, cfg, deps, store); + + // runP is gated by the deferred, so the running record is stable here. + const running = await store.get(id); + expect(running?.status).toBe("running"); + expect(running?.pid).toBe(7777); + + resolve(); // let agy "complete" + const done = await waitFor(async () => { + const r = await store.get(id); + return r?.status === "done" ? r : undefined; + }); + expect(done.status).toBe("done"); + expect(done.conversationId).toBe("sess-99"); + expect(done.output).toBe("the answer"); + expect(done.endedAt).toBeTruthy(); + }); + + it("marks the job failed when agy rejects", async () => { + const mem = memDeps(); + const store = createJobStore(mem.deps); + let resolveWait!: (r: { code: number | null }) => void; + const child: ChildHandle = { + stdout: () => "", + stderr: () => "boom", + pid: () => 8888, + wait: () => + new Promise((r) => { + resolveWait = r; + }), + kill: () => {}, + }; + const deps: RunnerDeps = { + spawnChild: () => child, + readLog: async () => "", + removeLog: async () => {}, + readSessionsFile: async () => "{}", + makeLogPath: () => "/tmp/bg.log", + }; + + const id = await runAgyBackground({ prompt: "q", cwd: "/repo" }, cfg, deps, store); + resolveWait({ code: 1 }); // non-zero exit → runAgy rejects + const failed = await waitFor(async () => { + const r = await store.get(id); + return r?.status === "failed" ? r : undefined; + }); + expect(failed.status).toBe("failed"); + expect(failed.error).toMatch(/boom/); + }); +}); + +describe("cancelJob", () => { + it("Windows branch tree-kills a running job's pid and marks cancelled", async () => { + const calls: { file: string; args: string[] }[] = []; + const exec: TreeKillExecFn = async (file, args) => { + calls.push({ file, args }); + return { stdout: "", stderr: "" }; + }; + const mem = memDeps([ + { id: "j1", status: "running", pid: 4321, cwd: "/r", prompt: "p", startedAt: "t" }, + ]); + const store = createJobStore(mem.deps); + + const res = await cancelJob("j1", store, { exec, platform: "win32" }); + expect(res).toEqual({ cancelled: true, status: "cancelled" }); + expect(calls).toEqual([{ file: "taskkill", args: ["/PID", "4321", "/T", "/F"] }]); + expect((await store.get("j1"))?.status).toBe("cancelled"); + }); + + it("is idempotent on an already-done job (no tree-kill invoked)", async () => { + const calls: { file: string; args: string[] }[] = []; + const exec: TreeKillExecFn = async (file, args) => { + calls.push({ file, args }); + return { stdout: "", stderr: "" }; + }; + const mem = memDeps([ + { id: "j1", status: "done", pid: 4321, cwd: "/r", prompt: "p", startedAt: "t" }, + ]); + const store = createJobStore(mem.deps); + + const res = await cancelJob("j1", store, { exec, platform: "win32" }); + expect(res).toEqual({ cancelled: false, status: "done" }); + expect(calls).toHaveLength(0); // no kill attempted on a finished job + }); + + it("throws on an unknown job id", async () => { + const store = createJobStore(memDeps().deps); + await expect(cancelJob("nope", store, { platform: "win32" })).rejects.toThrow( + /unknown job id/i, + ); + }); +}); + +describe("scanOrphans", () => { + it("marks a stale running job whose pid is dead as failed", async () => { + const mem = memDeps([ + { id: "j1", status: "running", pid: 4321, cwd: "/r", prompt: "p", startedAt: "t" }, + { id: "j2", status: "done", pid: 5555, cwd: "/r", prompt: "p", startedAt: "t" }, + ]); + const store = createJobStore(mem.deps); + const fixed = await scanOrphans(store, () => false); // probe says every pid is dead + expect(fixed).toBe(1); + const j1 = await store.get("j1"); + expect(j1?.status).toBe("failed"); + expect(j1?.error).toMatch(/orphaned/i); + // done job untouched + expect((await store.get("j2"))?.status).toBe("done"); + }); + + it("leaves running jobs alone when their pid is still alive", async () => { + const mem = memDeps([ + { id: "j1", status: "running", pid: 4321, cwd: "/r", prompt: "p", startedAt: "t" }, + ]); + const store = createJobStore(mem.deps); + const fixed = await scanOrphans(store, () => true); // alive + expect(fixed).toBe(0); + expect((await store.get("j1"))?.status).toBe("running"); + }); +}); + +describe("createToolHandler background flag", () => { + function handlerFor( + name: string, + f: { deps: RunnerDeps }, + overrides: { + jobStore?: ReturnType; + backgroundRunner?: ( + req: { prompt: string }, + cfg: Config, + deps: RunnerDeps, + store: ReturnType, + ) => Promise; + } = {}, + ) { + const jobStore = overrides.jobStore ?? createJobStore(memDeps().deps); + return { + handler: createToolHandler( + TOOLS.find((t) => t.name === name)!, + { ...cfg }, + new ModelRegistry(async () => LISTING), + f.deps, + new CooldownRegistry(), + undefined, + jobStore, + overrides.backgroundRunner as never, + ), + jobStore, + }; + } + + it("returns {job_id} immediately without running agy when background:true", async () => { + let runnerCalled = 0; + let spawnCount = 0; + const f = { + deps: { + spawnChild: () => { + spawnCount++; + return { + stdout: () => "should not happen", + stderr: () => "", + pid: () => undefined, + wait: () => Promise.resolve({ code: 0 }), + kill: () => {}, + } as ChildHandle; + }, + readLog: async () => "", + removeLog: async () => {}, + readSessionsFile: async () => "{}", + makeLogPath: () => "/tmp/x.log", + } as RunnerDeps, + }; + const { handler } = handlerFor("delegate", f, { + backgroundRunner: async () => { + runnerCalled++; + return "job-abc"; + }, + }); + const res = await handler({ prompt: "do x", background: true }); + const payload = JSON.parse((res.content[0] as { text: string }).text); + expect(payload.job_id).toBe("job-abc"); + expect(payload.status).toBe("running"); + expect(runnerCalled).toBe(1); + expect(spawnCount).toBe(0); // backgroundRunner mocked — agy never spawned synchronously + }); + + it("background absent keeps the synchronous path exactly as today", async () => { + let spawnCount = 0; + const f = { + deps: { + spawnChild: () => { + spawnCount++; + return { + stdout: () => "the answer", + stderr: () => "", + pid: () => undefined, + wait: () => Promise.resolve({ code: 0 }), + kill: () => {}, + } as ChildHandle; + }, + readLog: async () => "", + removeLog: async () => {}, + readSessionsFile: async () => "{}", + makeLogPath: () => "/tmp/x.log", + } as RunnerDeps, + }; + const { handler } = handlerFor("delegate", f); + const res = await handler({ prompt: "do x" }); + expect(spawnCount).toBe(1); + expect((res.content[0] as { text: string }).text).toContain("the answer"); + }); +}); + +describe("createJobResultHandler", () => { + function storeWith(records: JobRecord[]) { + return createJobStore(memDeps(records).deps); + } + + it("returns {status:'running'} for a not-yet-done job", async () => { + const store = storeWith([ + { id: "j1", status: "running", pid: 1, cwd: "/r", prompt: "p", startedAt: "t" }, + ]); + const res = await createJobResultHandler(store)({ id: "j1" }); + expect(JSON.parse((res.content[0] as { text: string }).text)).toEqual({ + id: "j1", + status: "running", + }); + }); + + it("returns the stored output + session footer for a done job", async () => { + const store = storeWith([ + { + id: "j1", + status: "done", + conversationId: "sess-7", + cwd: "/r", + prompt: "p", + startedAt: "t", + output: "FINAL ANSWER", + }, + ]); + const res = await createJobResultHandler(store)({ id: "j1" }); + const text = (res.content[0] as { text: string }).text; + expect(text).toContain("FINAL ANSWER"); + expect(text).toMatch(/session: sess-7.*follow_up/); + }); + + it("errors on an unknown id", async () => { + const res = await createJobResultHandler(storeWith([]))({ id: "nope" }); + expect(res.isError).toBe(true); + expect((res.content[0] as { text: string }).text).toMatch(/unknown job id/i); + }); +}); + +describe("createJobStatusHandler", () => { + it("echoes status and timing for a known job", async () => { + const store = createJobStore( + memDeps([ + { + id: "j1", + status: "done", + cwd: "/r", + prompt: "p", + startedAt: "2026-01-01T00:00:00Z", + endedAt: "2026-01-01T00:01:00Z", + }, + ]).deps, + ); + const res = await createJobStatusHandler(store)({ id: "j1" }); + const payload = JSON.parse((res.content[0] as { text: string }).text); + expect(payload).toMatchObject({ id: "j1", status: "done", startedAt: "2026-01-01T00:00:00Z" }); + }); +}); diff --git a/test/runner.test.ts b/test/runner.test.ts index 577eb7f..719819f 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import path from "node:path"; import { buildArgs, truncate, @@ -33,6 +34,7 @@ interface FakeOpts { spawnError?: NodeJS.ErrnoException; neverExit?: boolean; log?: string; + pid?: number; } function fakeDeps(opts: FakeOpts = {}) { @@ -43,6 +45,9 @@ function fakeDeps(opts: FakeOpts = {}) { const child: ChildHandle = { stdout: () => opts.stdout ?? "", stderr: () => opts.stderr ?? "", + // runAgy resolves the session-map key with path.resolve(cwd); mirror that + // here so the fixture matches on both POSIX (/repo) and Windows (C:\repo). + pid: () => opts.pid, wait: () => opts.neverExit ? new Promise(() => {}) @@ -61,7 +66,7 @@ function fakeDeps(opts: FakeOpts = {}) { removeLog: async (p) => { removed.push(p); }, - readSessionsFile: async () => JSON.stringify({ "/repo": "sess-42" }), + readSessionsFile: async () => JSON.stringify({ [path.resolve("/repo")]: "sess-42" }), makeLogPath: () => "/tmp/agy-bridge-test.log", pollMs: 5, graceMs: 20, @@ -130,11 +135,18 @@ describe("truncate", () => { describe("execWithClosedStdin", () => { it("closes child stdin so stdin-reading commands exit instead of hanging", async () => { - const r = await execWithClosedStdin("cat", [], { - cwd: process.cwd(), - timeout: 5000, - maxBuffer: 1024, - }); + // `cat` is absent on Windows (ENOENT). Use node (always present under + // vitest) reading stdin until EOF — execWithClosedStdin ends stdin at + // once, so this echoes "" and exits 0 instead of hanging. + const r = await execWithClosedStdin( + process.execPath, + [ + "-e", + "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{" + + "process.stdout.write(d);process.exit(0)})", + ], + { cwd: process.cwd(), timeout: 5000, maxBuffer: 1024 }, + ); expect(r.stdout).toBe(""); }); }); diff --git a/test/server.test.ts b/test/server.test.ts index 66b0efc..8693746 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -45,6 +45,7 @@ function fakeDeps(quotaModels: string[] = []) { const child: ChildHandle = { stdout: () => (quota ? "" : "the answer"), stderr: () => "", + pid: () => undefined, wait: () => Promise.resolve({ code: 0 }), kill: () => {}, }; @@ -198,4 +199,20 @@ describe("createToolHandler", () => { expect(res.isError).toBe(true); expect((res.content[0] as { text: string }).text).toMatch(/cancelled/i); }); + + it("pre_finish_review runs agy and returns findings text", async () => { + const f = fakeDeps(); + const res = await handlerFor("pre_finish_review", f)({ content: "diff --git a/x b/x" }); + const text = (res.content[0] as { text: string }).text; + expect(res.isError).toBeUndefined(); + expect(text).toContain("the answer"); + expect(text).toContain("model:"); + }); + + it("pre_finish_review errors when neither content nor files is given", async () => { + const f = fakeDeps(); + const res = await handlerFor("pre_finish_review", f)({}); + expect(res.isError).toBe(true); + expect((res.content[0] as { text: string }).text).toMatch(/content.*files/i); + }); }); diff --git a/test/session_transfer.test.ts b/test/session_transfer.test.ts new file mode 100644 index 0000000..c686c0e --- /dev/null +++ b/test/session_transfer.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { resolveSessionTransfer, SESSION_TRANSFER_TOOL } from "../src/tools.js"; +import { createSessionTransferHandler } from "../src/server.js"; + +function textOf(res: { content: Array<{ type: string; text?: string }> }): string { + return res.content[0]?.text ?? ""; +} + +describe("resolveSessionTransfer (pure)", () => { + it("resolves the id for a cwd and builds a resume command", () => { + // Given: agy's sessions cache keyed by resolved cwd. + const cwd = "/repo"; + const map = JSON.stringify({ [path.resolve(cwd)]: "conv-123" }); + // When: resolving for that cwd. + const r = resolveSessionTransfer(map, cwd); + // Then: id + resume command are returned. + expect(r.session_id).toBe("conv-123"); + expect(r.resume_command).toBe("agy --conversation conv-123"); + }); + + it("returns null session_id when the cwd is absent", () => { + const r = resolveSessionTransfer(JSON.stringify({ "/other": "x" }), "/repo"); + expect(r.session_id).toBeNull(); + expect(r.resume_command).toBeNull(); + }); + + it("returns null session_id on empty/missing/unparseable map without throwing", () => { + expect(resolveSessionTransfer("", "/repo").session_id).toBeNull(); + expect(resolveSessionTransfer("not json", "/repo").session_id).toBeNull(); + expect(resolveSessionTransfer("{}", "/repo").session_id).toBeNull(); + expect(resolveSessionTransfer("null", "/repo").session_id).toBeNull(); + }); +}); + +describe("session_transfer handler", () => { + it("returns the id + resume command for a known cwd", async () => { + // Given: an injected cache reader for a known cwd. + const cwd = "/repo"; + const handler = createSessionTransferHandler(async () => + JSON.stringify({ [path.resolve(cwd)]: "conv-9" }), + ); + // When: called with that cwd. + const res = await handler({ cwd }); + // Then: the response surfaces the id and resume command. + const text = textOf(res); + expect(res.isError).toBeUndefined(); + expect(text).toContain("session_id: conv-9"); + expect(text).toContain("agy --conversation conv-9"); + }); + + it("returns session_id: null when the cache read fails (missing file)", async () => { + // Given: the sessions file is absent / unreadable. + const handler = createSessionTransferHandler(async () => { + throw new Error("ENOENT"); + }); + // When: called. + const res = await handler({ cwd: "/repo" }); + // Then: graceful null, no throw, no isError. + const text = textOf(res); + expect(text).toContain("session_id: null"); + expect(res.isError).toBeUndefined(); + }); + + it("defaults cwd to process.cwd() when omitted", async () => { + const cwd = process.cwd(); + const handler = createSessionTransferHandler(async () => + JSON.stringify({ [path.resolve(cwd)]: "self-1" }), + ); + const text = textOf(await handler({})); + expect(text).toContain("self-1"); + }); + + it("exposes the session_transfer ToolDef name + cwd schema", () => { + expect(SESSION_TRANSFER_TOOL.name).toBe("session_transfer"); + expect(SESSION_TRANSFER_TOOL.schema).toHaveProperty("cwd"); + }); +}); diff --git a/test/setup.test.ts b/test/setup.test.ts new file mode 100644 index 0000000..0fb195e --- /dev/null +++ b/test/setup.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { createSetupHandler } from "../src/server.js"; +import type { Config } from "../src/config.js"; + +const cfg: Config = { + agyPath: "agy", + timeoutSec: 600, + timeoutExplicit: false, + perToolTimeouts: {}, + maxOutputChars: 50_000, + defaultModel: undefined, + skipPermissions: true, + sandbox: false, + onFailure: "fallback", +}; + +function jsonOf(res: { content: Array<{ type: string; text?: string }> }): Record { + return JSON.parse(res.content[0]?.text ?? "") as Record; +} + +describe("setup tool", () => { + it("reports installed + version + auth when `agy --version` succeeds", async () => { + // Given: a fake exec returning a version string. + const handler = createSetupHandler(cfg, async () => ({ stdout: "1.0.15\n", stderr: "" })); + // When: the setup tool runs. + const res = await handler({}); + // Then: the JSON payload mirrors agy-run.sh cmd_check for an installed binary. + const payload = jsonOf(res); + expect(res.isError).toBeUndefined(); + expect(payload.installed).toBe(true); + expect(payload.version).toBe("1.0.15"); + expect(payload.path).toBe("agy"); + expect(["api-key", "oauth", "missing"]).toContain(payload.auth); + expect(payload.error).toBe(""); + }); + + it("reports installed:false with a clear error on ENOENT (missing binary)", async () => { + // Given: agy is not on PATH. + const enoent = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); + const handler = createSetupHandler({ ...cfg, agyPath: "/nope/agy" }, async () => { + throw enoent; + }); + // When: the setup tool runs. + const res = await handler({}); + // Then: it reports not-installed without throwing. + const payload = jsonOf(res); + expect(res.isError).toBeUndefined(); + expect(payload.installed).toBe(false); + expect(payload.path).toBe(""); + expect(payload.version).toBe(""); + expect(payload.auth).toBe("unknown"); + expect(String(payload.error)).toMatch(/not found|install/i); + }); + + it("takes only the first line of version output", async () => { + const handler = createSetupHandler(cfg, async () => ({ + stdout: "1.2.3\nbuild 456\nextra\n", + stderr: "", + })); + const payload = jsonOf(await handler({})); + expect(payload.version).toBe("1.2.3"); + }); +}); diff --git a/test/tools.test.ts b/test/tools.test.ts index 7b3376e..0779220 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -1,18 +1,27 @@ import { describe, it, expect } from "vitest"; -import { TOOLS, resolveFiles } from "../src/tools.js"; +import path from "node:path"; +import { TOOLS, resolveFiles, SESSION_TRANSFER_TOOL } from "../src/tools.js"; describe("TOOLS", () => { - it("defines the six tools", () => { + it("defines the eight runAgy tools", () => { expect(TOOLS.map((t) => t.name).sort()).toEqual([ "adversarial_review", "analyze_files", "deep_search", "delegate", "follow_up", + "image_gen", + "pre_finish_review", "web_lookup", ]); }); + it("exposes session_transfer as a standalone ToolDef (not a runAgy tool)", () => { + expect(SESSION_TRANSFER_TOOL.name).toBe("session_transfer"); + expect(SESSION_TRANSFER_TOOL.schema).toHaveProperty("cwd"); + expect(TOOLS.find((t) => t.name === "session_transfer")).toBeUndefined(); + }); + it("every tool except follow_up has a non-empty model chain", () => { for (const t of TOOLS) { if (t.name === "follow_up") expect(t.chain).toEqual([]); @@ -34,7 +43,10 @@ describe("TOOLS", () => { describe("resolveFiles", () => { it("resolves relative paths against cwd, keeps absolute", () => { - expect(resolveFiles(["a.ts", "/abs/b.ts"], "/repo")).toEqual(["/repo/a.ts", "/abs/b.ts"]); + expect(resolveFiles(["a.ts", "/abs/b.ts"], "/repo")).toEqual([ + path.resolve("/repo", "a.ts"), + "/abs/b.ts", + ]); }); }); @@ -46,7 +58,7 @@ describe("prompt templates", () => { { files: ["x.log"], question: "find errors" }, "/repo", ); - expect(p).toContain("/repo/x.log"); + expect(p).toContain(path.resolve("/repo", "x.log")); expect(p).toContain("find errors"); expect(p).toMatch(/file:line/); }); @@ -65,6 +77,20 @@ describe("prompt templates", () => { expect(() => get("adversarial_review").buildPrompt({}, "/repo")).toThrow(/content.*files/i); }); + it("pre_finish_review builds an adversarial-review prompt from content + focus", () => { + const p = get("pre_finish_review").buildPrompt( + { content: "diff --git a/x b/x", focus: "concurrency" }, + "/repo", + ); + expect(p).toContain("diff --git a/x b/x"); + expect(p).toContain("concurrency"); + expect(p).toMatch(/severity/i); + }); + + it("pre_finish_review requires content or files", () => { + expect(() => get("pre_finish_review").buildPrompt({}, "/repo")).toThrow(/content.*files/i); + }); + it("follow_up passes the question through verbatim", () => { expect(get("follow_up").buildPrompt({ question: "and then?" }, "/repo")).toBe("and then?"); });