agent-watcher/mcp-watcher/test/tools.test.ts
bob c22558c67a Layer 2 MCP Watcher v0 scaffold
Per spec/agent-watcher.md §4. TypeScript/Node implementation living in
mcp-watcher/ subdirectory, parallel to Layer 1 Collector at repo root.

What lands:
- Core MCP server (src/server.ts) with experimental['claude/channel']:{}
  + tools:{} capability declarations, stdio transport, channel-event
  notifier wired through the inbox watcher.
- Identity resolution mirroring agent-ping's layered model
  (PING_AGENT_IDENTITY env, $CLAUDE_HOME/ping-agent, ~/.ping-agent).
- Inbox reader with HWM tracking, sentinel deferral (warn-after-3),
  atomic HWM writes via tmp+rename.
- chokidar-backed file watcher with coalesced drain, urgent-first
  ordering, recentEvents map for tool sender lookup.
- Three reply tools (ack / respond / mark_handled) with cross-host
  write discipline (writes to local inbox files; Syncthing replicates).
- Sentinel file (.<agent>.watcher-active) for hook coexistence per
  spec §4.3 — agent-ping hook stands down when the watcher is in
  charge of delivery on this host. Sentinel + hwm in .stignore.
- 35 unit tests passing (vitest): inbox parsing, HWM round-trip,
  sentinel deferral semantics, identity layers, tool I/O, watcher
  drain + ordering + restart-from-hwm.
- install.sh (Angus-executed, rule-2 compliant) installs deps,
  builds, symlinks ~/.local/bin/agent-watcher-mcp, prints mcp.json
  registration snippet for paste.
- README documents launch flag, sandbox CLAUDE_HOME pattern,
  hook coexistence, observability, v2 limitations.

Not yet:
- Integration test against a real Claude Code session — gated on
  Angus spinning up a sandbox CC session on the VPS with
  CLAUDE_HOME=~/.claude-sandbox.
- agent-ping hook update to read the sentinel and stand down.
  Separate small PR against agent-ping.

Interface contract with Layer 1: the inbox JSONL line shape from
inbox.ts::PingEvent matches inbox.Event in the Collector — bit-
identical reads regardless of source.
2026-05-06 17:44:57 -03:00

159 lines
5.4 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { makePaths } from "../src/paths.js";
import { callTool, toolDefinitions } from "../src/tools.js";
import type { PingEvent } from "../src/inbox.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "watcher-tools-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
const setupDeps = (recent: Map<string, PingEvent>) => ({
paths: makePaths(dir),
agent: "bob",
recentEvents: () => recent,
});
const sampleEvent = (over: Partial<PingEvent> = {}): PingEvent => ({
ts: "2026-05-06T10:00:00Z",
id: "ping-orig",
from: "foreman",
to: "bob",
type: "NEEDS-RESPONSE",
payload: "do the thing",
...over,
});
describe("toolDefinitions", () => {
it("declares ack, respond, and mark_handled with required ping_id", () => {
const tools = toolDefinitions();
const names = tools.map((t) => t.name).sort();
expect(names).toEqual(["ack", "mark_handled", "respond"]);
for (const t of tools) {
expect(t.inputSchema.required).toContain("ping_id");
}
});
});
describe("callTool — ack", () => {
it("appends to acks/log with ack_kind=delivered + original_type", () => {
const recent = new Map<string, PingEvent>([["ping-orig", sampleEvent()]]);
const deps = setupDeps(recent);
const r = callTool("ack", { ping_id: "ping-orig" }, deps);
expect(r.ok).toBe(true);
const log = readFileSync(deps.paths.acksLog, "utf8").trim();
const obj = JSON.parse(log);
expect(obj.acked_by).toBe("bob");
expect(obj.ping_id).toBe("ping-orig");
expect(obj.original_sender).toBe("foreman");
expect(obj.ack_kind).toBe("delivered");
expect(obj.original_type).toBe("NEEDS-RESPONSE");
});
it("rejects missing ping_id", () => {
const r = callTool("ack", {}, setupDeps(new Map()));
expect(r.ok).toBe(false);
});
});
describe("callTool — respond", () => {
it("writes a JSONL line to <sender>.inbox + ack with ack_kind=responded", () => {
const recent = new Map<string, PingEvent>([["ping-orig", sampleEvent()]]);
const deps = setupDeps(recent);
const r = callTool(
"respond",
{ ping_id: "ping-orig", payload: "ok done", type: "INFO" },
deps,
);
expect(r.ok).toBe(true);
const inboxPath = deps.paths.inbox("foreman");
const lines = readFileSync(inboxPath, "utf8").trim().split("\n");
expect(lines).toHaveLength(1);
const ev = JSON.parse(lines[0]);
expect(ev.from).toBe("bob");
expect(ev.to).toBe("foreman");
expect(ev.type).toBe("INFO");
expect(ev.payload).toBe("ok done");
expect(ev.id).toMatch(/^ping-[a-f0-9]{8}$/);
const ack = JSON.parse(readFileSync(deps.paths.acksLog, "utf8").trim());
expect(ack.ack_kind).toBe("responded");
expect(ack.response_id).toBe(ev.id);
});
it("defaults missing type to INFO + missing priority to normal", () => {
const recent = new Map<string, PingEvent>([["ping-orig", sampleEvent()]]);
const deps = setupDeps(recent);
callTool("respond", { ping_id: "ping-orig", payload: "x" }, deps);
const lines = readFileSync(deps.paths.inbox("foreman"), "utf8").trim().split("\n");
const ev = JSON.parse(lines[0]);
expect(ev.type).toBe("INFO");
expect(ev.priority).toBe("normal");
});
it("rejects unknown ping_id", () => {
const r = callTool(
"respond",
{ ping_id: "ping-unknown", payload: "x" },
setupDeps(new Map()),
);
expect(r.ok).toBe(false);
expect(r.text).toContain("not found");
});
it("rejects invalid type", () => {
const recent = new Map<string, PingEvent>([["ping-orig", sampleEvent()]]);
const r = callTool(
"respond",
{ ping_id: "ping-orig", payload: "x", type: "bogus" },
setupDeps(recent),
);
expect(r.ok).toBe(false);
});
});
describe("callTool — mark_handled", () => {
it("writes an ack with explanatory message", () => {
const recent = new Map<string, PingEvent>([["ping-orig", sampleEvent()]]);
const deps = setupDeps(recent);
const r = callTool("mark_handled", { ping_id: "ping-orig" }, deps);
expect(r.ok).toBe(true);
const ack = JSON.parse(readFileSync(deps.paths.acksLog, "utf8").trim());
expect(ack.ack_kind).toBe("delivered");
expect(ack.message).toMatch(/handled/i);
});
});
describe("callTool — unknown tool", () => {
it("returns ok=false on unknown tool name", () => {
const r = callTool("bogus", { ping_id: "x" }, setupDeps(new Map()));
expect(r.ok).toBe(false);
expect(r.text).toContain("unknown");
});
});
describe("ack does not crash on unknown ping_id", () => {
it("still writes an ack with original_sender=null", () => {
const deps = setupDeps(new Map());
const r = callTool("ack", { ping_id: "ping-unknown" }, deps);
expect(r.ok).toBe(true);
const ack = JSON.parse(readFileSync(deps.paths.acksLog, "utf8").trim());
expect(ack.original_sender).toBeNull();
});
});
describe("paths writability through tools", () => {
it("creates the acks dir on first ack", () => {
const deps = setupDeps(new Map([["p1", sampleEvent({ id: "p1" })]]));
expect(existsSync(deps.paths.acksLog)).toBe(false);
callTool("ack", { ping_id: "p1" }, deps);
expect(existsSync(deps.paths.acksLog)).toBe(true);
});
});