roles/coder.json crossRole goes from ["task.reassign", "task.scope.change"] to ["task.scope.change"]. The broker refuses a non-pm task.reassign at the writer check before it looks at authority, so the grant could never be used. A coder's task.reassign now classifies as gated and goes to the human. Backed by lead decision 90. Tests: business role and resolve (the narrowing test moves to the pm, which still holds two cross-role actions), a bus test on the shipped role files, a harness test that a coder with a tracker gets no task_reassign, the tasks fixture, and a verbs line showing a coder's task.reassign refuses field-writer with no action.allowed. mutants.sh is Rocko's packet rerun script, already committed. Author: Rocko (packet agents/rocko/work/queue-73/, commit2137dd7f). Candidate manifest sha256 bf4e5ed6, base324c064b, no drift on the candidate paths through8a42e819. Approval: Dewey, round 1, comment 27506 on #1567. Gate (Sage, /tmp/sage-g73 on8a42e819, 23:42-23:48Z): tasks 71, bus 81, business 60, harness 59, cli 113, webui 61, seat 27 node tests, 0 failed; every scripts/test-*.sh exit 0, 0 failed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
68 lines
3.2 KiB
JavaScript
68 lines
3.2 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { callTool, typedTools, untypedActions } from "../src/tools.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",
|
|
]);
|
|
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.
|
|
const untyped = untypedActions(coder, tools);
|
|
assert.ok(untyped.includes("git.push.working"));
|
|
assert.ok(!untyped.includes("message.send"));
|
|
});
|
|
|
|
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",
|
|
]);
|
|
});
|
|
|
|
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"]);
|
|
});
|
|
|
|
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"]);
|
|
});
|