Row 145 added 3 lines to cli.mjs after the approved round 1 base; same change, new base, regenerated candidate manifest, TOOLS.md line rewrapped. Every cli test file passes singly; 6/6 mutants killed on the new base. Packet only; the candidate is row142.patch and is not applied to the tree. Nothing pushed. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
160 lines
11 KiB
Diff
160 lines
11 KiB
Diff
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
|
|
index baa06fad3..fa1edff42 100644
|
|
--- a/docs/TOOLS.md
|
|
+++ b/docs/TOOLS.md
|
|
@@ -262,9 +262,11 @@ 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 <task>`. It
|
|
does not pull in the task's rows.
|
|
- `talk` sends `<text>` 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 60a0ba29b..dd8774175 100644
|
|
--- a/packages/cli/src/cli.mjs
|
|
+++ b/packages/cli/src/cli.mjs
|
|
@@ -213,8 +213,9 @@ function formatMessage(m) {
|
|
}
|
|
|
|
// `mosaic talk <role> <text>`: 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");
|
|
@@ -245,12 +246,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) => {
|