diff --git a/packages/bus/tests/launch-notice.test.mjs b/packages/bus/tests/launch-notice.test.mjs index 3ac83e702..0bf1703a2 100644 --- a/packages/bus/tests/launch-notice.test.mjs +++ b/packages/bus/tests/launch-notice.test.mjs @@ -92,6 +92,12 @@ test('a message to a stopped role sends launch.by one notice, and the result say launcherRunning: true, }); assert.equal(ctx.notices().length, 2); + // Row 151 (#1645): the notice is routed to launch.by's holder, which the Console's waiting lists read. + // message.receive selects on to_role, so only this row shows a notice routed anywhere else. + assert.equal( + ctx.store().get("SELECT holder_run FROM deliveries WHERE message=? AND op='routed'", ctx.notices().at(-1).id).holder_run, + 'chief-run', + ); // A role's message to a stopped role tells launch.by too. ctx.launch('coder', 'coder'); ctx.claim('coder'); diff --git a/packages/cli/tests/verbs.test.mjs b/packages/cli/tests/verbs.test.mjs index f29922e0e..c9d0b9d17 100644 --- a/packages/cli/tests/verbs.test.mjs +++ b/packages/cli/tests/verbs.test.mjs @@ -118,6 +118,44 @@ test("talk says when the role isn't running, whether launch.by was told, and how assert.equal(await text(s, "coder"), ""); }); +// Row 151 (#1645): rows 146 and 142 in one talk. The waiting line prints once after "sent", a +// message that isn't the reply arrives first and stays unread, then the reply prints and talk exits 0. +test("talk to a stopped role prints the waiting line once, skips a non-reply and prints the reply", async (t) => { + const s = setup(t, {}, { demo: { ...BUSINESSES.demo, launch: { by: "pm", instances: ["cto", "coder"], models: {}, limits: {} } } }); + const pm = s.b.bindLaunch({ business: "demo", role: "pm", run: "pm-run", harness: "pi" }); + call(s.b, pm, "role.claim"); + // pm's note arrives on the first poll; cto comes up and answers on the second. + let polls = 0; + let note; + let reply; + const transport = (business) => { + const inner = s.transport(business); + return async (verb, args) => { + if (verb === "message.receive" && ++polls === 1) note = call(s.b, pm, "message.send", { to: "human", body: "launching cto", class: "INFO" }); + if (verb === "message.receive" && polls === 2) { + const cto = s.b.bindLaunch({ business: "demo", role: "cto", run: "cto-run", harness: "pi" }); + call(s.b, cto, "role.claim"); + const [m] = call(s.b, cto, "message.receive"); + reply = call(s.b, cto, "message.send", { to: m.from_role, body: "on it", class: "RESULT", in_reply_to: m.id }); + } + return inner(verb, args); + }; + }; + const r = await s.run(["talk", "cto", "review the plan", "--wait", "5", "--business", "demo"], io(), { transport }); + assert.equal(r.code, 0, r.error); + const sent = s.store.get("SELECT id FROM messages WHERE from_role='human' ORDER BY seq DESC LIMIT 1"); + assert.equal( + r.io.out.text, + `sent ${sent.id.slice(0, 8)} to cto\ncto isn't running, pm has been told\ncto (RESULT, re ${sent.id.slice(0, 8)}) ${reply.id.slice(0, 8)}:\non it\n`, + ); + assert.equal(polls, 2, "the note came first"); + assert.deepEqual(s.calls.filter((c) => c.verb === "message.read").map((c) => c.args.id), [reply.id], "only the reply 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(`pm \\(INFO\\) ${note.id.slice(0, 8)}:\nlaunching cto\n`)); +}); + test("talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors", async (t) => { const s = setup(t); const agent = () => io({ ...io().env, CLAUDECODE: "1" });