diff --git a/docs/TOOLS.md b/docs/TOOLS.md index baa06fad3..a0424e30c 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -262,9 +262,10 @@ unit's `MOSAIC_CLAUDE_BIN`, from `--claude PATH` or else `command -v claude` PID namespace with a generated bundle, and `--pm` launches the business's `launch.by` instance at start. A host that starts ends the launches a crashed host left as `host-lost` and refuses with exit 3 on a -`sessions.json` it can't read. `talk` sends a REQUEST and prints what -arrives until the reply (polls every 2 s, `--wait` default 900; `0` only -sends). `stop` sends SIGTERM to a run's runner after checking it in +`sessions.json` it can't read. `talk` sends a REQUEST and prints only the +reply to it, marking only that read (polls every 2 s, `--wait` default 900; +`0` only sends); other messages stay unread for `mosaic inbox`, and +`--follow` and `follow` print and read everything. `stop` sends SIGTERM to a run's runner after checking it in `sessions.json`. `launches off|on` revokes and restores `role.launch` for the business; running sessions keep running. `launches list` reads `sessions.json` and needs no broker. `talk`, `stop` and `launches off|on` diff --git a/packages/cli/README.md b/packages/cli/README.md index da6877607..53bb2ab92 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -62,12 +62,13 @@ mosaic launches list [--json] trail ends with its `task_ref` and `follow with: mosaic trail `. It does not pull in the task's rows. - `talk` sends `` to the role as a REQUEST, then polls the human's - inbox every 2 s for up to `--wait` seconds (default 900). It prints every - message that arrives and marks it read, and stops at the reply to its - REQUEST. No reply in time exits 1; the reply may still come, and the next - `mosaic talk` prints it. `--wait 0` only sends. `talk --follow` keeps - printing and marking messages read until Ctrl-C, including messages with - no `in_reply_to`. `follow` runs that same loop without sending. `--wait` + inbox every 2 s for up to `--wait` seconds (default 900). It prints the + reply to its REQUEST, marks only that read, and stops. Any other message + that arrives meanwhile is not printed and stays unread; `mosaic inbox` + lists it. No reply in time exits 1; the reply may still come, and + `mosaic inbox` shows it. `--wait 0` only sends. `talk --follow` keeps + printing and marking every message read until Ctrl-C, including messages + with no `in_reply_to`. `follow` runs that same loop without sending. `--wait` and `--follow` together refuse. These commands consume the same human inbox; run one receiving CLI at a time. Console only reads history. - `launches off` and `on` are the broker's `launch.revoke` and diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index fbca789a9..17956ff1a 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -211,8 +211,9 @@ function formatMessage(m) { } // `mosaic talk `: a REQUEST to the role, then the human's inbox -// until the reply arrives or --wait runs out. --follow keeps receiving after -// that reply; follow starts the same receive loop without sending. +// until the reply to it arrives or --wait runs out; other messages stay unread. +// --follow prints and reads every message and keeps receiving after that +// reply; follow starts the same receive loop without sending. async function talk(rest, io, deps, receiveOnly = false) { const parsed = parse(rest, { flags: ["--follow", "--tester"], values: ["--business", ...(receiveOnly ? [] : ["--wait"])] }); if (parsed.positional.length !== (receiveOnly ? 0 : 2)) throw usage(receiveOnly ? "follow takes no arguments" : "talk takes a role and one quoted text"); @@ -243,12 +244,14 @@ async function talk(rest, io, deps, receiveOnly = false) { while (!controller.signal.aborted) { let answered = false; for (const m of await call("message.receive")) { + // Row 142 (#1636): without --follow only the reply to the sent message is printed and marked read; the rest stays unread for mosaic inbox. + if (!follow && m.in_reply_to !== sent.id) continue; io.stdout.write(formatMessage(m)); await call("message.read", { id: m.id }); if (m.in_reply_to === sent?.id) answered = true; } if (!follow && answered) return; - if (!follow && Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and the next mosaic talk shows it`, 1); + if (!follow && Date.now() >= deadline) throw new CliError(`no reply from ${to} within ${wait} s; it may still come, and mosaic inbox shows it`, 1); if (controller.signal.aborted) break; await new Promise(resolve => { const wake = () => { clearTimeout(timer); controller.signal.removeEventListener("abort", wake); resolve(); }; diff --git a/packages/cli/tests/verbs.test.mjs b/packages/cli/tests/verbs.test.mjs index 6bdbc6793..b9842cdd7 100644 --- a/packages/cli/tests/verbs.test.mjs +++ b/packages/cli/tests/verbs.test.mjs @@ -28,32 +28,42 @@ function setup(t, deps = {}) { const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args }); -test("talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply", async (t) => { +// 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 arrives first and is printed too. + // 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"); - call(s.b, cto, "message.send", { to: m.from_role, body: "fyi", class: "INFO" }); + 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); }; }; - const r = await s.run(["talk", "coder", "fix the build", "--business", "demo"], io(), { transport }); + // 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, /cto \(INFO\) [0-9a-f]{8}:\nfyi\n/); assert.match(r.io.out.text, /coder \(RESULT, re [0-9a-f]{8}\) [0-9a-f]{8}:\ndone: 3 files\n$/); - assert.equal(s.calls.filter((c) => c.verb === "message.read").length, 2); - assert.deepEqual(await s.transport("demo")("message.receive"), [], "both were marked read"); + 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) => { @@ -63,11 +73,11 @@ test("talk --wait 0 only sends; no reply within --wait exits 1 and says where it 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 81 (#1575): only mosaic talk shows a late reply; mosaic inbox lists decisions, never messages. - assert.equal(slow.error, "no reply from coder within 1 s; it may still come, and the next mosaic talk shows it"); + // 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 still reaches the next talk", async (t) => { +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"); @@ -77,8 +87,12 @@ test("a late reply the Console has listed still reaches the next talk", async (t 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.match(next.io.out.text, new RegExp(`coder \\(RESULT, re ${id.slice(0, 8)}\\) ${answer.id.slice(0, 8)}:\nlate answer\n`)); - assert.deepEqual(s.calls.filter((c) => c.verb === "message.read").map((c) => c.args.id), [answer.id]); + 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`)); }); test("talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors", async (t) => {