Files
stack/packages/harness/tests/tools.test.mjs
T
jason.woltjeandClaude Opus 5.5 b8539480aa tasks, harness: task verbs across the tree (#1556)
Row 63, round 2. Task verbs act in any installed project of the tree;
a project the tree doesn't hold refuses task-project as before, and a
watch-only one refuses with the new code. The harness task_create tool
takes an optional integer project. Round 2 adds Dewey's five proof
tests and Filbert's N1 child-path tests; the code is round 1's.

Candidate manifest 2a22fd3f (agents/darkwing/work/queue-63/r2/), base
cca5f23e, no drift on the six paths. Builder Darkwing. Approved by
Dewey (comment 28024) and Filbert (comment 28026). Sage's gate on
1117d19b plus the patch 2026-10-12T23:07:33Z-23:09:22Z: tasks 84,
harness 65, every scripts/test-*.sh green.

Lands before row 90 (decision 125). The harness change is live at the
next role session launch (decision 109); Mos had the notice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:09:39 -05:00

123 lines
7.2 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",
]);
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);
}
});
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", "raise_decision", "list_agents", "inbox", "trail"]);
// Held actions without a tool are listed, so the manifest shows them.
assert.deepEqual(untypedActions(coder, tools), ["task.update.assigned", "git.push.working", "review.request", "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",
"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 })), ["raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
});
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 80002 bytes: the cap isn't a byte count.
assert.equal(replyText("\u{1F600}".repeat(REPLY_CAP / 2 - 1)).length, REPLY_CAP);
});