Files
stack/packages/harness/tests/tools.test.mjs
T
jason.woltje 9466f3e5ed business: the human adds and changes role instances from the CLI and the Console (row 165, Q171, #1662)
`mosaic business instances BUSINESS` reads the role instances, and
`mosaic business instance add|set` writes one add or change through the
broker's new human-only `instances` and `instances.set` verbs. The writer is
packages/business/src/instance-settings.mjs. It takes the caller's revision,
validates the next document, refuses a definition that declares credentials
(organization-credentials-out-of-scope), keeps protected instances as they
are, logs the human as the actor, and says when the running bus needs a
restart. Neither command restarts the bus.

The writer refuses the instance ids human and tester with invalid-request,
because the broker refuses a business whose roles use either name when the
bus starts.

The Console's Settings view shows the instances and, under --send only,
posts to POST /api/instances with the exact Origin and same-origin Fetch
Metadata. A refusal keeps the draft. An answer without the view's shape is
outcome-unknown (503). The page's public/ diff carries the decision 123 go
in mos-0832.

Neither verb is in ORGANIZATION_KEYS, so no agent tool reaches them.

Owner Darkwing, brief in row 165 (#1662). Round 3 rebases round 2 onto
4f45cadc0 (row 156). The public/ +/- digest is 939ecd28...9444, unchanged
at landing. Reviewers on candidate manifest e30b54da...f580: Dewey
approve 29052, Filbert approve 29053. Seat run on 4f45cadc0: 1436/0
singles, 37/37 mutants killed. Sage gate on e8b09735a: GATE_PASSED 25/25,
packages 2282/0, queue node 156/0, script selftests 422/0, 23:59:13Z to
00:05:35Z. Rebased onto 50a57e454 over two SESSIONS-only commits.

Nothing live.
2026-10-18 19:10:20 -05:00

354 lines
25 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { callTool, ORGANIZATION_KEYS, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs";
import { ORGANIZATION_KEYS as BROKER_ORGANIZATION_KEYS, TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs";
import { SETTINGS_KEYS } from "../../business/src/launch-settings.mjs";
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
const names = (tools) => tools.map((t) => t.name);
test("the PM gets its task verbs and the reads, and no launch", (t) => {
const pm = resolvedFor(t, "pm");
const tools = typedTools({ resolved: pm, arbiter: true, tracker: true });
assert.deepEqual(names(tools), [
"send_message", "request_staff",
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
"task_update_assigned", "task_close",
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
]);
// Row 119 (#1613): only the chief of staff launches (REQ-COS-1).
assert.equal(pm.launch, null);
// 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", "read_spec", "write_record", "raise_decision"]);
for (const tool of tools) {
assert.equal(tool.parameters.type, "object");
assert.equal(tool.parameters.additionalProperties, false);
assert.ok(tool.description.length > 10);
}
});
// Row 143 (#1637): send_message offers the BLOCK class a pm or cto answers a spec notice with.
test("send_message offers the BLOCK class, beside the classes it already had", (t) => {
const send = typedTools({ resolved: resolvedFor(t, "pm"), arbiter: true, tracker: true }).find((x) => x.name === "send_message");
assert.deepEqual(send.parameters.properties.class.enum, ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION", "BLOCK"]);
});
// Row 119 (#1613): the chief of staff holds launch and message.send; row 123 adds role.end and organization.change, which row 132 gives tools.
test("the chief of staff gets launch, send_message, end_run, change_organization and set_name, and no task verbs", (t) => {
const chief = resolvedFor(t, "chief");
const tools = typedTools({ resolved: chief, tracker: true });
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "change_organization", "set_name", "read_spec", "write_record", "raise_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 132 (#1628): role.end and organization.change have their tools. Row 141 (#1635) granted
// instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped.
assert.deepEqual(untypedActions(chief, tools), []);
});
// Row 140 (#1634, REQ-COS-1): the pm asks for staff with request_staff, which spends message.send; the
// chief of staff and every other role has no such tool, and the chief's launch offer is unchanged.
test("request_staff is the pm's alone: staff.request over message.send, roles and counts bounded", (t) => {
const tools = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true });
const ask = tools.find((x) => x.name === "request_staff");
assert.deepEqual([ask.verb, ask.action], ["staff.request", "message.send"]);
assert.deepEqual(Object.keys(ask.parameters.properties), ["task_ref", "roles", "note", "decision"]);
assert.deepEqual(ask.parameters.required, ["task_ref", "roles"]);
assert.equal(ask.parameters.additionalProperties, false);
const roles = ask.parameters.properties.roles;
assert.deepEqual([roles.minItems, roles.maxItems], [1, 10]);
assert.deepEqual(roles.items.required, ["role", "count"]);
assert.equal(roles.items.additionalProperties, false);
assert.deepEqual([roles.items.properties.count.type, roles.items.properties.count.minimum, roles.items.properties.count.maximum], ["integer", 1, 20]);
for (const instance of ["chief", "coder", "reviewer"])
assert.ok(!names(typedTools({ resolved: resolvedFor(t, instance), tracker: true })).includes("request_staff"), instance);
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
assert.ok(!names(typedTools({ resolved: assistant })).includes("request_staff"));
// A pm without message.send is offered no request_staff, as for send_message.
const mute = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["task.assign"]));
assert.ok(!names(typedTools({ resolved: mute, tracker: true })).includes("request_staff"));
// The pm still has no launch tool, and the chief of staff's launch offer is its limits, not a request.
assert.ok(!names(tools).includes("launch"));
const chief = resolvedFor(t, "chief");
const launch = typedTools({ resolved: chief, tracker: true }).find((x) => x.name === "launch");
assert.deepEqual(launch.parameters.properties.instance.enum, chief.launch.instances);
});
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): each tool maps to the broker verb and action row 123 added.
test("end_run sends role and reason, no decision; change_organization sends revision and the organization keys", (t) => {
const tools = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true });
const end = tools.find((x) => x.name === "end_run");
assert.deepEqual([end.verb, end.action], ["role.revoke", "role.end"]);
assert.deepEqual(Object.keys(end.parameters.properties), ["role", "reason"]);
assert.deepEqual(end.parameters.required, ["role", "reason"]);
assert.deepEqual([end.parameters.properties.reason.minLength, end.parameters.properties.reason.maxLength], [1, 500]);
assert.equal(end.parameters.additionalProperties, false);
// Row 155 (#1649, Q169): the chief of staff ends the assistant; only its own run stays refused.
assert.match(end.description, /Ending the assistant ends every run it has, one per thread/);
assert.match(end.description, /You can't end your own run;/);
assert.doesNotMatch(end.description, /assistant's/);
const change = tools.find((x) => x.name === "change_organization");
assert.deepEqual([change.verb, change.action], ["organization.set", "organization.change"]);
assert.deepEqual(Object.keys(change.parameters.properties), [...ORGANIZATION_KEYS]);
assert.deepEqual(change.parameters.required, ["revision"]);
assert.equal(change.parameters.additionalProperties, false);
assert.equal(change.parameters.properties.revision.pattern, "^[a-f0-9]{64}$");
// Row 155 (#1649, Q169): an assistant entry takes vars, tracker and definition, and the definition
// only the thread definition; holder and credentials aren't offered.
const assistant = change.parameters.properties.assistants.additionalProperties;
assert.deepEqual(Object.keys(assistant.properties), ["vars", "tracker", "definition"]);
assert.equal(assistant.additionalProperties, false);
assert.deepEqual(assistant.properties.definition.enum, ["assistant"]);
assert.deepEqual(assistant.properties.tracker.type, ["object", "null"]);
assert.match(change.description, /as the human gave it to you/);
});
// Row 155 (#1649, REQ-MSG-2): set_name is row 141's name.set under the instance.name grant.
test("set_name sends instance and name to name.set under instance.name, only for a holder of that grant", (t) => {
const set = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true }).find((x) => x.name === "set_name");
assert.deepEqual([set.verb, set.action], ["name.set", "instance.name"]);
assert.deepEqual(Object.keys(set.parameters.properties), ["instance", "name"]);
assert.deepEqual(set.parameters.required, ["instance", "name"]);
assert.equal(set.parameters.additionalProperties, false);
for (const instance of ["pm", "coder", "reviewer"])
assert.equal(typedTools({ resolved: resolvedFor(t, instance), arbiter: true, tracker: true }).some((x) => x.name === "set_name"), false, instance);
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
assert.equal(typedTools({ resolved: assistant, tracker: true }).some((x) => x.name === "set_name"), false);
const without = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { ...d.roles.chief.vars, "limits.authority": ["message.send", "role.end"] }));
assert.equal(typedTools({ resolved: without }).some((x) => x.name === "set_name"), false);
});
// Row 132 (#1628): the key list is the broker's.
// Row 125 (#1619, REQ-LIMIT-3): and both are the keys launch settings write, reset included.
test("the organization keys change_organization takes are the broker's ORGANIZATION_KEYS and the settings' SETTINGS_KEYS", () => {
assert.deepEqual([...ORGANIZATION_KEYS], BROKER_ORGANIZATION_KEYS);
assert.deepEqual([...ORGANIZATION_KEYS], [...SETTINGS_KEYS]);
assert.ok(ORGANIZATION_KEYS.includes("reset"));
});
// Row 132 (#1628): the grants are the offer; a role without them gets neither tool.
test("a role without role.end or organization.change gets neither tool, and each tool follows its own grant", (t) => {
for (const instance of ["pm", "coder", "reviewer"]) {
const tools = typedTools({ resolved: resolvedFor(t, instance), arbiter: true, tracker: true });
assert.deepEqual(names(tools).filter((n) => n === "end_run" || n === "change_organization"), [], instance);
}
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
assert.deepEqual(names(typedTools({ resolved: assistant, tracker: true })).filter((n) => n === "end_run" || n === "change_organization"), []);
const only = (grant) => resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { ...d.roles.chief.vars, "limits.authority": ["message.send", grant] }));
assert.deepEqual(names(typedTools({ resolved: only("role.end") })).filter((n) => n === "end_run" || n === "change_organization"), ["end_run"]);
assert.deepEqual(names(typedTools({ resolved: only("organization.change") })).filter((n) => n === "end_run" || n === "change_organization"), ["change_organization"]);
});
// Row 165 (#1662, sage-0918): instances and instances.set are the human's. No role's tools send
// either verb, and change_organization takes none of the instance write's keys, so the chief of
// staff can't add or change an instance through it.
test("no role's tools send instances or instances.set, and change_organization takes no instance write keys", (t) => {
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
for (const [who, resolved] of [...["chief", "pm", "coder", "reviewer"].map((i) => [i, resolvedFor(t, i)]), ["assistant", assistant]]) {
const tools = typedTools({ resolved, arbiter: true, tracker: true });
assert.deepEqual(tools.filter((x) => x.verb === "instances" || x.verb === "instances.set").map((x) => x.name), [], who);
assert.equal(untypedActions(resolved, tools).some((a) => a.startsWith("instances")), false, who);
}
const change = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true }).find((x) => x.name === "change_organization");
for (const key of ["instance", "op", "definition", "vars", "launch"]) assert.equal(Object.hasOwn(change.parameters.properties, key), false, key);
});
// Row 119 gave the assistant message.send; row 138 adds record.write.
test("the assistant gets send_message, the reads and the web tools (it holds the web grant), no launch", (t) => {
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
const tools = typedTools({ resolved: assistant, tracker: true });
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.equal(assistant.launch, null);
assert.deepEqual(untypedActions(assistant, tools), []);
});
// 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/);
assert.match(raise.recommendation.description, /^Exactly one option key, as written in options, with nothing added/);
assert.doesNotMatch(raise.recommendation.description, /with a reason/);
assert.match(raise.recommendation.description, /The reason goes in question/);
assert.match(raise.question.description, /the reason for your recommendation/);
assert.match(raise.approvalChoice.description, /defaults to yes, so pass it whenever no option key is yes/);
});
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", "read_spec", "write_record", "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",
"read_spec", "write_record", "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) => {
assert.ok(names(typedTools({ resolved: resolvedFor(t, "chief") })).includes("launch"));
const chief = resolvedFor(t, "chief", (d) => delete d.launch);
assert.equal(chief.launch, null);
assert.ok(!names(typedTools({ resolved: chief })).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", "read_spec", "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",
"read_spec", "write_record", "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", "read_spec", "write_record", "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);
});
// Row 137 (#1631): land_work only when the launch found a merge decision to
// land (landHeld), for any role and with no clone; the broker checks the rest.
test("land_work is there only with land, after push_working, with the task and the decision", (t) => {
const coder = resolvedFor(t, "coder");
assert.ok(!names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })).includes("land_work"));
const tools = typedTools({ resolved: coder, tracker: true, projects: ["stack"], land: true });
assert.deepEqual(names(tools).slice(3, 6), ["push_working", "land_work", "request_review"]);
const land = tools.find((x) => x.name === "land_work");
assert.deepEqual([land.verb, land.action], ["working.land", "git.merge.protected"]);
assert.deepEqual(land.parameters.required, ["task_ref", "decision"]);
assert.deepEqual(Object.keys(land.parameters.properties), ["task_ref", "decision"]);
assert.equal(land.parameters.additionalProperties, false);
assert.match(land.description, /answers "started" with the commit that will land\. The full test gate runs after that, and its outcome comes to you as a RESULT message/);
assert.match(land.description, /land-busy means another is running\.$/);
// A chief with no clone gets it too; land doesn't need a project.
assert.ok(names(typedTools({ resolved: resolvedFor(t, "chief"), land: true })).includes("land_work"));
});
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);
});
// vikunja:19/307: one query limit. The tool schema's maxLength is web.mjs's constant.
test("web_search's query maxLength is the web layer's SEARCH_MAX_QUERY_CHARS", async (t) => {
const { SEARCH_MAX_QUERY_CHARS } = await import("../../discord/src/web.mjs");
const r = resolvedFor(t, "coder");
const granted = { ...r, limits: { ...r.limits, tools: [...r.limits.tools, "web"] } };
const search = typedTools({ resolved: granted }).find((x) => x.name === "web_search");
assert.equal(search.parameters.properties.query.maxLength, SEARCH_MAX_QUERY_CHARS);
});