Files

241 lines
14 KiB
JavaScript

// The S6 human verbs: talk, stop and launches. The human transport is the
// in-process broker from helpers.mjs, because the real one refuses to run
// under an agent (src/transport.mjs). A real stop of a real runner is in
// packages/seat/tests/session.test.mjs and tests/launcher.test.mjs.
import { test } from "node:test";
import assert from "node:assert/strict";
import { main } from "../src/cli.mjs";
import { CliError } from "../src/errors.mjs";
import { startTimeOf } from "../src/host.mjs";
import { Registry } from "../../seat/src/session.mjs";
import { BUSINESSES, broker, io, tmp } from "./helpers.mjs";
function setup(t, deps = {}, businesses) {
const bus = broker(t, businesses);
const dataRoot = tmp(t);
const run = async (argv, x = io(), more = {}) => {
try {
await main(argv, x, { system: { dataRoot }, transport: bus.transport, talkPollMs: 20, ...deps, ...more });
return { code: 0, io: x };
} catch (e) {
if (!(e instanceof CliError)) throw e;
return { code: e.exitCode, error: e.message, io: x };
}
};
return { ...bus, dataRoot, run };
}
const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
// Row 142 (#1636): without --follow, talk prints and reads the reply to its own message and nothing else.
test("talk sends a REQUEST, prints and reads only the reply to it, and leaves other messages unread", async (t) => {
const s = setup(t);
const cto = s.b.bindLaunch({ business: "demo", role: "cto", run: "cto-run", harness: "pi" });
call(s.b, cto, "role.claim");
// The coder answers on the second poll; a cto note and an unsolicited coder note arrive first.
let polls = 0;
let note;
let unsolicited;
const transport = (business) => {
const inner = s.transport(business);
return async (verb, args) => {
if (verb === "message.receive" && ++polls === 2) {
const [m] = call(s.b, s.coder, "message.receive");
note = call(s.b, cto, "message.send", { to: m.from_role, body: "fyi", class: "INFO" });
unsolicited = call(s.b, s.coder, "message.send", { to: m.from_role, body: "side note", class: "INFO" });
call(s.b, s.coder, "message.send", { to: m.from_role, body: "done: 3 files", class: "RESULT", in_reply_to: m.id });
}
return inner(verb, args);
};
};
// A short --wait makes a reply that is never printed fail here instead of waiting out the default 900 s.
const r = await s.run(["talk", "coder", "fix the build", "--wait", "5", "--business", "demo"], io(), { transport });
assert.equal(r.code, 0, r.error);
const sent = s.calls.find((c) => c.verb === "message.send");
assert.deepEqual(sent.args, { to: "coder", body: "fix the build", class: "REQUEST" });
assert.match(r.io.out.text, /^sent [0-9a-f]{8} to coder\n/);
assert.match(r.io.out.text, /coder \(RESULT, re [0-9a-f]{8}\) [0-9a-f]{8}:\ndone: 3 files\n$/);
assert.doesNotMatch(r.io.out.text, /fyi|side note/, "another role's note and the same role's unsolicited note are not printed");
assert.equal(s.calls.filter((c) => c.verb === "message.read").length, 1, "only the reply was marked read");
// receive delivers a message once, so what talk skipped is no longer receivable; mosaic inbox lists it as unread.
const inbox = io();
await main(["inbox", "--business", "demo"], inbox, { system: { dataRoot: s.dataRoot }, transport: s.transport });
assert.match(inbox.out.text, /messages: 2 unread\n/, "the rest stays unread for mosaic inbox");
assert.match(inbox.out.text, new RegExp(`cto \\(INFO\\) ${note.id.slice(0, 8)}:\nfyi\n`));
assert.match(inbox.out.text, new RegExp(`coder \\(INFO\\) ${unsolicited.id.slice(0, 8)}:\nside note\n`));
});
test("talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show", async (t) => {
const s = setup(t);
const quick = await s.run(["talk", "coder", "hello", "--wait", "0", "--business", "demo"]);
assert.equal(quick.code, 0);
assert.deepEqual(s.calls.map((c) => c.verb), ["message.send"]);
const slow = await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"]);
assert.equal(slow.code, 1);
// Row 142 (#1636): a late reply stays unread, so mosaic inbox lists it.
assert.equal(slow.error, "no reply from coder within 1 s; it may still come, and mosaic inbox shows it");
});
test("a late reply the Console has listed stays unread for mosaic inbox, and the next talk skips it", async (t) => {
const s = setup(t);
assert.equal((await s.run(["talk", "coder", "hello", "--wait", "1", "--business", "demo"])).code, 1);
const { id } = s.store.get("SELECT id FROM messages WHERE from_role='human' ORDER BY seq DESC LIMIT 1");
assert.equal(call(s.b, s.coder, "message.receive")[0].id, id);
const answer = call(s.b, s.coder, "message.send", { to: "human", body: "late answer", class: "RESULT", in_reply_to: id });
// What the Console's send receipt reads, through its reader session.
assert.deepEqual(s.read("replies", { id }).map((m) => m.id), [answer.id]);
const next = await s.run(["talk", "coder", "again", "--wait", "1", "--business", "demo"]);
assert.equal(next.code, 1, "no answer to the second one");
assert.doesNotMatch(next.io.out.text, /late answer/, "the late reply answers the first message, not this one");
assert.deepEqual(s.calls.filter((c) => c.verb === "message.read"), [], "nothing was marked read");
const inbox = io();
await main(["inbox", "--business", "demo"], inbox, { system: { dataRoot: s.dataRoot }, transport: s.transport });
assert.match(inbox.out.text, /messages: 1 unread\n/);
assert.match(inbox.out.text, new RegExp(`coder \\(RESULT, re ${id.slice(0, 8)}\\) ${answer.id.slice(0, 8)}:\nlate answer\n`));
});
// Row 146 (#1640): the lines after "sent" when the role has no running session.
test("talk says when the role isn't running, whether launch.by was told, and how to start launch.by", async (t) => {
const restart = (role, either = "") =>
`${role} isn't running${either}. systemctl --user restart mosaic-bus@demo starts it if the unit runs with --pm (scripts/bus-service.sh status demo shows pm: on). The restart stops running sessions.\n`;
const text = async (s, role) => {
const r = await s.run(["talk", role, "hello", "--wait", "0", "--business", "demo"]);
assert.equal(r.code, 0, r.error);
return r.io.out.text.replace(/^sent [0-9a-f]{8} to \S+\n/, "");
};
// No launch block: nobody to tell.
const plain = setup(t);
assert.equal(await text(plain, "cto"), "cto isn't running, and the business names no launch.by\n");
assert.equal(await text(plain, "coder"), "", "a running role gets the sent line only");
// pm launches: told, and pm itself down too.
const s = setup(t, {}, { demo: { ...BUSINESSES.demo, launch: { by: "pm", instances: ["cto", "coder"], models: {}, limits: {} } } });
assert.equal(await text(s, "cto"), "cto isn't running, pm has been told\n" + restart("pm", " either"));
assert.equal(await text(s, "pm"), restart("pm"));
assert.equal(s.store.get("SELECT count(*) n FROM messages WHERE from_role='_bus'").n, 1, "a message to launch.by tells nobody");
call(s.b, s.b.bindLaunch({ business: "demo", role: "pm", run: "pm-run", harness: "pi" }), "role.claim");
assert.equal(await text(s, "cto"), "cto isn't running, pm has been told\n");
assert.equal(await text(s, "coder"), "");
});
// Row 152 (#1646): a role outside launch.instances. talk says so instead of "has been told", and
// prints no restart line for launch.by, whose start wouldn't launch the role.
test("talk to a role launch.by can't launch says it isn't in the launch block", async (t) => {
const roles = { ...BUSINESSES.demo.roles, qa: { authority: { withinRole: ["message.send"], crossRole: [] } } };
const s = setup(t, {}, { demo: { ...BUSINESSES.demo, roles, launch: { by: "pm", instances: ["cto", "coder"], models: {}, limits: {} } } });
const text = async (role) => {
const r = await s.run(["talk", role, "hello", "--wait", "0", "--business", "demo"]);
assert.equal(r.code, 0, r.error);
return r.io.out.text.replace(/^sent [0-9a-f]{8} to \S+\n/, "");
};
assert.equal(await text("qa"), "qa isn't running and isn't in the launch block, so pm can't launch it\n");
assert.equal(await text("cto"), "cto isn't running, pm has been told\n" + `pm isn't running either. systemctl --user restart mosaic-bus@demo starts it if the unit runs with --pm (scripts/bus-service.sh status demo shows pm: on). The restart stops running sessions.\n`);
const notices = s.store.all("SELECT body FROM messages WHERE from_role='_bus' ORDER BY seq").map((m) => m.body);
assert.equal(notices.length, 2, "pm is told of both");
assert.match(notices[0], / waits: qa has no running session and isn't in the launch block's instances, so a launch of qa is refused\. /);
assert.match(notices[1], / waits: cto has no running session\. Launch cto within /);
});
// Row 151 (#1645): rows 146 and 142 in one talk. The waiting line prints once after "sent", a
// message that isn't the reply arrives first and stays unread, then the reply prints and talk exits 0.
test("talk to a stopped role prints the waiting line once, skips a non-reply and prints the reply", async (t) => {
const s = setup(t, {}, { demo: { ...BUSINESSES.demo, launch: { by: "pm", instances: ["cto", "coder"], models: {}, limits: {} } } });
const pm = s.b.bindLaunch({ business: "demo", role: "pm", run: "pm-run", harness: "pi" });
call(s.b, pm, "role.claim");
// pm's note arrives on the first poll; cto comes up and answers on the second.
let polls = 0;
let note;
let reply;
const transport = (business) => {
const inner = s.transport(business);
return async (verb, args) => {
if (verb === "message.receive" && ++polls === 1) note = call(s.b, pm, "message.send", { to: "human", body: "launching cto", class: "INFO" });
if (verb === "message.receive" && polls === 2) {
const cto = s.b.bindLaunch({ business: "demo", role: "cto", run: "cto-run", harness: "pi" });
call(s.b, cto, "role.claim");
const [m] = call(s.b, cto, "message.receive");
reply = call(s.b, cto, "message.send", { to: m.from_role, body: "on it", class: "RESULT", in_reply_to: m.id });
}
return inner(verb, args);
};
};
const r = await s.run(["talk", "cto", "review the plan", "--wait", "5", "--business", "demo"], io(), { transport });
assert.equal(r.code, 0, r.error);
const sent = s.store.get("SELECT id FROM messages WHERE from_role='human' ORDER BY seq DESC LIMIT 1");
assert.equal(
r.io.out.text,
`sent ${sent.id.slice(0, 8)} to cto\ncto isn't running, pm has been told\ncto (RESULT, re ${sent.id.slice(0, 8)}) ${reply.id.slice(0, 8)}:\non it\n`,
);
assert.equal(polls, 2, "the note came first");
assert.deepEqual(s.calls.filter((c) => c.verb === "message.read").map((c) => c.args.id), [reply.id], "only the reply was marked read");
const inbox = io();
await main(["inbox", "--business", "demo"], inbox, { system: { dataRoot: s.dataRoot }, transport: s.transport });
assert.match(inbox.out.text, /messages: 1 unread\n/);
assert.match(inbox.out.text, new RegExp(`pm \\(INFO\\) ${note.id.slice(0, 8)}:\nlaunching cto\n`));
});
test("talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors", async (t) => {
const s = setup(t);
const agent = () => io({ ...io().env, CLAUDECODE: "1" });
for (const argv of [["talk", "coder", "hi", "--business", "demo"], ["stop", "r0123456789ab"], ["launches", "off", "--business", "demo"], ["launches", "on", "--business", "demo"]]) {
assert.equal((await s.run(argv, agent())).code, 3, argv.join(" "));
}
const usage = [
["talk", "coder", "--business", "demo"],
["talk", "coder", "a", "b", "--business", "demo"],
["talk", "coder", "hi", "--wait", "-1", "--business", "demo"],
["talk", "coder", "hi", "--wait", "1.5", "--business", "demo"],
["stop"],
["stop", "a", "b"],
["stop", "a", "--unsupported", "demo"],
["launches"],
["launches", "maybe"],
["launches", "off", "x", "--business", "demo"],
["launches", "list", "x"],
];
for (const argv of usage) assert.equal((await s.run(argv)).code, 4, argv.join(" "));
assert.equal(s.calls.length, 0);
});
test("launches off and on go to the broker and change the business's launch state", async (t) => {
const s = setup(t);
const state = () => s.store.get("SELECT state FROM launch_state WHERE business=?", "demo")?.state;
const off = await s.run(["launches", "off", "--business", "demo"]);
assert.equal(off.code, 0, off.error);
assert.match(off.io.out.text, /^launches off for demo: .*running sessions keep running\n$/);
assert.equal(state(), "revoked");
const on = await s.run(["launches", "on", "--business", "demo"]);
assert.equal(on.io.out.text, "launches on for demo\n");
assert.notEqual(state(), "revoked");
assert.deepEqual(s.calls.map((c) => c.verb), ["launch.revoke", "launch.restore"]);
});
test("launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner", async (t) => {
const s = setup(t);
assert.equal((await s.run(["launches", "list"])).io.out.text, "no sessions\n");
const r = new Registry(s.dataRoot);
const base = { business: "demo", harness: "pi", model: "claude-sonnet-5-5", launchedBy: "pm", startedAt: "2026-10-09T00:00:00.000Z", runnerPid: process.pid };
r.add({ ...base, instance: "coder", run: "r000000000001", runnerStartTime: startTimeOf(process.pid) });
r.add({ ...base, instance: "reviewer", run: "r000000000002", runnerStartTime: "1" });
const list = await s.run(["launches", "list"]);
assert.equal(
list.io.out.text,
"r000000000001 demo/coder pi claude-sonnet-5-5 by pm since 2026-10-09T00:00:00.000Z running\n" +
"r000000000002 demo/reviewer pi claude-sonnet-5-5 by pm since 2026-10-09T00:00:00.000Z not running (stale)\n",
);
const json = JSON.parse((await s.run(["launches", "list", "--json"])).io.out.text);
assert.deepEqual(json.map((e) => [e.run, e.live]), [["r000000000001", true], ["r000000000002", false]]);
const stale = await s.run(["stop", "r000000000002"]);
assert.equal(stale.code, 0);
assert.equal(stale.io.out.text, "run r000000000002 is not running (stale entry)\n");
const unknown = await s.run(["stop", "nope"]);
assert.equal(unknown.code, 2);
assert.match(unknown.error, /no running session has run id nope/);
// This test process is live with the recorded start time but is not a runner.
const notRunner = await s.run(["stop", "r000000000001"]);
assert.equal(notRunner.code, 2);
assert.match(notRunner.error, /is not a session runner; refusing to signal it/);
assert.equal(s.calls.length, 0, "stop and list never touch the bus");
});