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]>
186 lines
12 KiB
JavaScript
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);
|
|
});
|