diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 30ada288bd76..a90226f67c7e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -659,6 +659,30 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect.each([ + { label: "live", archived: false }, + { label: "archived", archived: true }, + ])("never publishes tombstones for $label subagent threads", ({ archived }) => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* Ref.set( + currentShell, + shell({ + lineage: { + rootThreadId: THREAD_ID, + parentThreadId: THREAD_ID, + relationshipToParent: "subagent", + }, + ...(archived ? { archivedAt: yield* DateTime.now } : {}), + }), + ); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.length, 0); + }), + ); + it.effect("confirms a first completed state and respects disabling during confirmation", () => Effect.gen(function* () { const { relay, secrets, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 71e74d11b913..bc0e3d233119 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -522,6 +522,16 @@ export const make = Effect.gen(function* () { // domain event, so materializing the full shell here would make the cost // of one thread's activity proportional to how many threads exist. const threadShell = yield* threads.getThreadShell(threadId); + if ( + threadShell?.lineage.relationshipToParent === "subagent" && + !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) + ) { + // Subagents never project activity, so the relay holds no row to clear. + // Their events would otherwise publish a tombstone each, and every + // publish re-delivers the user's aggregate. Checked before the archive + // filter so archiving one stays quiet too. + return; + } const thread = threadShell === null || threadShell.archivedAt !== null ? Option.none()