From 777af476cbb42b045d6547d83cf28b645556a93a Mon Sep 17 00:00:00 2001 From: Octane Date: Fri, 6 Mar 2026 14:14:00 +0800 Subject: [PATCH] Respect source channel for agent event surfacing (#36030) --- .../reply/agent-runner-execution.ts | 7 ++++++ src/auto-reply/reply/followup-runner.ts | 8 +++++++ src/gateway/server-chat.agent-events.test.ts | 23 +++++++++++++++++++ src/gateway/server-chat.ts | 3 ++- src/infra/agent-events.test.ts | 22 ++++++++++++++++++ src/infra/agent-events.ts | 13 +++++++---- 6 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index ed7fadcd8f9..ed843a73014 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -26,6 +26,7 @@ import { isMarkdownCapableMessageChannel, resolveMessageChannel, } from "../../utils/message-channel.js"; +import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import type { TemplateContext } from "../templating.js"; import type { VerboseLevel } from "../thinking.js"; @@ -113,11 +114,17 @@ export async function runAgentTurnWithFallback(params: { didNotifyAgentRunStart = true; params.opts?.onAgentRunStart?.(runId); }; + const shouldSurfaceToControlUi = isInternalMessageChannel( + params.followupRun.run.messageProvider ?? + params.sessionCtx.Surface ?? + params.sessionCtx.Provider, + ); if (params.sessionKey) { registerAgentRunContext(runId, { sessionKey: params.sessionKey, verboseLevel: params.resolvedVerboseLevel, isHeartbeat: params.isHeartbeat, + isControlUiVisible: shouldSurfaceToControlUi, }); } let runResult: Awaited>; diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 8c8bd71b4c6..7838a83bc4d 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -10,6 +10,7 @@ import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; +import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import type { OriginatingChannelType } from "../templating.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; @@ -131,10 +132,17 @@ export function createFollowupRunner(params: { return async (queued: FollowupRun) => { try { const runId = crypto.randomUUID(); + const shouldSurfaceToControlUi = isInternalMessageChannel( + resolveOriginMessageProvider({ + originatingChannel: queued.originatingChannel, + provider: queued.run.messageProvider, + }), + ); if (queued.run.sessionKey) { registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey, verboseLevel: queued.run.verboseLevel, + isControlUiVisible: shouldSurfaceToControlUi, }); } let autoCompactionCompleted = false; diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index fd394834906..b89e2462c51 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -612,6 +612,29 @@ describe("agent event handler", () => { expect(nodePayload.runId).toBe("run-fallback-client"); }); + it("suppresses chat and node session events for non-control-UI-visible runs", () => { + const { broadcast, nodeSendToSession, handler } = createHarness({ + resolveSessionKeyForRun: () => "session-hidden", + }); + registerAgentRunContext("run-hidden", { + sessionKey: "session-hidden", + isControlUiVisible: false, + verboseLevel: "off", + }); + + handler({ + runId: "run-hidden", + seq: 1, + stream: "assistant", + ts: Date.now(), + data: { text: "Reply from imessage" }, + }); + emitLifecycleEnd(handler, "run-hidden", 2); + + expect(chatBroadcastCalls(broadcast)).toHaveLength(0); + expect(nodeSendToSession).not.toHaveBeenCalled(); + }); + it("uses agent event sessionKey when run-context lookup cannot resolve", () => { const { broadcast, handler } = createHarness({ resolveSessionKeyForRun: () => undefined, diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 73f90e8b037..5ce6e8471f5 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -502,6 +502,7 @@ export function createAgentEventHandler({ const chatLink = chatRunState.registry.peek(evt.runId); const eventSessionKey = typeof evt.sessionKey === "string" && evt.sessionKey.trim() ? evt.sessionKey : undefined; + const isControlUiVisible = getAgentRunContext(evt.runId)?.isControlUiVisible ?? true; const sessionKey = chatLink?.sessionKey ?? eventSessionKey ?? resolveSessionKeyForRun(evt.runId); const clientRunId = chatLink?.clientRunId ?? evt.runId; @@ -556,7 +557,7 @@ export function createAgentEventHandler({ const lifecyclePhase = evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null; - if (sessionKey) { + if (isControlUiVisible && sessionKey) { // Send tool events to node/channel subscribers only when verbose is enabled; // WS clients already received the event above via broadcastToConnIds. if (!isToolEvent || toolVerbose !== "off") { diff --git a/src/infra/agent-events.test.ts b/src/infra/agent-events.test.ts index f86425894f9..9661ee13bfc 100644 --- a/src/infra/agent-events.test.ts +++ b/src/infra/agent-events.test.ts @@ -61,4 +61,26 @@ describe("agent-events sequencing", () => { expect(phases).toEqual(["start", "end"]); }); + + test("omits sessionKey for runs hidden from Control UI", async () => { + resetAgentRunContextForTest(); + registerAgentRunContext("run-hidden", { + sessionKey: "session-imessage", + isControlUiVisible: false, + }); + + let receivedSessionKey: string | undefined; + const stop = onAgentEvent((evt) => { + receivedSessionKey = evt.sessionKey; + }); + emitAgentEvent({ + runId: "run-hidden", + stream: "assistant", + data: { text: "hi" }, + sessionKey: "session-imessage", + }); + stop(); + + expect(receivedSessionKey).toBeUndefined(); + }); }); diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index 23557cdda61..3b2e219574b 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -15,6 +15,8 @@ export type AgentRunContext = { sessionKey?: string; verboseLevel?: VerboseLevel; isHeartbeat?: boolean; + /** Whether control UI clients should receive chat/agent updates for this run. */ + isControlUiVisible?: boolean; }; // Keep per-run counters so streams stay strictly monotonic per runId. @@ -37,6 +39,9 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext) if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) { existing.verboseLevel = context.verboseLevel; } + if (context.isControlUiVisible !== undefined) { + existing.isControlUiVisible = context.isControlUiVisible; + } if (context.isHeartbeat !== undefined && existing.isHeartbeat !== context.isHeartbeat) { existing.isHeartbeat = context.isHeartbeat; } @@ -58,10 +63,10 @@ export function emitAgentEvent(event: Omit) { const nextSeq = (seqByRun.get(event.runId) ?? 0) + 1; seqByRun.set(event.runId, nextSeq); const context = runContextById.get(event.runId); - const sessionKey = - typeof event.sessionKey === "string" && event.sessionKey.trim() - ? event.sessionKey - : context?.sessionKey; + const isControlUiVisible = context?.isControlUiVisible ?? true; + const eventSessionKey = + typeof event.sessionKey === "string" && event.sessionKey.trim() ? event.sessionKey : undefined; + const sessionKey = isControlUiVisible ? (eventSessionKey ?? context?.sessionKey) : undefined; const enriched: AgentEventPayload = { ...event, sessionKey,