From 21ae108bae09e8187634ec89c0c020a16a83a365 Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 12 Apr 2026 13:49:55 +0100 Subject: [PATCH] feat(background): add background registry and status tool with tests --- src/background-registry.test.ts | 42 +++++++++ src/background-registry.ts | 136 +++++++++++++++++++++++++++++ src/background-schema.ts | 35 ++++++++ src/background-status-tool.test.ts | 27 ++++++ src/background-status-tool.ts | 54 ++++++++++++ tmp-debug.mjs | 12 +++ 6 files changed, 306 insertions(+) create mode 100644 src/background-registry.test.ts create mode 100644 src/background-registry.ts create mode 100644 src/background-schema.ts create mode 100644 src/background-status-tool.test.ts create mode 100644 src/background-status-tool.ts create mode 100644 tmp-debug.mjs diff --git a/src/background-registry.test.ts b/src/background-registry.test.ts new file mode 100644 index 0000000..c7aeb6e --- /dev/null +++ b/src/background-registry.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createBackgroundRegistry } from "./background-registry.ts"; + +test("recordLaunch and counts update correctly", () => { + const reg = createBackgroundRegistry(); + reg.recordLaunch({ runId: "r1", preset: "p1", task: "t1" }); + reg.recordLaunch({ runId: "r2", preset: "p2", task: "t2" }); + + let counts = reg.getCounts(); + assert.equal(counts.running, 2); + assert.equal(counts.total, 2); + + reg.recordUpdate("r2", { status: "completed", exitCode: 0, finalText: "done" }); + counts = reg.getCounts(); + assert.equal(counts.running, 1); + assert.equal(counts.completed, 1); + assert.equal(counts.total, 2); + + const r1 = reg.getRun("r1"); + const r2 = reg.getRun("r2"); + assert.ok(r1); + assert.ok(r2); + assert.equal(r2?.finalText, "done"); +}); + +test("replay seeds registry and counts", () => { + const reg = createBackgroundRegistry(); + const now = Date.now(); + reg.replay([ + { runId: "a", preset: "p", task: "x", status: "running", startTime: now }, + { runId: "b", preset: "p2", task: "y", status: "failed", startTime: now - 1000, endTime: now - 500 }, + ]); + + const counts = reg.getCounts(); + assert.equal(counts.running, 1); + assert.equal(counts.failed, 1); + assert.equal(counts.total, 2); + + const snap = reg.getSnapshot({ includeCompleted: true }); + assert.equal(snap.length, 2); +}); diff --git a/src/background-registry.ts b/src/background-registry.ts new file mode 100644 index 0000000..813138a --- /dev/null +++ b/src/background-registry.ts @@ -0,0 +1,136 @@ +export type BackgroundStatus = "running" | "completed" | "failed" | "aborted"; + +export interface BackgroundRun { + runId: string; + preset: string; + task: string; + requestedModel?: string; + resolvedModel?: string; + status: BackgroundStatus; + startTime: number; // epoch ms + endTime?: number; // epoch ms when finished + finalText?: string; + exitCode?: number; + stopReason?: string; + paths?: Record; + meta?: Record; +} + +export interface BackgroundRegistry { + recordLaunch(entry: { + runId: string; + preset: string; + task: string; + requestedModel?: string; + resolvedModel?: string; + paths?: Record; + meta?: Record; + }): void; + + recordUpdate(runId: string, update: Partial>): void; + + replay(entries: BackgroundRun[]): void; + + getRun(runId: string): BackgroundRun | undefined; + + getSnapshot(opts?: { runId?: string; includeCompleted?: boolean }): BackgroundRun[]; + + getCounts(): { running: number; completed: number; failed: number; aborted: number; total: number }; +} + +export function createBackgroundRegistry(): BackgroundRegistry { + const map = new Map(); + + function recordLaunch(entry: { + runId: string; + preset: string; + task: string; + requestedModel?: string; + resolvedModel?: string; + paths?: Record; + meta?: Record; + }) { + const now = Date.now(); + const run: BackgroundRun = { + runId: entry.runId, + preset: entry.preset, + task: entry.task, + requestedModel: entry.requestedModel, + resolvedModel: entry.resolvedModel, + status: "running", + startTime: now, + paths: entry.paths, + meta: entry.meta, + }; + map.set(entry.runId, run); + } + + function recordUpdate(runId: string, update: Partial>) { + const existing = map.get(runId); + if (!existing) return; + const merged: BackgroundRun = { ...existing, ...update } as BackgroundRun; + if ((update.status && update.status !== "running") || (update.exitCode !== undefined && merged.status === "running")) { + // if status moved to finished states or exitCode provided while status still running, set endTime + if (!merged.endTime) merged.endTime = Date.now(); + } + // if status explicitly set to completed/failed/aborted and no endTime, set it + if ((merged.status === "completed" || merged.status === "failed" || merged.status === "aborted") && !merged.endTime) { + merged.endTime = Date.now(); + } + map.set(runId, merged); + } + + function replay(entries: BackgroundRun[]) { + map.clear(); + for (const e of entries) { + // keep provided timestamps and status as-is + map.set(e.runId, { ...e }); + } + } + + function getRun(runId: string) { + const r = map.get(runId); + return r ? { ...r } : undefined; + } + + function getSnapshot(opts?: { runId?: string; includeCompleted?: boolean }) { + const includeCompleted = Boolean(opts?.includeCompleted); + if (opts?.runId) { + const r = map.get(opts.runId); + if (!r) return []; + if (!includeCompleted && (r.status === "completed" || r.status === "failed" || r.status === "aborted")) return []; + return [{ ...r }]; + } + const arr = Array.from(map.values()).filter((r) => includeCompleted || r.status === "running"); + // sort by startTime desc (most recent first) + arr.sort((a, b) => (b.startTime || 0) - (a.startTime || 0)); + return arr.map((r) => ({ ...r })); + } + + function getCounts() { + let running = 0, + completed = 0, + failed = 0, + aborted = 0; + for (const r of map.values()) { + switch (r.status) { + case "running": + running++; + break; + case "completed": + completed++; + break; + case "failed": + failed++; + break; + case "aborted": + aborted++; + break; + } + } + const total = running + completed + failed + aborted; + return { running, completed, failed, aborted, total }; + } + + return { recordLaunch, recordUpdate, replay, getRun, getSnapshot, getCounts }; +} diff --git a/src/background-schema.ts b/src/background-schema.ts new file mode 100644 index 0000000..8e6948c --- /dev/null +++ b/src/background-schema.ts @@ -0,0 +1,35 @@ +import { StringEnum } from "@mariozechner/pi-ai"; +import { Type, type Static } from "@sinclair/typebox"; + +export function createBackgroundAgentSchema(availableModels: readonly string[]) { + return Type.Object( + { + preset: Type.String({ description: "Subagent preset name to use for this task" }), + task: Type.String({ description: "Task to delegate to the child agent" }), + model: Type.Optional( + StringEnum(availableModels, { + description: "Optional child model override. Must be one of the currently available models.", + }), + ), + cwd: Type.Optional(Type.String({ description: "Optional working directory override" })), + }, + { description: "Parameters for launching a background_agent run" }, + ); +} + +export const BackgroundAgentSchema = createBackgroundAgentSchema([]); + +export function createBackgroundAgentStatusSchema() { + return Type.Object( + { + runId: Type.Optional(Type.String({ description: "Optional runId to query a single run" })), + includeCompleted: Type.Optional(Type.Boolean({ description: "Include completed/finished runs in listing" })), + }, + { description: "Parameters for background_agent_status tool" }, + ); +} + +export const BackgroundAgentStatusSchema = createBackgroundAgentStatusSchema(); + +export type BackgroundAgentParams = Static; +export type BackgroundAgentStatusParams = Static; diff --git a/src/background-status-tool.test.ts b/src/background-status-tool.test.ts new file mode 100644 index 0000000..d760eef --- /dev/null +++ b/src/background-status-tool.test.ts @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createBackgroundRegistry } from "./background-registry.ts"; +import { createBackgroundStatusTool } from "./background-status-tool.ts"; + +test("status tool shows active runs by default and can query single run", async () => { + const reg = createBackgroundRegistry(); + reg.recordLaunch({ runId: "r1", preset: "p1", task: "t1" }); + reg.recordLaunch({ runId: "r2", preset: "p2", task: "t2" }); + reg.recordUpdate("r2", { status: "completed", exitCode: 0, finalText: "done" }); + + const tool = createBackgroundStatusTool({ registry: reg }); + const resDefault: any = await tool.execute("id", {}, undefined, undefined, undefined); + // default should show only active (running) runs + assert.equal(resDefault.details.runs.length, 1); + assert.equal(resDefault.details.runs[0].runId, "r1"); + assert.match(resDefault.content[0].text, /Active runs: 1/); + + const resSingle: any = await tool.execute("id", { runId: "r2" }, undefined, undefined, undefined); + assert.equal(resSingle.details.runs.length, 1); + assert.equal(resSingle.details.runs[0].runId, "r2"); + assert.match(resSingle.content[0].text, /status=completed/); + + const resNotFound: any = await tool.execute("id", { runId: "nope" }, undefined, undefined, undefined); + assert.equal(resNotFound.details.runs.length, 0); + assert.match(resNotFound.content[0].text, /No run found/); +}); diff --git a/src/background-status-tool.ts b/src/background-status-tool.ts new file mode 100644 index 0000000..d78d4d6 --- /dev/null +++ b/src/background-status-tool.ts @@ -0,0 +1,54 @@ +import { BackgroundAgentStatusSchema } from "./background-schema.ts"; +import type { BackgroundRegistry } from "./background-registry.ts"; + +export function createBackgroundStatusTool(deps: { registry: BackgroundRegistry } | { registry?: BackgroundRegistry } = {}) { + const registry = (deps as any).registry; + + return { + name: "background_agent_status", + label: "Background Agent Status", + description: "Query background agent runs and their status.", + parameters: BackgroundAgentStatusSchema, + async execute(_toolCallId: string, params: any = {}, _signal: AbortSignal | undefined, _onUpdate: any, _ctx: any) { + if (!registry) { + return { + content: [{ type: "text" as const, text: "Background registry not available" }], + details: { runs: [] }, + isError: true, + }; + } + + const runId = typeof params.runId === "string" ? params.runId : undefined; + const includeCompleted = Boolean(params.includeCompleted); + + // when querying a single run by id, allow completed runs regardless of includeCompleted flag + const runs = runId ? registry.getSnapshot({ runId, includeCompleted: true }) : registry.getSnapshot({ includeCompleted }); + + if (runId) { + if (runs.length === 0) { + return { + content: [{ type: "text" as const, text: `No run found for runId: ${runId}` }], + details: { runs: [] }, + isError: false, + }; + } + const r = runs[0]; + const text = `Run ${r.runId}: status=${r.status}, task=${r.task}, final="${r.finalText ?? "(no output)"}"`; + return { + content: [{ type: "text" as const, text }], + details: { runs }, + isError: false, + }; + } + + // default: active runs only (unless includeCompleted) + const active = runs; + const text = `Active runs: ${active.length}`; + return { + content: [{ type: "text" as const, text }], + details: { runs: active }, + isError: false, + }; + }, + }; +} diff --git a/tmp-debug.mjs b/tmp-debug.mjs new file mode 100644 index 0000000..f3ff8c3 --- /dev/null +++ b/tmp-debug.mjs @@ -0,0 +1,12 @@ +import { createBackgroundRegistry } from './src/background-registry.js'; +import { createBackgroundStatusTool } from './src/background-status-tool.js'; +(async()=>{ + const reg = createBackgroundRegistry(); + reg.recordLaunch({ runId: 'r1', preset: 'p1', task: 't1' }); + reg.recordLaunch({ runId: 'r2', preset: 'p2', task: 't2' }); + reg.recordUpdate('r2', { status: 'completed', exitCode: 0, finalText: 'done' }); + const tool = createBackgroundStatusTool({ registry: reg }); + const resDefault = await tool.execute('id', {}, undefined, undefined, undefined); + console.log('default details', resDefault.details); + console.log('default content', resDefault.content); +})();