feat(background): add background registry and status tool with tests
This commit is contained in:
42
src/background-registry.test.ts
Normal file
42
src/background-registry.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
136
src/background-registry.ts
Normal file
136
src/background-registry.ts
Normal file
@@ -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<string, string>;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BackgroundRegistry {
|
||||
recordLaunch(entry: {
|
||||
runId: string;
|
||||
preset: string;
|
||||
task: string;
|
||||
requestedModel?: string;
|
||||
resolvedModel?: string;
|
||||
paths?: Record<string, string>;
|
||||
meta?: Record<string, unknown>;
|
||||
}): void;
|
||||
|
||||
recordUpdate(runId: string, update: Partial<Omit<BackgroundRun, "runId" | "startTime">>): 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<string, BackgroundRun>();
|
||||
|
||||
function recordLaunch(entry: {
|
||||
runId: string;
|
||||
preset: string;
|
||||
task: string;
|
||||
requestedModel?: string;
|
||||
resolvedModel?: string;
|
||||
paths?: Record<string, string>;
|
||||
meta?: Record<string, unknown>;
|
||||
}) {
|
||||
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<Omit<BackgroundRun, "runId" | "startTime">>) {
|
||||
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 };
|
||||
}
|
||||
35
src/background-schema.ts
Normal file
35
src/background-schema.ts
Normal file
@@ -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<typeof BackgroundAgentSchema>;
|
||||
export type BackgroundAgentStatusParams = Static<typeof BackgroundAgentStatusSchema>;
|
||||
27
src/background-status-tool.test.ts
Normal file
27
src/background-status-tool.test.ts
Normal file
@@ -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/);
|
||||
});
|
||||
54
src/background-status-tool.ts
Normal file
54
src/background-status-tool.ts
Normal file
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
12
tmp-debug.mjs
Normal file
12
tmp-debug.mjs
Normal file
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user