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 [--json] scripts/mosaic bus start [--pm] | stop | status [--json] scripts/mosaic talk [--wait SECONDS] [--business ] scripts/mosaic project create [--repo ] | list [--json] -scripts/mosaic threads [--project ] [--json] -scripts/mosaic thread create | open | read [--json] -scripts/mosaic talk --thread [--wait SECONDS | --follow] -scripts/mosaic follow --thread +scripts/mosaic threads [--project ] [--json] [--tester] +scripts/mosaic thread create | open | read [--json] [--tester] +scripts/mosaic talk --thread [--wait SECONDS | --follow] [--tester] +scripts/mosaic follow --thread [--tester] scripts/mosaic stop scripts/mosaic launches off | on [--business ] scripts/mosaic launches list [--json] @@ -277,7 +277,7 @@ scripts/bus-service.sh uninstall | status 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@` runs. It boots the broker, starts the Discord notifier, and needs `/notify//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 ] mosaic project create [--repo ] [--business ] [--tester] mosaic project list [--business ] [--json] [--tester] mosaic threads [--project ] [--business ] [--json] [--tester] -mosaic thread create [--business ] -mosaic thread open [--business ] -mosaic thread read [--business ] [--json] -mosaic talk --thread [--wait | --follow] [--business ] -mosaic follow --thread [--business ] +mosaic thread create [--business ] [--tester] +mosaic thread open [--business ] [--tester] +mosaic thread read [--business ] [--json] [--tester] +mosaic talk --thread [--wait | --follow] [--business ] [--tester] +mosaic follow --thread [--business ] [--tester] mosaic name [--business ] [--tester] mosaic names [--business ] [--json] [--tester] mosaic stop @@ -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 ` 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 `/` 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 `. 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 `/projects//`, 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 ] [--business ] [--json] [--tester]"; export const THREAD_USAGE = [ - "mosaic thread create [--business ]", - "mosaic thread open [--business ]", - "mosaic thread read [--business ] [--json]", + "mosaic thread create [--business ] [--tester]", + "mosaic thread open [--business ] [--tester]", + "mosaic thread read [--business ] [--json] [--tester]", ].join("\n "); export const THREAD_TALK_USAGE = [ - "mosaic talk --thread [--wait | --follow] [--business ]", - "mosaic follow --thread [--business ]", + "mosaic talk --thread [--wait | --follow] [--business ] [--tester]", + "mosaic follow --thread [--business ] [--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 /); 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);