diff --git a/plugins/orchestrator/dist/server.js b/plugins/orchestrator/dist/server.js index 6b6141f..ac31459 100644 --- a/plugins/orchestrator/dist/server.js +++ b/plugins/orchestrator/dist/server.js @@ -24309,13 +24309,18 @@ class AgentChannel { } function filterParagraphsForReceiver(content, receiverId, sender, sessions) { const paragraphs = content.split(/\n{2,}/); + const paraAddrs = paragraphs.map((para) => parseAddressing(para, sender, sessions)); + const targetSets = paraAddrs.filter((a) => a.targets.length > 0).map((a) => [...a.targets].sort().join(",")); + const allAddressedParagraphsShareOneTargetSet = targetSets.length > 0 && targetSets.every((s) => s === targetSets[0]); + if (allAddressedParagraphsShareOneTargetSet) { + return paraAddrs.some((a) => a.targets.includes(receiverId)) ? content : null; + } const kept = []; - for (const para of paragraphs) { - const paraAddr = parseAddressing(para, sender, sessions); - if (paraAddr.targets.includes(receiverId)) { + paragraphs.forEach((para, i) => { + if (paraAddrs[i].targets.includes(receiverId)) { kept.push(para); } - } + }); return kept.length > 0 ? kept.join(` `) : null; diff --git a/plugins/orchestrator/mcp/engine/agent_channel.ts b/plugins/orchestrator/mcp/engine/agent_channel.ts index 6e0de9c..a5f8873 100644 --- a/plugins/orchestrator/mcp/engine/agent_channel.ts +++ b/plugins/orchestrator/mcp/engine/agent_channel.ts @@ -604,6 +604,17 @@ export class AgentChannel { * Used by SA receivers to extract only the parts of a sender's message * that were addressed to them - prevents PA-to-user prose in a mixed message * from leaking to an SA that was named in a different paragraph. + * + * Single-recipient-set heuristic: when ALL addressed paragraphs in a message + * route to the same target set, the whole content is emitted (no paragraph + * filtering). This fixes the common-case truncation where a sender writes + * a directive with structural continuation paragraphs (bullets, lists, + * follow-up prose, closing sentences) that have no @-address of their own - + * under strict per-paragraph filtering those paragraphs drop, leaving the + * receiver with just the intro and no scope. Per-paragraph filtering is + * still applied to genuinely mixed-audience messages (multiple @-addresses + * with DIFFERENT target sets) so private user-prose paragraphs interleaved + * between SA-directed paragraphs continue to not leak. */ function filterParagraphsForReceiver( content: string, @@ -612,12 +623,33 @@ function filterParagraphsForReceiver( sessions: SessionEntry[], ): string | null { const paragraphs = content.split(/\n{2,}/); + const paraAddrs = paragraphs.map((para) => + parseAddressing(para, sender, sessions), + ); + + // Collect target-set signatures of the addressed paragraphs only. + const targetSets = paraAddrs + .filter((a) => a.targets.length > 0) + .map((a) => [...a.targets].sort().join(",")); + const allAddressedParagraphsShareOneTargetSet = + targetSets.length > 0 && targetSets.every((s) => s === targetSets[0]); + + if (allAddressedParagraphsShareOneTargetSet) { + // Single-recipient-set message: deliver the full content (including + // unaddressed structural continuation paragraphs) to the target set. + return paraAddrs.some((a) => a.targets.includes(receiverId)) + ? content + : null; + } + + // Mixed-audience fallback: per-paragraph filtering (the original safety + // property from work_item b4c37849 — paragraphs addressed to a different + // recipient must not leak to this receiver). const kept: string[] = []; - for (const para of paragraphs) { - const paraAddr = parseAddressing(para, sender, sessions); - if (paraAddr.targets.includes(receiverId)) { + paragraphs.forEach((para, i) => { + if (paraAddrs[i].targets.includes(receiverId)) { kept.push(para); } - } + }); return kept.length > 0 ? kept.join("\n\n") : null; } diff --git a/plugins/orchestrator/tests/integration/agent_channel_routing.test.ts b/plugins/orchestrator/tests/integration/agent_channel_routing.test.ts index 329796a..fbbd866 100644 --- a/plugins/orchestrator/tests/integration/agent_channel_routing.test.ts +++ b/plugins/orchestrator/tests/integration/agent_channel_routing.test.ts @@ -323,4 +323,69 @@ describe("agent-channel routing E2E", () => { ), ).toBe(false); }); + + // Single-recipient-set heuristic: when ALL @-addressed paragraphs in a + // message route to the same target set, the WHOLE content is delivered + // (including unaddressed structural continuation paragraphs like bullet + // lists, follow-up sentences, closing prose). This fixes the common-case + // truncation where a sender writes a directive with structure that has + // no @-address of its own — under strict per-paragraph filtering those + // paragraphs drop, leaving the receiver with just the intro paragraph + // and no scope. + test("single-recipient message with unaddressed continuation paragraphs delivers in full", async () => { + const pa = makeSession("prime", "f5b8708d", "PA"); + const sourceSa = makeSession("subordinate", "50ce0bba", "SA-source"); + const target = makeSession("subordinate", "abc12345", "SA-target"); + const other = makeSession("subordinate", "deadbe11", "SA-other"); + writeSession(stateDir, pa); + writeSession(stateDir, sourceSa); + writeSession(stateDir, target); + writeSession(stateDir, other); + + const sourceJsonl = join(projectsHashDir, `${sourceSa.session_id}.jsonl`); + writeFileSync(sourceJsonl, ""); + + // A typical PA dispatch: opening directive with @-address, then + // bulleted scope, then closing prose. Only the opening paragraph + // contains an explicit @SA- address. + const dispatch = [ + "@SA-abc12345 dispatch reviewer on PR #72. Fix scope to surface in the brief:", + "- Patch 3 regex must match current upstream shape", + "- Multi-patch non-transactionality requires read-then-validate-all-then-apply", + "- `--help` sed range must terminate at header end (line 62)", + "Same branch, no new ADR, re-review through reviewer chain when meta-coder ships.", + ].join("\n\n"); + appendAssistantEvent(sourceJsonl, dispatch); + + const targetReceived: ChannelNotification[] = []; + const otherReceived: ChannelNotification[] = []; + const targetChan = new AgentChannel(stateDir, projectsHashDir, target, (n) => + targetReceived.push(n), + ); + const otherChan = new AgentChannel(stateDir, projectsHashDir, other, (n) => + otherReceived.push(n), + ); + targetChan.start(); + otherChan.start(); + await new Promise((r) => setTimeout(r, 50)); + targetChan.stop(); + otherChan.stop(); + + // SA-target receives the FULL content (intro + bullets + closing). + const targetContents = targetReceived + .filter((n) => n.meta.event_type === "assistant_text") + .map((n) => n.content); + expect(targetContents.length).toBe(1); + const got = targetContents[0]; + expect(got).toContain("dispatch reviewer on PR #72"); + expect(got).toContain("Patch 3 regex must match"); + expect(got).toContain("Multi-patch non-transactionality"); + expect(got).toContain("`--help` sed range"); + expect(got).toContain("Same branch, no new ADR"); + + // SA-other (not addressed) receives nothing. + expect( + otherReceived.some((n) => n.meta.event_type === "assistant_text"), + ).toBe(false); + }); });