Files
stack/packages/harness/tests/tools.test.mjs
T
jason.woltjeandClaude Opus 5.5 3a1e11b83e bus: review.request reports whether a reviewer holds the role (#1599)
Row 105. Built by a Haiku subagent under Researcher (decision 129),
packet agents/researcher/work/queue-105/r1/, candidate manifest
cd6d5eb3e4093ac157f63d05cb639d243d76c6bde1a03e78efb8f29e191b6b44.
Filbert approved on #1599, comment 28247.

The review.request reply and the review.requested event carry held,
the run id holding the reviewer role or null. The request_review tool
description says what null means. The broker change is live only at
a bus restart.

Sage's gate rerun on 79e126be0 with rows 101 and 105 applied, Docker
up: node suites bus 128, business 67, cli 146, control-board 124,
conversation 182, discord 178, harness 70, ledger 78, mosaic 69,
queue 155, runs 43, seat 109, tasks 92, webui 108; scripts auth 15,
conductor 17, config 24, discord 66, extension-package 18,
foundation 44, queue 28, release 14, task 98. All pass, 0 fail.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:26:12 -05:00

186 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})?$");
// Row 105 (#1599): the description says what a null held means, and what the requester should do.
assert.match(ask.description, /The reply's held names the run holding the reviewer role, or is null when no reviewer is running\./);
assert.match(ask.description, /A null request waits until a reviewer launches, so tell whoever launches roles that a review is waiting\./);
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);
});