row 184 (#1682): tester thread verbs in the CLI

mosaic thread create|open|read and threads take --tester and run as the tester through
tester-cli.mjs. talk --thread and follow --thread take --tester too, and the row 177 refusal of
--thread with --tester is gone. The bus already checks thread ownership (thread-not-owned), so
the CLI adds no rule of its own, and every verb it sends as the tester is in TESTER_VERBS. follow
already receives with message.receive, so a tester reply arrives without polling inbox.
docs/TOOLS.md and packages/cli/README.md list the new flags.

The new tester-threads.test.mjs runs an in-process Broker. It covers create, list, open, send,
read and follow on a tester thread; thread-not-owned on a human thread both ways; and the human
forms unchanged with no tester transport call. A real-process test runs scripts/mosaic as the
tester. thread-cli.test.mjs and tester-reads.test.mjs drop the argvs that pinned the old
refusal. Brief mutants (talk sent as the human, follow without the thread filter) are killed.

Filbert approved round 1 in #1682 comment 29396 with no findings or notes. Their run on 3da362982,
with row 182 in the broker, added one more mutant (no thread read before the first receive in
follow), and it was killed.
This commit is contained in:
2026-10-20 01:12:07 -05:00
committed by jason.woltje
parent dd7656cbf3
commit 87aca4cb8d
7 changed files with 345 additions and 28 deletions
+5 -5
View File
@@ -263,10 +263,10 @@ scripts/mosaic trail <task|decision> [--json]
scripts/mosaic bus start <business> [--pm] | stop | status [--json]
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
scripts/mosaic threads [--project <project>] [--json]
scripts/mosaic thread create <project> <name> | open <thread> | read <thread> [--json]
scripts/mosaic talk --thread <thread> <text> [--wait SECONDS | --follow]
scripts/mosaic follow --thread <thread>
scripts/mosaic threads [--project <project>] [--json] [--tester]
scripts/mosaic thread create <project> <name> | open <thread> | read <thread> [--json] [--tester]
scripts/mosaic talk --thread <thread> <text> [--wait SECONDS | --follow] [--tester]
scripts/mosaic follow --thread <thread> [--tester]
scripts/mosaic stop <run>
scripts/mosaic launches off | on [--business <id>]
scripts/mosaic launches list [--json]
@@ -277,7 +277,7 @@ scripts/bus-service.sh uninstall | status <business>
The human commands read and resolve through the broker's human transport
(`packages/bus/src/human-cli.mjs`), never the SQLite file. They refuse inside
an agent run, except the forms that take `--tester` (rows 110 and 175), which
an agent run, except the forms that take `--tester` (rows 110, 175 and 184), which
run as the business's tester; `packages/cli/README.md` lists them. `bus start` is the host the systemd user unit
`mosaic-bus@<business>` runs. It boots the broker, starts the Discord
notifier, and needs `<dataRoot>/notify/<business>/notify.json`. That
+16 -7
View File
@@ -35,11 +35,11 @@ mosaic follow [--business <id>]
mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]
mosaic project list [--business <id>] [--json] [--tester]
mosaic threads [--project <project>] [--business <id>] [--json] [--tester]
mosaic thread create <project> <name> [--business <id>]
mosaic thread open <thread> [--business <id>]
mosaic thread read <thread> [--business <id>] [--json]
mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>]
mosaic follow --thread <thread> [--business <id>]
mosaic thread create <project> <name> [--business <id>] [--tester]
mosaic thread open <thread> [--business <id>] [--tester]
mosaic thread read <thread> [--business <id>] [--json] [--tester]
mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
mosaic follow --thread <thread> [--business <id>] [--tester]
mosaic name <instance> <name> [--business <id>] [--tester]
mosaic names [--business <id>] [--json] [--tester]
mosaic stop <run>
@@ -118,6 +118,14 @@ mosaic launches list [--json]
that isn't the user's is refused before any message is received. A plain
`mosaic talk <role> <text>` to the threaded instance has no default
thread: it exits 2 and names `--thread` and `mosaic threads`.
- With `--tester` (row 184, #1682) `thread create`, `open` and `read`,
`talk --thread` and `follow --thread` run as the tester on the tester's own
threads, which the bus keeps apart from the human's (owner `tester`, row
177). `threads --tester` lists only those. A human thread's full id
refuses `thread-not-owned` and its `<project>/<name>` or prefix isn't in
the tester's list, as for another user's thread above. The reply comes
back through `message.receive` as the tester's message; `inbox --tester`
lists decisions only. Tests: `tests/tester-threads.test.mjs`.
- `trail` prints rows in the broker's order (at, table, seq). A decision's
trail ends with its `task_ref` and `follow with: mosaic trail <task>`. It
does not pull in the task's rows.
@@ -183,8 +191,9 @@ mosaic launches list [--json]
exits 3. A run launched but not yet claimed counts as not running. The
role's reply comes back to the tester, not to the human's inbox. `decide`, `stop`, `launches`,
`inbox --read` or `--read-all`, `limits set`, `reset` and `migrate`,
`project check`, the `thread` commands, `talk` or `follow --thread` and
`bus start` or `stop` take no `--tester` (exit 4).
`project check` and `bus start` or `stop` take no `--tester` (exit 4).
Row 184 (#1682) adds the `thread` commands and `talk` and `follow
--thread` (above).
- `project init --tester` (row 175, mos-0872 Q2) may run inside an agent.
Before anything touches the disk it refuses a repository outside
`<dataRoot>/projects/<business>/`, or that directory itself, since a
+3 -3
View File
@@ -350,15 +350,15 @@ function waitingLines(w, business) {
// Row 128 (#1622): --thread names a thread (src/thread.mjs) and sends with thread.send. The
// reply is found as before; --follow prints and reads only that thread's messages, and every
// other message stays unread for mosaic inbox.
// Row 184 (#1682, Q173): with --tester the same loop runs as the tester on its own thread. The
// tester's replies arrive only through message.receive, as for the human; inbox --tester lists
// decisions only.
async function talk(rest, io, deps, receiveOnly = false) {
const parsed = parse(rest, { flags: ["--follow", "--tester"], values: ["--business", "--thread", ...(receiveOnly ? [] : ["--wait"])] });
const threadRef = parsed.values["--thread"];
if (parsed.positional.length !== (receiveOnly ? 0 : threadRef === undefined ? 2 : 1)) {
throw usage(receiveOnly ? "follow takes no arguments" : threadRef === undefined ? "talk takes a role and one quoted text" : "talk --thread takes one quoted text");
}
// Row 177 (#1675): the tester has threads of its own now, but the CLI's thread verbs stay the
// human's; a tester talk --thread is a follow-up row.
if (threadRef !== undefined && parsed.flags.has("--tester")) throw usage("--thread takes no --tester");
if (receiveOnly && parsed.flags.has("--follow")) throw usage("follow already follows");
const follow = receiveOnly || parsed.flags.has("--follow");
if (follow && parsed.values["--wait"] !== undefined) throw usage("--follow and --wait cannot be combined");
+9 -7
View File
@@ -3,6 +3,8 @@
// and the thread resolver `mosaic talk --thread` and `mosaic follow --thread` (src/cli.mjs) use.
// The broker owns every rule: ownership, uniqueness, the one threaded instance. The CLI adds
// no check of those; it resolves a reference to an id and prints what the broker returns.
// Row 184 (#1682, Q173): each verb takes --tester and runs as the tester through tester-cli.mjs,
// on the tester's own threads (owner `tester`, row 177). The broker's ownership check is the only one.
import { CliError } from "./errors.mjs";
import { shortId } from "./format.mjs";
@@ -11,13 +13,13 @@ import { rolesDir } from "./config.mjs";
export const THREADS_USAGE = "mosaic threads [--project <project>] [--business <id>] [--json] [--tester]";
export const THREAD_USAGE = [
"mosaic thread create <project> <name> [--business <id>]",
"mosaic thread open <thread> [--business <id>]",
"mosaic thread read <thread> [--business <id>] [--json]",
"mosaic thread create <project> <name> [--business <id>] [--tester]",
"mosaic thread open <thread> [--business <id>] [--tester]",
"mosaic thread read <thread> [--business <id>] [--json] [--tester]",
].join("\n ");
export const THREAD_TALK_USAGE = [
"mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>]",
"mosaic follow --thread <thread> [--business <id>]",
"mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]",
"mosaic follow --thread <thread> [--business <id>] [--tester]",
].join("\n ");
// The parse shape callFor reads (flags, values, positional). Errors carry the verb's own usage.
@@ -108,7 +110,7 @@ export function threadMembers(call, thread) {
const json = (io, value) => io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
// Row 175 (#1673, Q173): with --tester the tester lists threads. Since row 177 (#1675) those are
// its own threads (owner tester); the CLI has no other tester thread verb.
// its own threads (owner tester).
export async function threadsCommand(args, io, callFor) {
const parsed = parseArgs(args, THREADS_USAGE, { flags: ["--json", "--tester"], values: ["--business", "--project"] });
if (parsed.positional.length !== 0) throw new CliError(`threads takes no arguments\n${THREADS_USAGE}`, 4);
@@ -125,7 +127,7 @@ export async function threadsCommand(args, io, callFor) {
export async function threadCommand(args, io, callFor, format) {
const [sub, ...rest] = args;
if (!["create", "open", "read"].includes(sub)) throw new CliError(`usage:\n ${THREAD_USAGE}`, 4);
const parsed = parseArgs(rest, THREAD_USAGE, { flags: sub === "read" ? ["--json"] : [], values: ["--business"] });
const parsed = parseArgs(rest, THREAD_USAGE, { flags: sub === "read" ? ["--json", "--tester"] : ["--tester"], values: ["--business"] });
if (parsed.positional.length !== (sub === "create" ? 2 : 1)) {
throw new CliError(`thread ${sub} takes ${sub === "create" ? "a project and a name" : "one thread"}\n${THREAD_USAGE}`, 4);
}
+2 -5
View File
@@ -103,11 +103,8 @@ test("row 175: --tester on limits set, reset and migrate and every write form ou
["decide", "d1", "yes", "--business", "acme"],
["inbox", "--read", "abcdefgh", "--business", "acme"],
["inbox", "--read-all", "--business", "acme"],
["thread", "create", "alpha", "one", "--business", "acme"],
["thread", "open", "t-1", "--business", "acme"],
["thread", "read", "t-1", "--business", "acme"],
["talk", "--thread", "t-1", "hello", "--business", "acme"],
["follow", "--thread", "t-1", "--business", "acme"],
// Row 184 (#1682): the thread verbs and talk and follow --thread left this list
// (tests/tester-threads.test.mjs).
["stop", "run-1"],
["launches", "off", "--business", "acme"],
["launches", "on", "--business", "acme"],
+309
View File
@@ -0,0 +1,309 @@
// Row 184 (#1682, Q173): the CLI's thread verbs with --tester. The broker is in process on a real
// store, as in thread-cli.test.mjs, with the human and the tester each behind a stand-in transport
// that records its verbs. Tester forms run with an agent marker set, as Q173's seats run them. A
// run bound to a thread plays the assistant, and the chief plays a role outside any thread. The
// last test runs scripts/mosaic as its own process, so the tester-cli.mjs it spawns is the real one.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BusError, Broker } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { Store } from "../../bus/src/store.mjs";
import { systemFor } from "../../business/tests/helpers.mjs";
import { run } from "../src/cli.mjs";
import { CliError } from "../src/errors.mjs";
import { busExit } from "../src/transport.mjs";
import { fixture, humanEnv, io, tmp } from "./helpers.mjs";
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), "utf8"));
const roles = {
chief: { definition: "chief-of-staff", authority: { withinRole: ["message.send"], crossRole: [] } },
assistant: { definition: "assistant", authority: { withinRole: ["message.send"], crossRole: [] } },
};
const definitions = { "chief-of-staff": {}, assistant: { messaging: shipped("assistant").messaging } };
const inAgent = () => humanEnv({ CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" });
function setup(t) {
const root = mkdtempSync(join(tmpdir(), "mosaic-tester-threads-"));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const b = new Broker({
organization: (business, verb) => {
assert.equal(verb, "plugins");
return { business, revision: "a".repeat(64), plugins: { channel: ["cli", "webui"] } };
},
store,
businesses: { demo: { id: "demo", human: "jason", vars: { "bus.tester": true }, arbiters: { technical: "chief", delivery: "chief" }, roles, definitions, projects: { stack: { root: "/nonexistent" } } } },
});
const caps = {
human: b.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true }),
tester: b.bindTester({ business: "demo" }),
};
const call = (who, verb, args = {}) => JSON.parse(JSON.stringify(b.request(caps[who], { verb, args })));
const launch = (name, role, extra = {}) => {
caps[name] = b.bindLaunch({ business: "demo", role, run: `${name}-run`, harness: "pi", ...extra });
call(name, "role.claim");
};
const hooks = {};
const calls = { human: [], tester: [] };
const via = (who) => () => async (verb, args = {}) => {
calls[who].push(verb);
try {
const result = structuredClone(b.request(caps[who], { verb, args }));
hooks[verb]?.(result, args);
return result;
} catch (e) {
if (!(e instanceof BusError)) throw e;
const error = new CliError(e.code, busExit(e.code));
error.code = e.code;
throw error;
}
};
const deps = { system: { dataRoot: root }, transport: via("human"), testerTransport: via("tester"), talkPollMs: 5 };
const count = (table) => store.get(`SELECT count(*) n FROM ${table}`).n;
// The rows a refused command must not have written.
const writes = () => ["messages", "thread_opens", "deliveries", "message_threads", "threads", "projects"].map(count);
const cli = (env) => async (argv, extra = {}) => {
const x = io(env);
const code = await run([...argv, "--business", "demo"], x, { ...deps, ...extra });
return { code, out: x.out.text, err: x.err.text };
};
const human = cli(humanEnv());
const testerCli = cli(inAgent());
const tester = (argv, extra) => testerCli([...argv, "--tester"], extra);
const project = call("human", "project.create", { name: "stack", repo: "stack" }).id;
return { b, store, call, launch, hooks, calls, deps, writes, human, tester, project };
}
// Each message to the tester, by body: true once it has been marked read.
const testerRead = (s) => Object.fromEntries(s.store.all(
"SELECT body, EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read') AS r FROM messages m WHERE to_role='tester' ORDER BY seq",
).map((m) => [m.body, m.r === 1]));
const byBody = (s, body) => s.store.get("SELECT * FROM messages WHERE body=?", body);
test("row 184: with --tester, thread create, threads, open, talk --thread and read run on the tester's own thread", async (t) => {
const s = setup(t);
const made = await s.tester(["thread", "create", "stack", "probe"]);
assert.equal(made.code, 0, made.err);
const row = s.store.get("SELECT id, owner, instance FROM threads WHERE name='probe'");
assert.deepEqual([row.owner, row.instance], ["tester", "assistant"]);
assert.equal(made.out, `created stack/probe id ${row.id} instance assistant\n`);
assert.equal((await s.tester(["threads"])).out, `stack/probe assistant ${row.id.slice(0, 8)}\n`);
assert.deepEqual(JSON.parse((await s.tester(["threads", "--json"])).out).map((r) => r.id), [row.id]);
const opened = await s.tester(["thread", "open", "stack/probe"]);
assert.deepEqual([opened.code, opened.out], [0, "opened stack/probe\n"]);
assert.deepEqual(s.store.all("SELECT thread, \"by\" FROM thread_opens").map((r) => [r.thread, r.by]), [[row.id, "tester"]]);
s.launch("a", "assistant", { thread: row.id });
s.hooks["thread.send"] = (sent) => s.call("a", "message.send", { to: "tester", body: "the answer", class: "RESULT", in_reply_to: sent.id });
const talked = await s.tester(["talk", "--thread", row.id.slice(0, 8), "what is the state?"]);
assert.equal(talked.code, 0, talked.err);
const q = byBody(s, "what is the state?");
const r = byBody(s, "the answer");
assert.deepEqual([q.from_role, q.from_run, q.to_role, q.class], ["tester", "tester-cli", "assistant", "REQUEST"]);
assert.equal(talked.out, `sent ${q.id.slice(0, 8)} to stack/probe\nassistant (RESULT, re ${q.id.slice(0, 8)}) ${r.id.slice(0, 8)}:\nthe answer\n`);
assert.deepEqual(testerRead(s), { "the answer": true });
const read = await s.tester(["thread", "read", "stack/probe"]);
assert.equal(read.out, `tester (REQUEST) ${q.id.slice(0, 8)}:\nwhat is the state?\nassistant (RESULT, re ${q.id.slice(0, 8)}) ${r.id.slice(0, 8)}:\nthe answer\n`);
assert.deepEqual(JSON.parse((await s.tester(["thread", "read", row.id, "--json"])).out).map((m) => m.id), [q.id, r.id]);
assert.equal((await s.tester(["talk", "--thread", "stack/probe", "send only", "--wait", "0"])).out, `sent ${byBody(s, "send only").id.slice(0, 8)} to stack/probe\n`);
// Every call went through the tester's transport, and the human sees none of it.
assert.deepEqual(s.calls.human, []);
assert.ok(!s.calls.tester.includes("inbox"));
assert.equal((await s.human(["threads"])).out, "no threads\n");
assert.deepEqual(s.call("human", "messages", { limit: 200 }).messages, []);
});
test("row 184: talk --thread --follow and follow --thread with --tester print and read only that thread's messages, from message.receive", async (t) => {
const s = setup(t);
const P = s.call("tester", "thread.create", { project: s.project, name: "probe" }).id;
const O = s.call("tester", "thread.create", { project: s.project, name: "other" }).id;
s.launch("chief", "chief");
s.launch("a", "assistant", { thread: P });
s.launch("o", "assistant", { thread: O });
// Replies to the tester outside any thread and in its other thread, still undelivered.
const ping = s.call("tester", "message.send", { to: "chief", body: "ping", class: "REQUEST" }).id;
s.call("chief", "message.send", { to: "tester", body: "outside any thread", in_reply_to: ping });
const asked = s.call("tester", "thread.send", { thread: O, body: "other question", class: "REQUEST" }).id;
s.call("o", "message.send", { to: "tester", body: "other answer", in_reply_to: asked });
const controller = new AbortController();
let polls = 0;
const base = s.deps.testerTransport;
const testerTransport = (business) => {
const inner = base(business);
return async (verb, args) => {
if (verb === "message.receive") {
polls++;
const question = byBody(s, "question")?.id;
if (polls === 1) s.call("a", "message.send", { to: "tester", body: "answer", class: "RESULT", in_reply_to: question });
if (polls === 2) s.call("a", "message.send", { to: "tester", body: "later in probe", class: "INFO", in_reply_to: question });
if (polls === 3) controller.abort();
}
return inner(verb, args);
};
};
const followed = await s.tester(["talk", "--thread", "stack/probe", "question", "--follow"], { testerTransport, signal: controller.signal });
assert.equal(followed.code, 0, followed.err);
assert.equal(polls, 3);
assert.match(followed.out, /\nanswer\n/);
assert.match(followed.out, /\nlater in probe\n$/);
assert.doesNotMatch(followed.out, /outside any thread|other answer/);
assert.deepEqual(testerRead(s), { "outside any thread": false, "other answer": false, answer: true, "later in probe": true });
assert.ok(s.calls.tester.includes("message.receive"));
assert.ok(!s.calls.tester.includes("inbox"));
assert.deepEqual(s.calls.human, []);
// follow --thread is the same loop without the send, from the start of the thread.
const s2 = setup(t);
const P2 = s2.call("tester", "thread.create", { project: s2.project, name: "probe" }).id;
const O2 = s2.call("tester", "thread.create", { project: s2.project, name: "other" }).id;
s2.launch("chief", "chief");
s2.launch("a", "assistant", { thread: P2 });
s2.launch("o", "assistant", { thread: O2 });
const mine = s2.call("tester", "thread.send", { thread: P2, body: "earlier question", class: "REQUEST" }).id;
s2.call("a", "message.send", { to: "tester", body: "older in probe", in_reply_to: mine });
const theirs = s2.call("tester", "thread.send", { thread: O2, body: "other question", class: "REQUEST" }).id;
s2.call("o", "message.send", { to: "tester", body: "other answer", in_reply_to: theirs });
const ping2 = s2.call("tester", "message.send", { to: "chief", body: "ping", class: "REQUEST" }).id;
s2.call("chief", "message.send", { to: "tester", body: "outside any thread", in_reply_to: ping2 });
const c2 = new AbortController();
let n = 0;
const base2 = s2.deps.testerTransport;
const only = await s2.tester(["follow", "--thread", "stack/probe"], {
signal: c2.signal,
testerTransport: (business) => {
const inner = base2(business);
return async (verb, args) => {
if (verb === "message.receive" && ++n === 2) c2.abort();
return inner(verb, args);
};
},
});
assert.equal(only.code, 0, only.err);
assert.match(only.out, /\nolder in probe\n/);
assert.doesNotMatch(only.out, /other answer|outside any thread/);
assert.deepEqual(testerRead(s2), { "older in probe": true, "other answer": false, "outside any thread": false });
assert.equal(s2.writes()[1], 0, "follow opens nothing");
assert.equal(s2.call("tester", "thread.messages", { thread: P2 }).length, 2, "follow sends nothing");
assert.deepEqual(s2.calls.human, []);
});
test("row 184: with --tester a human thread refuses with the bus's code, and nothing is written", async (t) => {
const s = setup(t);
const H = s.call("human", "thread.create", { project: s.project, name: "alpha" }).id;
s.launch("chief", "chief");
s.launch("a", "assistant", { thread: H });
s.call("human", "thread.send", { thread: H, body: "the human's", class: "REQUEST" });
// A reply waiting for the tester: message.receive would write a delivery row for it.
const ping = s.call("tester", "message.send", { to: "chief", body: "ping", class: "REQUEST" }).id;
s.call("chief", "message.send", { to: "tester", body: "waiting", in_reply_to: ping });
const before = s.writes();
const history = s.call("human", "thread.messages", { thread: H });
for (const argv of [["thread", "open", H], ["thread", "read", H], ["talk", "--thread", H, "hello"], ["follow", "--thread", H], ["talk", "--thread", H, "hello", "--follow"]]) {
const refused = await s.tester(argv, { signal: AbortSignal.timeout(500) });
assert.deepEqual([refused.code, refused.err, refused.out], [2, "mosaic: thread-not-owned\n", ""], argv.join(" "));
}
// By name or prefix the human's thread isn't in the tester's list at all.
for (const ref of ["stack/alpha", H.slice(0, 8)]) {
const unknown = await s.tester(["thread", "read", ref]);
assert.deepEqual([unknown.code, /^mosaic: no thread of yours matches /.test(unknown.err)], [2, true], ref);
}
// A thread name is unique in its project whoever owns it.
const dup = await s.tester(["thread", "create", "stack", "alpha"]);
assert.deepEqual([dup.code, dup.err], [2, "mosaic: thread-exists\n"]);
assert.equal((await s.tester(["threads"])).out, "no threads\n");
assert.deepEqual(s.writes(), before);
assert.deepEqual(s.call("human", "thread.messages", { thread: H }), history);
assert.deepEqual(testerRead(s), { waiting: false });
assert.deepEqual(s.calls.human, []);
// The other way round as well: the human doesn't reach the tester's thread.
const T = s.call("tester", "thread.create", { project: s.project, name: "probe" }).id;
const back = await s.human(["thread", "read", T]);
assert.deepEqual([back.code, back.err], [2, "mosaic: thread-not-owned\n"]);
});
test("row 184: without --tester the thread verbs stay the human's and never call the tester transport", async (t) => {
const s = setup(t);
const made = await s.human(["thread", "create", "stack", "alpha"]);
assert.equal(made.code, 0, made.err);
const [row] = s.call("human", "thread.list");
assert.equal(made.out, `created stack/alpha id ${row.id} instance assistant\n`);
assert.equal((await s.human(["threads"])).out, `stack/alpha assistant ${row.id.slice(0, 8)}\n`);
assert.equal((await s.human(["thread", "open", "stack/alpha"])).out, "opened stack/alpha\n");
s.launch("a", "assistant", { thread: row.id });
s.hooks["thread.send"] = (sent) => s.call("a", "message.send", { to: "human", body: "the answer", class: "RESULT", in_reply_to: sent.id });
const talked = await s.human(["talk", "--thread", "stack/alpha", "hi"]);
const q = byBody(s, "hi");
assert.deepEqual([q.from_role, q.from_run], ["human", "human-cli"]);
assert.equal(talked.out, `sent ${q.id.slice(0, 8)} to stack/alpha\nassistant (RESULT, re ${q.id.slice(0, 8)}) ${byBody(s, "the answer").id.slice(0, 8)}:\nthe answer\n`);
assert.match((await s.human(["thread", "read", "stack/alpha"])).out, /^human \(REQUEST\) /);
const c = new AbortController();
const followed = await s.human(["follow", "--thread", "stack/alpha"], { signal: c.signal, talkPollMs: 1, transport: (business) => {
const inner = s.deps.transport(business);
return async (verb, args) => {
if (verb === "message.receive") c.abort();
return inner(verb, args);
};
} });
assert.equal(followed.code, 0, followed.err);
assert.deepEqual(s.calls.tester, []);
});
// The real tester transport: scripts/mosaic runs as its own process and spawns tester-cli.mjs,
// which reaches the runtime in this one, as tester-reads.test.mjs's W7 does.
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
function mosaic(args, env) {
const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !/^MOSAIC_BUS_(TESTER|CLI)_NONCE$/.test(k)));
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env: clean, timeout: 60000 });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
child.on("error", reject);
child.on("close", (status) => resolve({ status, out, err }));
});
}
test("row 184: scripts/mosaic runs thread create, threads, talk --thread and thread read as the tester through tester-cli.mjs", async (t) => {
const root = tmp(t, "mosaic-cli-tester-threads-");
const f = fixture(root, "acme");
const none = { authority: { withinRole: ["message.send"], crossRole: [] } };
const businesses = { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, vars: { "bus.tester": true },
roles: { coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } } } };
const rt = await startBroker({ dataRoot: f.dataRoot, businesses, organization: { dir: join(root, "config"), rolesDir: f.roles, system: systemFor(root) } });
t.after(() => rt.close());
const human = rt.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
const call = (verb, args = {}) => rt.broker.request(human, { verb, args });
call("plugins.set", { revision: call("plugins").revision, plugins: { channel: ["cli", "webui"] } });
call("project.create", { name: "stack" });
const agent = { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" };
const as = (args) => mosaic([...args, "--business", "acme", "--tester"], agent);
const made = await as(["thread", "create", "stack", "probe"]);
assert.equal(made.status, 0, made.err);
assert.match(made.out, /^created stack\/probe {2}id [0-9a-f-]{36} {2}instance assistant\n$/);
const listed = await as(["threads", "--json"]);
assert.deepEqual(JSON.parse(listed.out).map((r) => r.name), ["probe"]);
const talked = await as(["talk", "--thread", "stack/probe", "hello", "--wait", "0"]);
assert.equal(talked.status, 0, talked.err);
assert.match(talked.out, /^sent [0-9a-f]{8} to stack\/probe\n$/);
const read = await as(["thread", "read", "stack/probe", "--json"]);
assert.equal(read.status, 0, read.err);
assert.deepEqual(JSON.parse(read.out).map((m) => [m.from_role, m.from_run, m.body]), [["tester", "tester-cli", "hello"]]);
// The human's list leaves the tester's thread out.
assert.deepEqual(call("thread.list"), []);
});
+1 -1
View File
@@ -308,7 +308,7 @@ test("REQ-CLI-2 plain talk to the assistant asks for a thread and exits 2 withou
assert.match(plain.err, /mosaic talk --thread <thread> <text>/);
assert.match(plain.err, /mosaic threads/);
assert.deepEqual(s.writes(), before);
for (const argv of [["talk", "--thread", "stack/alpha"], ["talk", "--thread", "stack/alpha", "assistant", "hello", "--wait", "0"],["talk", "--thread", "stack/alpha", "hi", "--tester"], ["talk", "--thread", "stack/alpha", "hi", "--follow", "--wait", "1"]]) {
for (const argv of [["talk", "--thread", "stack/alpha"], ["talk", "--thread", "stack/alpha", "assistant", "hello", "--wait", "0"], ["talk", "--thread", "stack/alpha", "hi", "--follow", "--wait", "1"]]) {
assert.equal((await s.cli(argv)).code, 4, argv.join(" "));
}
assert.deepEqual(s.writes(), before);