diff --git a/extensions/connector-hermes/plugin/cotal/adapter.py b/extensions/connector-hermes/plugin/cotal/adapter.py index a9a93d72b4..95a5c659fe 100644 --- a/extensions/connector-hermes/plugin/cotal/adapter.py +++ b/extensions/connector-hermes/plugin/cotal/adapter.py @@ -179,6 +179,9 @@ async def send( # The shared chat names no peer: the reply goes to where the answered message came from. # Progress and notices name no message and go to the origin of the latest turn. target = self._origins.get(answered) if answered else None + if target is None and self._running and "channel" in self._running and not self._channel_replies: + # An unanchored send in a channel turn (progress, a notice) falls back to the channel. + return SendResult(success=True, message_id=uuid.uuid4().hex) target = target or self._running if target is None: logger.warning("cotal: a send to the shared chat names no delivered message (%r) and no turn has run; it is not sent", reply_to) diff --git a/extensions/connector-hermes/smoke/channel-replies.probe.py b/extensions/connector-hermes/smoke/channel-replies.probe.py index 3b4c3b7827..7e71457810 100644 --- a/extensions/connector-hermes/smoke/channel-replies.probe.py +++ b/extensions/connector-hermes/smoke/channel-replies.probe.py @@ -137,6 +137,11 @@ async def main() -> None: # So does a channel send anchored to a DM, not a channel message. await adapter.send("channel:general", "anchored to a DM", reply_to="m-dm") out["sent"] = adapter._client.replies + # A send in the channel turn that names no message (progress, a turn-error notice). + adapter._client.replies = [] + await adapter._inject(dict(CH, id="m-ch2")) + await adapter.send(adapter.handled[-1].source["chat_id"], "unanchored") + out["unanchored_sent"] = adapter._client.replies print("RESULT " + json.dumps(out)) diff --git a/extensions/connector-hermes/smoke/channel-replies.smoke.ts b/extensions/connector-hermes/smoke/channel-replies.smoke.ts index b807efd87e..691f0dd939 100644 --- a/extensions/connector-hermes/smoke/channel-replies.smoke.ts +++ b/extensions/connector-hermes/smoke/channel-replies.smoke.ts @@ -44,7 +44,7 @@ function hard(condition: unknown, name: string): asserts condition { throw new Error(name); } -const EXPECTED_CELLS = 17; +const EXPECTED_CELLS = 20; if (process.platform === "win32") skipSuite("the Hermes connector is Unix-only"); const pkgDir = fileURLToPath(new URL("..", import.meta.url)); @@ -54,7 +54,13 @@ if (!python) skipSuite("Python is unavailable for the Hermes adapter probe"); const pythonBin = python; type Sent = { target: { peerId?: string; channel?: string }; text: string }; -type ProbeResult = { channel_note: boolean; dm_note: boolean; channel_reply_ok: boolean; sent: Sent[] }; +type ProbeResult = { + channel_note: boolean; + dm_note: boolean; + channel_reply_ok: boolean; + sent: Sent[]; + unanchored_sent: Sent[]; +}; function run(mode: "subject" | "control" | "shared"): ProbeResult { const res = spawnSync(pythonBin, [probe, `${pkgDir}plugin`, mode], { encoding: "utf8", timeout: 60_000 }); @@ -77,6 +83,9 @@ for (const mode of ["subject", "shared"] as const) { assert.equal(r.channel_reply_ok, true, `${mode}: a dropped channel reply reports success, so the gateway does not retry`); assert.equal(r.channel_note, true, `${mode}: a channel message tells the model its reply is not posted`); assert.equal(r.dm_note, false, `${mode}: a DM carries no channel note`); + // Per-peer, an unanchored send names its chat like an explicit send (cron), so it still posts. + const unanchored = mode === "shared" ? [] : [{ target: { channel: "ops" }, text: "unanchored" }]; + assert.deepEqual(r.unanchored_sent, unanchored, `${mode}: an unanchored send follows the chat it names`); } const control = run("control"); @@ -91,6 +100,11 @@ assert.deepEqual( ); assert.equal(control.channel_note, false, "control: by default a channel message carries no note"); assert.equal(control.dm_note, false, "control: a DM carries no note"); +assert.deepEqual( + control.unanchored_sent, + [{ target: { channel: "ops" }, text: "unanchored" }], + "control: by default an unanchored send in a channel turn is posted", +); console.log("hermes channel replies: channel posts are tool-only when channel_replies is false"); if (cells !== EXPECTED_CELLS) {