From 132f28dd1133d738c453f62ab9a962dad89d0bfc Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 26 Jun 2026 22:25:38 +0200 Subject: [PATCH] fix(coding-agent): prevented duplicate IRC broadcast messages in transcript - Suppressed main-UI relay for sibling broadcast legs when the main agent is a direct target. - Added `suppressRelay` option to `IrcBus.send` to allow selective disabling of relay rendering. - Ensured broadcast fan-outs avoid rendering the same message twice in the main transcript. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/irc/bus.ts | 14 +++++++++++--- packages/coding-agent/src/tools/irc.ts | 8 ++++++-- packages/coding-agent/test/tools/irc.test.ts | 18 ++++++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 56d420353..c502c761a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed garbled casing in auto-generated session titles. `normalizeGeneratedTitle` (`packages/coding-agent/src/tiny/text.ts`) used to force Title Case via a `\b\p{Ll}` regex, capitalizing function words ("for" → "For") and amplifying stray model capitals ("dAemon" → "DAemon"). It now reconciles each title token against the user's own message: tokens typed verbatim are kept; proper nouns the user cased distinctively are restored when the model flattened them ("tinyvmm" → "TinyVMM"); lowercase words carrying a stray interior capital the user never wrote are flattened ("dAemon" → "daemon"); and model-cased PascalCase proper nouns ("GitHub", "OAuth") are left untouched. Restoration is limited to distinctively cased source tokens so a message that merely starts with "For" can't force a mid-title "for" to "For". Applies to both the local tiny-model and online pi/smol title paths. +- Fixed IRC broadcasts (`to: "all"`) rendering twice in the main agent's transcript. A subagent broadcast fans out one `bus.send` per live peer, and `listVisibleTo` always includes `Main`, so the main agent received the body once as its own `irc:incoming` card *and* once per other recipient as an `irc:relay` observation of the sibling legs (`Sender → Other`) — identical text shown N+1 times. `IrcTool.#executeSend` now sets `suppressRelay` on every broadcast leg when `Main` is among the targets (it already has the body via its direct incoming card), and `IrcBus.send` skips `#relayToMainUi` for suppressed legs. Direct sub→sub relays, direct messages to `Main`, and `Main`'s own outbound sends are unaffected. ## [16.1.23] - 2026-06-26 diff --git a/packages/coding-agent/src/irc/bus.ts b/packages/coding-agent/src/irc/bus.ts index a40d59ec5..c5edcc977 100644 --- a/packages/coding-agent/src/irc/bus.ts +++ b/packages/coding-agent/src/irc/bus.ts @@ -91,8 +91,16 @@ export class IrcBus { * disabled — e.g. blocked in a synchronous task spawn awaiting the * sender's own batch) can generate an ephemeral side-channel auto-reply * instead of stranding the sender until timeout. + * + * `opts.suppressRelay` skips the display-only main-UI relay for this leg. + * Set by broadcast fan-out when the same broadcast also targets the main + * agent directly: the main agent then already sees the body as its own + * incoming card, so relaying the sibling legs would duplicate it. */ - async send(msg: Omit, opts?: { expectsReply?: boolean }): Promise { + async send( + msg: Omit, + opts?: { expectsReply?: boolean; suppressRelay?: boolean }, + ): Promise { const message: IrcMessage = { ...msg, id: Snowflake.next(), ts: Date.now() }; const ref = this.#registry.get(message.to); if (!ref || ref.status === "aborted") { @@ -127,7 +135,7 @@ export class IrcBus { const waiter = this.#takeMatchingWaiter(message.to, message.from); if (waiter) { waiter.resolve(message); - this.#relayToMainUi(message); + if (!opts?.suppressRelay) this.#relayToMainUi(message); return { to: message.to, outcome: revived ? "revived" : "injected" }; } @@ -138,7 +146,7 @@ export class IrcBus { try { const delivery = await session.deliverIrcMessage(message, opts); - this.#relayToMainUi(message); + if (!opts?.suppressRelay) this.#relayToMainUi(message); return { to: message.to, outcome: revived ? "revived" : delivery }; } catch (error) { // Live hand-off failed (e.g. recipient disposed mid-shutdown): buffer diff --git a/packages/coding-agent/src/tools/irc.ts b/packages/coding-agent/src/tools/irc.ts index ef2c01fb6..f37c91e58 100644 --- a/packages/coding-agent/src/tools/irc.ts +++ b/packages/coding-agent/src/tools/irc.ts @@ -19,7 +19,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { IrcBus, type IrcDeliveryReceipt, type IrcMessage } from "../irc/bus"; import type { Theme } from "../modes/theme/theme"; import ircDescription from "../prompts/tools/irc.md" with { type: "text" }; -import type { AgentRegistry } from "../registry/agent-registry"; +import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry"; import { canSpawnAtDepth } from "../task/types"; import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import type { ToolSession } from "."; @@ -280,6 +280,10 @@ export class IrcTool implements AgentTool { // parked agent on a broadcast would be a stampede. Direct sends go // through the bus unfiltered so parked recipients are revived. const targets = isBroadcast ? registry.listVisibleTo(senderId).map(ref => ref.id) : [to]; + // A broadcast that also reaches the main agent delivers the body to it + // directly (its own incoming card); relaying the sibling legs to the + // main UI would then show the same body once per other recipient. + const suppressRelay = isBroadcast && targets.includes(MAIN_AGENT_ID); const receipts = await Promise.all( targets.map(target => bus.send( @@ -287,7 +291,7 @@ export class IrcTool implements AgentTool { // Awaited sends mark the sender as blocked on an answer so a // busy recipient that cannot reach a step boundary (async // disabled) auto-replies instead of stranding the sender. - params.await ? { expectsReply: true } : undefined, + { expectsReply: params.await || undefined, suppressRelay: suppressRelay || undefined }, ), ), ); diff --git a/packages/coding-agent/test/tools/irc.test.ts b/packages/coding-agent/test/tools/irc.test.ts index b246f92a0..47c35239e 100644 --- a/packages/coding-agent/test/tools/irc.test.ts +++ b/packages/coding-agent/test/tools/irc.test.ts @@ -482,6 +482,24 @@ describe("IRC", () => { expect(a.delivered.map(msg => msg.body)).toEqual(["anyone there?"]); }); + it("op=send to=all does not relay sibling legs when the broadcast also reaches main", async () => { + const main = makeFakeSession(); + registry.register({ id: "Main", displayName: "main", kind: "main", session: main.session }); + const b = makeFakeSession(); + registry.register({ id: "0-B", displayName: "task", kind: "sub", session: b.session }); + registry.register({ id: "0-A", displayName: "task", kind: "sub", session: makeFakeSession().session }); + + const tool = new IrcTool(makeToolSession(registry, "0-A")); + await tool.execute("call-1", { op: "send", to: "all", message: "anyone there?" }); + + // Main receives the broadcast directly (its own incoming card) ... + expect(main.delivered.map(msg => msg.body)).toEqual(["anyone there?"]); + // ... so the 0-A → 0-B sibling leg must NOT also be relayed to main: it + // would render the identical body a second time. + expect(main.relayed).toEqual([]); + expect(b.delivered.map(msg => msg.body)).toEqual(["anyone there?"]); + }); + it("op=send await=true round-trips the recipient's reply", async () => { const main = makeFakeSession(); registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });