import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; 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 "../../organization/src/launch-settings.mjs"; import { ID_PATTERN } from "../../organization/src/vocabulary.mjs"; import { fakeToolSocket, REPO, 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", "intake_status", "intake_projects", "intake_open", "intake_question", "intake_propose", "deliver_request", "advance_request", "submit_prd", "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", "my_rails", ]); // 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.match(create.description, /exactly one requirement id/); assert.match(create.description, /A push cannot clear missing planning coverage/); assert.match(create.description, /approved task limit/); assert.deepEqual(tools.filter((x) => x.parameters.properties.project).map((x) => x.name), ["intake_propose", "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, hire, fire, 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", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]); const launch = tools.find((x) => x.name === "launch"); assert.equal(launch.verb, "launch"); assert.equal(launch.action, "role.launch"); // Row 194 (#1692, D3 item 5): a name with the id pattern, not an enum fixed at bundle time, so a hire // launches with no restart. The start-time listing stays in the description, and the host checks it. assert.deepEqual(launch.parameters.properties.instance, { type: "string", description: "Role instance to launch", pattern: ID_PATTERN.source }); assert.match(launch.description, /When this session started they were: coder, reviewer\. An instance you hired since is listed too\. The host refuses an instance that isn't listed,/); // 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. Row 194 (#1692, // PRD 1.7 REQ-COS-3) granted role.hire and role.fire, with hire and fire. // RR grants resolve rights; TI owns their typed tool exposure. assert.deepEqual(untypedActions(chief, tools), ["decision.resolve.technical", "decision.resolve.delivery"]); }); // Row 194 (#1692, PRD 1.7 REQ-COS-3): hire and fire send the broker's argument keys, reason required, // and only a holder of the grant gets them. test("hire and fire raise decisions under role.hire and role.fire, only for a holder of the grant", (t) => { const tools = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true }); const hire = tools.find((x) => x.name === "hire"); const fire = tools.find((x) => x.name === "fire"); assert.deepEqual([hire.verb, hire.action, fire.verb, fire.action], ["hire.raise", "role.hire", "hire.raise", "role.fire"]); assert.deepEqual(Object.keys(hire.parameters.properties), ["op", "level", "project", "name", "role", "credentials", "personality", "route", "reason"]); assert.deepEqual(hire.parameters.required, ["op", "level", "name", "role", "route", "reason"]); assert.deepEqual(Object.keys(fire.parameters.properties), ["op", "level", "project", "name", "reason"]); assert.deepEqual(fire.parameters.required, ["op", "level", "name", "reason"]); for (const tool of [hire, fire]) { assert.equal(tool.parameters.additionalProperties, false); assert.deepEqual([tool.parameters.properties.reason.minLength, tool.parameters.properties.reason.maxLength], [1, 500]); } for (const instance of ["coder", "reviewer", "pm"]) for (const name of ["hire", "fire"]) assert.ok(!names(typedTools({ resolved: resolvedFor(t, instance), tracker: true })).includes(name), `${instance} ${name}`); // Each tool follows its own grant. const only = (grant) => resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { ...d.roles.chief.vars, "limits.authority": ["message.send", grant] })); const HIRE_FIRE = (n) => n === "hire" || n === "fire"; assert.deepEqual(names(typedTools({ resolved: only("role.hire") })).filter(HIRE_FIRE), ["hire"]); assert.deepEqual(names(typedTools({ resolved: only("role.fire") })).filter(HIRE_FIRE), ["fire"]); }); // 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", "request", "roles", "note", "decision"]); assert.deepEqual(ask.parameters.required, ["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", "decision.resolve.delivery"])); 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.equal(launch.parameters.properties.instance.enum, undefined); assert.ok(launch.description.includes(`they were: ${chief.launch.instances.join(", ")}.`)); }); test("every managed role's typed tools avoid top-level schema composition rejected by model APIs", (t) => { const chief = resolvedFor(t, "chief"); for (const file of readdirSync(join(REPO, "roles")).filter((name) => name.endsWith(".json"))) { const role = JSON.parse(readFileSync(join(REPO, "roles", file), "utf8")); // Version 1 worker roles and the conductor policy do not use managed harness tools. if (role.roleVersion !== 2) continue; const resolved = { definition: role.name, limits: { tools: role.tools, authority: role.authority }, launch: role.name === "chief-of-staff" ? chief.launch : null, }; const tools = typedTools({ resolved, arbiter: true, tracker: true, projects: ["stack"], land: true, library: true }); for (const tool of tools) for (const keyword of ["oneOf", "anyOf", "allOf"]) assert.equal(Object.hasOwn(tool.parameters, keyword), false, `${role.name}.${tool.name}: top-level ${keyword}`); } }); // 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, /from organization_revision;/); assert.doesNotMatch(change.description, /human gave it/); // Row 134 (#1669): the revision read takes no arguments and is held by the same grant. const read = tools.find((x) => x.name === "organization_revision"); assert.deepEqual([read.verb, read.action], ["organization.revision", "organization.change"]); assert.deepEqual(read.parameters.properties, {}); assert.deepEqual(read.parameters.required, []); assert.equal(read.parameters.additionalProperties, false); assert.ok(tools.indexOf(read) < tools.indexOf(change)); }); // 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 none of the three tools, and each follows its own grant", (t) => { const ORGANIZATION_TOOLS = (n) => ["end_run", "organization_revision", "change_organization"].includes(n); for (const instance of ["pm", "coder", "reviewer"]) { const tools = typedTools({ resolved: resolvedFor(t, instance), arbiter: true, tracker: true }); assert.deepEqual(names(tools).filter(ORGANIZATION_TOOLS), [], instance); } const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" })); assert.deepEqual(names(typedTools({ resolved: assistant, tracker: true })).filter(ORGANIZATION_TOOLS), []); 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(ORGANIZATION_TOOLS), ["end_run"]); assert.deepEqual(names(typedTools({ resolved: only("organization.change") })).filter(ORGANIZATION_TOOLS), ["organization_revision", "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", "my_rails"]); 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(Object.keys(list.parameters.properties), ["project_key", "requirement_snapshot"]); assert.deepEqual(list.parameters.required, []); assert.match(list.description, /task_create takes only these/); assert.match(list.description, /Product intake always needs an approved product snapshot/); 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", "decision.resolve.delivery"])); 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 " 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", "my_rails"]); // 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", "my_rails", ]); }); 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", "my_rails"]); }); // 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", "my_rails", ]); 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", "my_rails"]); 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", "project_key", "requirement_snapshot", "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); }); test("PM tools carry the product snapshot and stable create operation, and filter colliding requirements by project", t => { const tools = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true }); const params = name => tools.find(t => t.name === name).parameters; for (const field of ["project_key", "requirement_snapshot", "op"]) assert.equal(params("task_create").properties[field]?.type, "string"); for (const name of ["list_requirements", "list_tasks"]) { assert.equal(params(name).properties.project_key?.type, "string"); assert.equal(params(name).properties.requirement_snapshot?.type, "string"); } assert.deepEqual(params("task_create").required, ["title", "request", "requirement"]); });