Skip to content

Commit b2fac9d

Browse files
ragesaqForgeChisel
authored andcommitted
fix(gateway): mirror commentary-phase assistant events to session message subscribers
Non-control-UI-visible runs previously dropped assistant commentary on the floor for session message subscribers. Mirror those events to exact session subscribers, gated strictly on phase === "commentary" so untagged text or delta frames and final-answer streaming never dual-lane into channel surfaces. Dialects that emit commentary as untagged deltas should tag the phase at provider normalization instead. Co-authored-by: Forge <[email protected]> Co-authored-by: Chisel <[email protected]>
1 parent 6cf06e8 commit b2fac9d

2 files changed

Lines changed: 180 additions & 0 deletions

File tree

src/gateway/server-chat.agent-events.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3022,6 +3022,160 @@ describe("agent event handler", () => {
30223022
);
30233023
});
30243024

3025+
it("mirrors commentary-phase assistant events only to exact session message subscribers", () => {
3026+
const {
3027+
broadcast,
3028+
broadcastToConnIds,
3029+
nodeSendToSession,
3030+
sessionMessageSubscribers,
3031+
handler,
3032+
nowSpy,
3033+
} = createHarness({
3034+
now: 1_000,
3035+
resolveSessionKeyForRun: () => "session-hidden",
3036+
});
3037+
sessionMessageSubscribers.subscribe("conn-selected", "session-hidden");
3038+
sessionMessageSubscribers.subscribe("conn-other", "session-other");
3039+
registerAgentRunContext("run-hidden-commentary", {
3040+
sessionKey: "session-hidden",
3041+
isControlUiVisible: false,
3042+
verboseLevel: "off",
3043+
});
3044+
3045+
handler({
3046+
runId: "run-hidden-commentary",
3047+
seq: 1,
3048+
stream: "assistant",
3049+
ts: Date.now(),
3050+
data: {
3051+
text: "I will inspect the files first.",
3052+
delta: "I will inspect the files first.",
3053+
phase: "commentary",
3054+
},
3055+
});
3056+
handler({
3057+
runId: "run-hidden-commentary",
3058+
seq: 2,
3059+
stream: "assistant",
3060+
ts: Date.now(),
3061+
data: {
3062+
text: "Untagged text frame must not mirror.",
3063+
delta: "Untagged text frame must not mirror.",
3064+
},
3065+
});
3066+
handler({
3067+
runId: "run-hidden-commentary",
3068+
seq: 3,
3069+
stream: "assistant",
3070+
ts: Date.now(),
3071+
data: {
3072+
delta: "Untagged delta-only stream must not mirror.",
3073+
},
3074+
});
3075+
handler({
3076+
runId: "run-hidden-commentary",
3077+
seq: 4,
3078+
stream: "assistant",
3079+
ts: Date.now(),
3080+
data: { text: "Terminal echo without delta" },
3081+
});
3082+
handler({
3083+
runId: "run-hidden-commentary",
3084+
seq: 5,
3085+
stream: "assistant",
3086+
ts: Date.now(),
3087+
data: { text: "Final answer", delta: "Final answer", phase: "final_answer" },
3088+
});
3089+
handler({
3090+
runId: "run-hidden-commentary",
3091+
seq: 6,
3092+
stream: "assistant",
3093+
ts: Date.now(),
3094+
data: {
3095+
delta: "Streaming commentary delta.",
3096+
phase: "commentary",
3097+
},
3098+
});
3099+
3100+
expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
3101+
expect(agentBroadcastCalls(broadcast)).toHaveLength(0);
3102+
expect(nodeSendToSession).not.toHaveBeenCalled();
3103+
3104+
const agentCalls = broadcastToConnIds.mock.calls.filter(([event]) => event === "agent");
3105+
expect(agentCalls).toHaveLength(2);
3106+
expect(agentCalls[0]?.[2]).toEqual(new Set(["conn-selected"]));
3107+
expect(agentCalls[1]?.[2]).toEqual(new Set(["conn-selected"]));
3108+
expectPayloadFields(agentCalls[0]?.[1], {
3109+
runId: "run-hidden-commentary",
3110+
sessionKey: "session-hidden",
3111+
stream: "assistant",
3112+
});
3113+
expectPayloadFields(agentCalls[1]?.[1], {
3114+
runId: "run-hidden-commentary",
3115+
sessionKey: "session-hidden",
3116+
stream: "assistant",
3117+
});
3118+
expectPayloadDataFields(agentCalls[0]?.[1], {
3119+
text: "I will inspect the files first.",
3120+
delta: "I will inspect the files first.",
3121+
phase: "commentary",
3122+
});
3123+
expectPayloadDataFields(agentCalls[1]?.[1], {
3124+
delta: "Streaming commentary delta.",
3125+
phase: "commentary",
3126+
});
3127+
3128+
const chatCalls = broadcastToConnIds.mock.calls.filter(([event]) => event === "chat");
3129+
expect(chatCalls).toHaveLength(1);
3130+
expect(chatCalls[0]?.[2]).toEqual(new Set(["conn-selected"]));
3131+
expectPayloadFields(chatCalls[0]?.[1], {
3132+
runId: "run-hidden-commentary",
3133+
sessionKey: "session-hidden",
3134+
state: "delta",
3135+
});
3136+
nowSpy?.mockRestore();
3137+
});
3138+
3139+
it("does not mirror aborted non-control-UI-visible assistant commentary", () => {
3140+
const {
3141+
broadcast,
3142+
broadcastToConnIds,
3143+
chatRunState,
3144+
nodeSendToSession,
3145+
sessionMessageSubscribers,
3146+
handler,
3147+
nowSpy,
3148+
} = createHarness({
3149+
now: 1_000,
3150+
resolveSessionKeyForRun: () => "session-hidden-aborted",
3151+
});
3152+
sessionMessageSubscribers.subscribe("conn-selected", "session-hidden-aborted");
3153+
registerAgentRunContext("run-hidden-commentary-aborted", {
3154+
sessionKey: "session-hidden-aborted",
3155+
isControlUiVisible: false,
3156+
verboseLevel: "off",
3157+
});
3158+
chatRunState.abortedRuns.set("run-hidden-commentary-aborted", 1_000);
3159+
3160+
handler({
3161+
runId: "run-hidden-commentary-aborted",
3162+
seq: 1,
3163+
stream: "assistant",
3164+
ts: Date.now(),
3165+
data: {
3166+
text: "This aborted commentary must not be mirrored.",
3167+
delta: "This aborted commentary must not be mirrored.",
3168+
phase: "commentary",
3169+
},
3170+
});
3171+
3172+
expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
3173+
expect(agentBroadcastCalls(broadcast)).toHaveLength(0);
3174+
expect(broadcastToConnIds).not.toHaveBeenCalled();
3175+
expect(nodeSendToSession).not.toHaveBeenCalled();
3176+
nowSpy?.mockRestore();
3177+
});
3178+
30253179
it("uses agent event sessionKey when run-context lookup cannot resolve", () => {
30263180
const { broadcast, handler } = createHarness({
30273181
resolveSessionKeyForRun: () => undefined,

src/gateway/server-chat.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-event
1010
import { detectErrorKind, type ErrorKind } from "../infra/errors.js";
1111
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
1212
import { isAcpSessionKey, isSubagentSessionKey } from "../sessions/session-key-utils.js";
13+
import { resolveAssistantEventPhase } from "../shared/chat-message-content.js";
1314
import { setSafeTimeout } from "../utils/timer-delay.js";
1415
import {
1516
normalizeLiveAssistantEventText,
@@ -134,6 +135,19 @@ function shouldSuppressHeartbeatToolEvents(runId: string, sourceRunId?: string):
134135
return Boolean(resolveHeartbeatContext(runId, sourceRunId)?.isHeartbeat);
135136
}
136137

138+
function shouldMirrorAssistantEventToHiddenSessionMessages(data: unknown): boolean {
139+
if (!data || typeof data !== "object") {
140+
return false;
141+
}
142+
const record = data as { text?: unknown; delta?: unknown };
143+
const hasText = typeof record.text === "string" && record.text.length > 0;
144+
const hasDelta = typeof record.delta === "string" && record.delta.length > 0;
145+
if (!hasText && !hasDelta) {
146+
return false;
147+
}
148+
return resolveAssistantEventPhase(data) === "commentary";
149+
}
150+
137151
function normalizeHeartbeatChatFinalText(params: {
138152
runId: string;
139153
sourceRunId?: string;
@@ -1187,6 +1201,18 @@ export function createAgentEventHandler({
11871201
);
11881202
}
11891203
}
1204+
} else if (
1205+
!isAborted &&
1206+
sessionKey &&
1207+
hasSessionMessageSubscribers &&
1208+
evt.stream === "assistant" &&
1209+
shouldMirrorAssistantEventToHiddenSessionMessages(evt.data)
1210+
) {
1211+
sendAgentPayload(
1212+
sessionKey,
1213+
{ ...agentPayload, ...buildSessionEventSnapshot(sessionKey, undefined, sessionAgentId) },
1214+
{ agentId: sessionAgentId, controlUiVisible: false, dropIfSlow: true },
1215+
);
11901216
}
11911217
}
11921218

0 commit comments

Comments
 (0)