Row 90, round 2. Dewey built it, Filbert approved it (comment 28068) on manifest 95611274 (21 paths, baseb8539480). The approved PRD's requirement ids live in packages/tasks/requirements.json. task_create refuses an id outside it, and the PM session gets a list_requirements tool. Round 2 adds requirements.mjs to the sandbox's RUNNER_FILES (decision 127). Sage gate onc047d028plus the patch, 23:50:16Z to 23:54:26Z: tasks 89, bus 111, harness 66, webui 101, cli 123, seat 33, business 60, control-board 124, queue 150, conversation 182, discord 178, ledger 78, runs 43, mosaic 69, all fail 0. Every scripts/test-*.sh exited 0. Co-Authored-By: Claude Opus 5.5 <[email protected]>
144 lines
8.7 KiB
JavaScript
144 lines
8.7 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", "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 79998 bytes: the cap isn't a byte count.
|
|
assert.equal(replyText("\u{1F600}".repeat(REPLY_CAP / 2 - 1)).length, REPLY_CAP);
|
|
});
|