Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions plugins/orchestrator/dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
40 changes: 36 additions & 4 deletions plugins/orchestrator/mcp/engine/agent_channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id8> 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);
});
});