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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user