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.
This commit is contained in:
can1357
2026-06-26 22:25:38 +02:00
parent cd835648ec
commit 132f28dd11
4 changed files with 36 additions and 5 deletions
+1
View File
@@ -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
+11 -3
View File
@@ -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<IrcMessage, "id" | "ts">, opts?: { expectsReply?: boolean }): Promise<IrcDeliveryReceipt> {
async send(
msg: Omit<IrcMessage, "id" | "ts">,
opts?: { expectsReply?: boolean; suppressRelay?: boolean },
): Promise<IrcDeliveryReceipt> {
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
+6 -2
View File
@@ -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<typeof ircSchema, IrcDetails> {
// 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<typeof ircSchema, IrcDetails> {
// 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 },
),
),
);
@@ -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 });