Commits move as v2 bundles over bus-host/git.sock (decision 135). The bus
host keeps one bare working repository per business and project, seeds main
from the project root under a per-repository lock, fetches a pushed bundle
with fsck into a temp ref, requires history shared with main, and moves the
task's work ref only as a fast-forward. Host and runner git run with no
system or global config, hooks at /dev/null and local transports only. The
socket binds only for roles holding git.push.working or review.verdict.
The broker records a push as the action.allowed for git.push.working with
its commit, takes review.request for the latest push and one review.verdict
per request, and refuses task.close on a pushed task without an approve
verdict for the latest push. recordEvent reserves both shapes, so agents
can't forge either.
Reviewed by Dewey (28196) and Sage (28197), both approve. Gate on 7a729aef:
14 node suites and 9 scripts green.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
183 lines
12 KiB
JavaScript
183 lines
12 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { callTool, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs";
|
|
import { TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs";
|
|
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
|
|
|
|
const names = (tools) => tools.map((t) => t.name);
|
|
|
|
test("the PM gets launch, its task verbs and the reads", (t) => {
|
|
const pm = resolvedFor(t, "pm");
|
|
const tools = typedTools({ resolved: pm, arbiter: true, tracker: true });
|
|
assert.deepEqual(names(tools), [
|
|
"send_message", "launch",
|
|
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
|
|
"task_update_assigned", "task_close",
|
|
"raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
|
|
]);
|
|
const launch = tools.find((x) => x.name === "launch");
|
|
assert.equal(launch.verb, "launch");
|
|
assert.equal(launch.action, "role.launch");
|
|
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
|
|
// Row B: the project is optional, a positive integer, and only task_create takes it.
|
|
const create = tools.find((x) => x.name === "task_create");
|
|
assert.deepEqual({ ...create.parameters.properties.project, description: undefined }, { type: "integer", minimum: 1, description: undefined });
|
|
assert.deepEqual(create.parameters.required, ["title", "request", "requirement"]);
|
|
assert.deepEqual(tools.filter((x) => x.parameters.properties.project).map((x) => x.name), ["task_create"]);
|
|
for (const tool of tools) {
|
|
assert.equal(tool.parameters.type, "object");
|
|
assert.equal(tool.parameters.additionalProperties, false);
|
|
assert.ok(tool.description.length > 10);
|
|
}
|
|
});
|
|
|
|
// Row 90 (#1584): list_requirements for the sessions that hold task.create, with a tracker.
|
|
test("list_requirements reads the requirement ids, for a task.create holder with a tracker", (t) => {
|
|
const pm = resolvedFor(t, "pm");
|
|
const tools = typedTools({ resolved: pm, arbiter: true, tracker: true });
|
|
const list = tools.find((x) => x.name === "list_requirements");
|
|
assert.deepEqual([list.verb, list.action], ["requirements", null]);
|
|
assert.deepEqual(list.parameters.properties, {});
|
|
assert.match(list.description, /task_create takes only these/);
|
|
assert.match(list.description, /raise a prd\.approve decision instead of creating the task/);
|
|
assert.ok(!names(typedTools({ resolved: pm, arbiter: true })).includes("list_requirements"));
|
|
const noCreate = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["message.send", "task.assign"]));
|
|
const without = names(typedTools({ resolved: noCreate, tracker: true }));
|
|
assert.ok(without.includes("task_assign") && !without.includes("task_create") && !without.includes("list_requirements"));
|
|
const create = tools.find((x) => x.name === "task_create").parameters.properties;
|
|
assert.match(create.requirement.description, /from list_requirements/);
|
|
assert.match(create.request.description, /shown as "request <id>" in that message's header; not the message's own id/);
|
|
const raise = tools.find((x) => x.name === "raise_decision").parameters.properties;
|
|
assert.match(raise.blocking.description, /needs task_ref/);
|
|
assert.match(raise.blocking.description, /A request with no task is false/);
|
|
});
|
|
|
|
test("a coder gets no launch, no resolve_decision, and no task tools without a tracker", (t) => {
|
|
const coder = resolvedFor(t, "coder");
|
|
const tools = typedTools({ resolved: coder });
|
|
assert.deepEqual(names(tools), ["send_message", "request_review", "raise_decision", "list_agents", "inbox", "trail"]);
|
|
// Held actions without a tool are listed, so the manifest shows them. With
|
|
// no clone to push from, push_working is one of them.
|
|
assert.deepEqual(untypedActions(coder, tools), ["task.update.assigned", "git.push.working", "task.scope.change"]);
|
|
});
|
|
|
|
test("a coder with a tracker gets its own task tools and no task_reassign", (t) => {
|
|
const coder = resolvedFor(t, "coder");
|
|
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
|
|
"send_message", "task_scope_change", "task_update_assigned", "request_review",
|
|
"raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
|
]);
|
|
});
|
|
|
|
test("launch only when the business's launch block names the instance as launcher", (t) => {
|
|
const pm = resolvedFor(t, "pm", (d) => delete d.launch);
|
|
assert.equal(pm.launch, null);
|
|
assert.ok(!names(typedTools({ resolved: pm })).includes("launch"));
|
|
});
|
|
|
|
test("an action outside the instance's authority has no tool", (t) => {
|
|
const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"]));
|
|
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
|
});
|
|
|
|
// Row 93 (#1587): the review loop's tools, by authority, and the git ones only with a clone.
|
|
test("the review loop: push_working and request_review for the coder, fetch_review and review_verdict for the reviewer, none for the PM", (t) => {
|
|
const coder = resolvedFor(t, "coder");
|
|
const tools = typedTools({ resolved: coder, tracker: true, projects: ["stack", "site"] });
|
|
assert.deepEqual(names(tools), [
|
|
"send_message", "task_scope_change", "task_update_assigned", "push_working", "request_review",
|
|
"raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
|
]);
|
|
assert.deepEqual(untypedActions(coder, tools), []);
|
|
const push = tools.find((x) => x.name === "push_working");
|
|
assert.deepEqual([push.verb, push.action], ["working.push", "git.push.working"]);
|
|
assert.deepEqual(push.parameters.required, ["task_ref", "project", "branch"]);
|
|
assert.deepEqual(push.parameters.properties.project.enum, ["stack", "site"]);
|
|
const ask = tools.find((x) => x.name === "request_review");
|
|
assert.deepEqual([ask.verb, ask.action], ["review.request", "review.request"]);
|
|
assert.deepEqual(ask.parameters.required, ["task_ref", "commit", "to"]);
|
|
assert.equal(ask.parameters.properties.commit.pattern, "^[0-9a-f]{40}([0-9a-f]{24})?$");
|
|
|
|
const reviewer = resolvedFor(t, "reviewer");
|
|
const rtools = typedTools({ resolved: reviewer, tracker: true, projects: ["stack"] });
|
|
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
|
const fetch = rtools.find((x) => x.name === "fetch_review");
|
|
assert.deepEqual([fetch.verb, fetch.action], ["working.fetch", null]);
|
|
assert.deepEqual(fetch.parameters.required, ["task_ref", "request"]);
|
|
const verdict = rtools.find((x) => x.name === "review_verdict");
|
|
assert.deepEqual([verdict.verb, verdict.action], ["review.verdict", "review.verdict"]);
|
|
assert.deepEqual(verdict.parameters.properties.verdict.enum, ["approve", "changes"]);
|
|
assert.deepEqual(verdict.parameters.required, ["task_ref", "request", "verdict", "body"]);
|
|
// No clone, no fetch; the verdict still has its tool.
|
|
assert.deepEqual(names(typedTools({ resolved: reviewer, projects: [] })).filter((n) => /review/.test(n)), ["review_verdict"]);
|
|
|
|
const pm = names(typedTools({ resolved: resolvedFor(t, "pm"), arbiter: true, tracker: true, projects: ["stack"] }));
|
|
for (const n of ["push_working", "request_review", "fetch_review", "review_verdict"]) assert.ok(!pm.includes(n), n);
|
|
const close = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true }).find((x) => x.name === "task_close");
|
|
assert.match(close.description, /closes only on an approve verdict for its latest push/);
|
|
for (const tool of [...tools, ...rtools]) assert.equal(tool.parameters.additionalProperties, false);
|
|
});
|
|
|
|
test("callTool: one JSON line out, the result back, a refusal rejects", async (t) => {
|
|
const dir = scratch(t);
|
|
const sock = await fakeToolSocket(t, dir, (tool, args) => {
|
|
if (tool === "launch") throw new Error("capacity-full");
|
|
return { echoed: args };
|
|
});
|
|
assert.deepEqual(await callTool(sock.path, "list_agents", { a: 1 }), { echoed: { a: 1 } });
|
|
await assert.rejects(callTool(sock.path, "launch", { instance: "coder" }), /refused: capacity-full/);
|
|
await assert.rejects(callTool(`${dir}/missing.sock`, "x"), /tool socket: ENOENT/);
|
|
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
|
|
});
|
|
|
|
// Row 89 (#1583): task reads a session can page, and replies that fit.
|
|
test("list_tasks takes the broker's filters and pages; get_task takes a task_ref", (t) => {
|
|
const tools = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true });
|
|
const list = tools.find((x) => x.name === "list_tasks");
|
|
const get = tools.find((x) => x.name === "get_task");
|
|
assert.deepEqual([list.verb, list.action, get.verb, get.action], ["tasks", null, "tasks", null]);
|
|
const p = list.parameters;
|
|
assert.deepEqual(Object.keys(p.properties), ["done", "assignee", "label", "requirement", "limit", "after"]);
|
|
assert.deepEqual(p.required, []);
|
|
assert.equal(p.properties.done.type, "boolean");
|
|
assert.equal(p.properties.assignee.type, "string");
|
|
assert.deepEqual([p.properties.label.type, p.properties.label.minimum], ["integer", 1]);
|
|
assert.equal(p.properties.requirement.pattern, "^REQ-[A-Z]+-[1-9][0-9]*$");
|
|
assert.equal(p.properties.after.type, "string");
|
|
// The page sizes are the broker's own.
|
|
assert.deepEqual([p.properties.limit.type, p.properties.limit.minimum, p.properties.limit.maximum], ["integer", 1, TASK_PAGE_MAX]);
|
|
assert.match(p.properties.limit.description, new RegExp(`\\b${TASK_PAGE} by default`));
|
|
assert.match(list.description, new RegExp(`open tasks \\(done false\\), ${TASK_PAGE} at a time`));
|
|
assert.match(list.description, /next as after/);
|
|
assert.match(list.description, /get_task/);
|
|
assert.deepEqual(get.parameters.properties, { task_ref: tools.find((x) => x.name === "task_close").parameters.properties.task_ref });
|
|
assert.deepEqual(get.parameters.required, ["task_ref"]);
|
|
// Neither without a tracker.
|
|
assert.deepEqual(names(typedTools({ resolved: resolvedFor(t, "pm") })).filter((n) => n.endsWith("_tasks") || n === "get_task"), []);
|
|
});
|
|
|
|
test("callTool sends list_tasks done false unless the session names done; other tools as given", async (t) => {
|
|
const sock = await fakeToolSocket(t, scratch(t), (tool, args) => args);
|
|
assert.deepEqual(await callTool(sock.path, "list_tasks"), { done: false });
|
|
assert.deepEqual(await callTool(sock.path, "list_tasks", { limit: 5 }), { done: false, limit: 5 });
|
|
assert.deepEqual(await callTool(sock.path, "list_tasks", { done: true }), { done: true });
|
|
assert.deepEqual(await callTool(sock.path, "get_task", { task_ref: "vikunja:3/7" }), { task_ref: "vikunja:3/7" });
|
|
assert.deepEqual(await callTool(sock.path, "list_agents"), {});
|
|
});
|
|
|
|
test("replyText: compact JSON up to the cap, an error naming the size and the cap over it", () => {
|
|
assert.equal(REPLY_CAP, 40_000);
|
|
assert.equal(replyText({ a: [1, { b: "c" }] }), '{"a":[1,{"b":"c"}]}');
|
|
assert.equal(replyText(null), "null");
|
|
assert.equal(replyText(undefined), "null");
|
|
// A JSON string adds its two quotes; length counts UTF-16 units, as the CLI does.
|
|
assert.equal(replyText("x".repeat(REPLY_CAP - 2)).length, REPLY_CAP);
|
|
assert.throws(() => replyText("x".repeat(REPLY_CAP - 1)), (e) => {
|
|
assert.equal(e.message, `reply too large: ${REPLY_CAP + 1} characters, over the ${REPLY_CAP}-character cap. Narrow the query: a filter, a smaller limit, or get_task for one task.`);
|
|
return true;
|
|
});
|
|
assert.throws(() => replyText("\u{1F600}".repeat(REPLY_CAP / 2)), /reply too large: 40002 characters/);
|
|
// 40000 units fit, though they are 79998 bytes: the cap isn't a byte count.
|
|
assert.equal(replyText("\u{1F600}".repeat(REPLY_CAP / 2 - 1)).length, REPLY_CAP);
|
|
});
|