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"]); 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); });