- Added the `xd://` virtual device protocol (`internal-urls/xd-protocol.ts`, `tools/xdev.ts`): tools declaring `loadMode: "discoverable"` are unmounted from the request tools array and driven via `read xd://` (list/docs+schema) and `write xd://<tool>` (execute), gated by the `tools.xdev` setting (default on) and inlined into the system prompt. - Merged the `irc`, `job`, and `launch` tools into a single `hub` tool (`tools/hub/`, `async/job-manager.ts`): messaging keeps `send`/`inbox`/`list`, job control maps to `wait`/`cancel`/`jobs`, process supervision keeps `start`/`logs`/`stop`/`restart`/`describe` with `ps`, and the unified `wait` races background jobs against peer messages; SDK `IrcTool`/`JobTool`/`LaunchTool` are replaced by `HubTool`. - Removed the hidden `resolve` tool in favor of the `xd://resolve`/`xd://reject`/`xd://propose` resolution devices, auto-including `write` whenever a deferrable tool or plan mode is present. - Removed the BM25 tool-discovery system: the `search_tool_bm25` tool, the `tool-discovery` module, the `tools.discoveryMode`/`mcp.discoveryMode`/`mcp.discoveryDefaultServers`/`tools.essentialOverride` settings, per-tool MCP selection, and the `mcp_tool_selection` message type. - Unified tool presentation on `ToolLoadMode` (`essential`|`discoverable`), replacing the custom-tool `xdev?: boolean` opt-out; custom, extension, MCP, RPC host, image-generation, and TTS tools now default to `discoverable`, and added a `satisfies` predicate to `SoftToolRequirement`. - Removed the standalone `ssh` command tool and `ssh/ssh-executor` (the `ssh://` read/write/search protocol stays), and made `--tools` address hidden built-ins. - Updated collab-web to render `xd://` dispatches and `hub` op families, dropped the `search_tool_bm25`/`ssh`/`report-finding` renderers, refreshed tool docs and prompts, and migrated the affected tests and changelogs.
160 lines
6.6 KiB
TypeScript
160 lines
6.6 KiB
TypeScript
/**
|
|
* The `job` tool's snapshot contract: `list` and empty-poll results must never
|
|
* come back as empty text, and they must surface running subagents that have
|
|
* no backing job (irc-woken/revived agents, spawns owned by another agent) so
|
|
* the tool's picture matches the UI's running-agent count. Regression for the
|
|
* QA report "job list returned no status output despite known running
|
|
* background jobs and subagents".
|
|
*/
|
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async";
|
|
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
|
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|
import { type CoordinationDetails, HubTool } from "../src/tools/hub";
|
|
|
|
const managers: AsyncJobManager[] = [];
|
|
|
|
function createManager(): AsyncJobManager {
|
|
const manager = new AsyncJobManager({ onJobComplete: () => {} });
|
|
managers.push(manager);
|
|
return manager;
|
|
}
|
|
|
|
function createToolSession(options: {
|
|
manager?: AsyncJobManager;
|
|
registry?: AgentRegistry;
|
|
agentId?: string;
|
|
}): ToolSession {
|
|
return {
|
|
cwd: process.cwd(),
|
|
hasUI: false,
|
|
settings: {
|
|
get: (key: string) => (key === "async.pollWaitDuration" ? "5s" : undefined),
|
|
},
|
|
getSessionFile: () => null,
|
|
getSessionSpawns: () => null,
|
|
getAgentId: () => options.agentId ?? null,
|
|
asyncJobManager: options.manager,
|
|
agentRegistry: options.registry,
|
|
} as unknown as ToolSession;
|
|
}
|
|
|
|
function registerRunningSub(registry: AgentRegistry, id: string, parentId = "Main"): void {
|
|
registry.register({ id, displayName: id, kind: "sub", parentId, session: null });
|
|
}
|
|
|
|
function resultText(result: { content: Array<{ type: string; text?: string }> }): string {
|
|
return result.content.find(part => part.type === "text")?.text ?? "";
|
|
}
|
|
|
|
const neverResolves = () => new Promise<string>(() => {});
|
|
|
|
afterEach(async () => {
|
|
for (const manager of managers.splice(0)) {
|
|
await manager.dispose({ timeoutMs: 200 });
|
|
}
|
|
});
|
|
|
|
describe("hub jobs snapshot", () => {
|
|
test("empty jobs snapshot reports 'no jobs' instead of empty output", async () => {
|
|
const tool = new HubTool(createToolSession({ manager: createManager(), agentId: "Main" }));
|
|
|
|
const result = await tool.execute("call", { op: "jobs" });
|
|
|
|
expect(resultText(result)).toBe("No background jobs.");
|
|
expect((result.details as CoordinationDetails)?.jobs).toEqual([]);
|
|
});
|
|
|
|
test("list surfaces running subagents that have no backing job", async () => {
|
|
const registry = new AgentRegistry();
|
|
registerRunningSub(registry, "Worker");
|
|
registerRunningSub(registry, "Idler");
|
|
registry.setStatus("Idler", "idle");
|
|
registry.register({ id: "advisor", displayName: "advisor", kind: "advisor", session: null });
|
|
registry.register({ id: "Main", displayName: "Main", kind: "main", session: null });
|
|
const tool = new HubTool(createToolSession({ manager: createManager(), registry, agentId: "Main" }));
|
|
|
|
const result = await tool.execute("call", { op: "jobs" });
|
|
|
|
expect((result.details as CoordinationDetails)?.agents?.map(agent => agent.id)).toEqual(["Worker"]);
|
|
const text = resultText(result);
|
|
expect(text).toContain("Running Agents (1)");
|
|
expect(text).toContain("Worker");
|
|
expect(result.useless).toBeUndefined();
|
|
});
|
|
|
|
test("agents covered by the caller's running jobs are not double-listed", async () => {
|
|
const manager = createManager();
|
|
const registry = new AgentRegistry();
|
|
// Task-style spawn: job id == agent id.
|
|
manager.register("task", "AgentA", neverResolves, { id: "AgentA", agentId: "AgentA", ownerId: "Main" });
|
|
registerRunningSub(registry, "AgentA");
|
|
// Vibe-style turn job: job id differs from the agent id; linkage via agentId.
|
|
manager.register("task", "vibe turn", neverResolves, { id: "vibe-1-t1", agentId: "vibe-1", ownerId: "Main" });
|
|
registerRunningSub(registry, "vibe-1");
|
|
// Woken via irc: running agent with no job at all.
|
|
registerRunningSub(registry, "Loner");
|
|
const tool = new HubTool(createToolSession({ manager, registry, agentId: "Main" }));
|
|
|
|
const result = await tool.execute("call", { op: "jobs" });
|
|
|
|
expect((result.details as CoordinationDetails)?.jobs?.map(job => job.id).sort()).toEqual(["AgentA", "vibe-1-t1"]);
|
|
expect((result.details as CoordinationDetails)?.agents?.map(agent => agent.id)).toEqual(["Loner"]);
|
|
manager.cancel("AgentA");
|
|
manager.cancel("vibe-1-t1");
|
|
});
|
|
|
|
test("a settled job in retention does not hide its re-woken agent", async () => {
|
|
const manager = createManager();
|
|
const registry = new AgentRegistry();
|
|
manager.register("task", "AgentB", async () => "done", { id: "AgentB", agentId: "AgentB", ownerId: "Main" });
|
|
await manager.waitForAll();
|
|
// The agent was re-woken (e.g. via irc) after its job completed.
|
|
registerRunningSub(registry, "AgentB");
|
|
const tool = new HubTool(createToolSession({ manager, registry, agentId: "Main" }));
|
|
|
|
const result = await tool.execute("call", { op: "jobs" });
|
|
|
|
expect((result.details as CoordinationDetails)?.jobs?.find(job => job.id === "AgentB")?.status).toBe("completed");
|
|
expect((result.details as CoordinationDetails)?.agents?.map(agent => agent.id)).toEqual(["AgentB"]);
|
|
});
|
|
});
|
|
|
|
describe("hub wait with no matching jobs", () => {
|
|
test("bare wait with nothing running stays a useless no-op message", async () => {
|
|
const tool = new HubTool(createToolSession({ manager: createManager(), agentId: "Main" }));
|
|
|
|
const result = await tool.execute("call", { op: "wait" });
|
|
|
|
expect(resultText(result)).toBe("No running background jobs to wait for.");
|
|
expect(result.useless).toBe(true);
|
|
});
|
|
|
|
test("bare wait reports running agents outside job control", async () => {
|
|
const registry = new AgentRegistry();
|
|
registerRunningSub(registry, "Worker");
|
|
const tool = new HubTool(createToolSession({ manager: createManager(), registry }));
|
|
|
|
const result = await tool.execute("call", { op: "wait" });
|
|
|
|
const text = resultText(result);
|
|
expect(text).toContain("No running background jobs to wait for.");
|
|
expect(text).toContain("Worker");
|
|
expect((result.details as CoordinationDetails)?.agents?.map(agent => agent.id)).toEqual(["Worker"]);
|
|
expect(result.useless).toBeUndefined();
|
|
});
|
|
|
|
test("waiting on an agent id that has no job explains the agent's state", async () => {
|
|
const registry = new AgentRegistry();
|
|
registerRunningSub(registry, "Worker");
|
|
const tool = new HubTool(createToolSession({ manager: createManager(), registry, agentId: "Main" }));
|
|
|
|
const result = await tool.execute("call", { op: "wait", ids: ["Worker"] });
|
|
|
|
const text = resultText(result);
|
|
expect(text).toContain("No matching jobs found for IDs: Worker");
|
|
expect(text).toContain("running agent with no job entry");
|
|
expect(text).toContain("history://Worker");
|
|
});
|
|
});
|