430 lines
31 KiB
JavaScript
430 lines
31 KiB
JavaScript
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 <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", "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"]);
|
|
});
|