Files
jason.woltje 6bad02612a lead: commit dewey's untracked evidence (721 files: queue-124-review queue-143 queue-149 queue-165-review queue-170 queue-175 queue-177 queue-184 queue-191)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:14 -06:00

496 lines
30 KiB
Diff

diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index dfce9a63b..1f9a3eb28 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -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
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 3abff626c..2ddff0187 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -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
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
index 09e9467d0..37b11ea0d 100644
--- a/packages/cli/src/cli.mjs
+++ b/packages/cli/src/cli.mjs
@@ -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");
diff --git a/packages/cli/src/thread.mjs b/packages/cli/src/thread.mjs
index 8eda59b76..498a99ef3 100644
--- a/packages/cli/src/thread.mjs
+++ b/packages/cli/src/thread.mjs
@@ -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);
}
diff --git a/packages/cli/tests/tester-reads.test.mjs b/packages/cli/tests/tester-reads.test.mjs
index db8f97bce..e22170f4f 100644
--- a/packages/cli/tests/tester-reads.test.mjs
+++ b/packages/cli/tests/tester-reads.test.mjs
@@ -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"],
diff --git a/packages/cli/tests/tester-threads.test.mjs b/packages/cli/tests/tester-threads.test.mjs
new file mode 100644
index 000000000..d5b6a5a0c
--- /dev/null
+++ b/packages/cli/tests/tester-threads.test.mjs
@@ -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"), []);
+});
diff --git a/packages/cli/tests/thread-cli.test.mjs b/packages/cli/tests/thread-cli.test.mjs
index 80d52db66..fb278d792 100644
--- a/packages/cli/tests/thread-cli.test.mjs
+++ b/packages/cli/tests/thread-cli.test.mjs
@@ -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);