fix(webchat): suppress heartbeat chat broadcast using agent runId
- Avoid per-event config loads by caching webchat heartbeat visibility\n- Use agent runId (not client runId) to read isHeartbeat context\n- Add regression test for clientRunId mismatch
This commit is contained in:
parent
822663001a
commit
cec996c812
60
src/gateway/server-chat.heartbeat-visibility.test.ts
Normal file
60
src/gateway/server-chat.heartbeat-visibility.test.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", () => ({
|
||||||
|
loadConfig: vi.fn(() => ({
|
||||||
|
channels: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
showOk: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../infra/heartbeat-visibility.js", () => ({
|
||||||
|
resolveHeartbeatVisibility: vi.fn(() => ({
|
||||||
|
showOk: false,
|
||||||
|
showAlerts: true,
|
||||||
|
useIndicator: true,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("server-chat heartbeat visibility", () => {
|
||||||
|
it("suppresses webchat chat broadcast for heartbeat runs (even when clientRunId differs)", async () => {
|
||||||
|
const { createAgentEventHandler, createChatRunState } = await import("./server-chat.js");
|
||||||
|
const { registerAgentRunContext } = await import("../infra/agent-events.js");
|
||||||
|
|
||||||
|
const broadcast = vi.fn();
|
||||||
|
const nodeSendToSession = vi.fn();
|
||||||
|
const agentRunSeq = new Map<string, number>();
|
||||||
|
const chatRunState = createChatRunState();
|
||||||
|
|
||||||
|
// runId is the agent run id; clientRunId is what webchat uses in payloads.
|
||||||
|
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
|
||||||
|
registerAgentRunContext("run-1", { isHeartbeat: true });
|
||||||
|
|
||||||
|
const handler = createAgentEventHandler({
|
||||||
|
broadcast,
|
||||||
|
nodeSendToSession,
|
||||||
|
agentRunSeq,
|
||||||
|
chatRunState,
|
||||||
|
resolveSessionKeyForRun: () => undefined,
|
||||||
|
clearAgentRunContext: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
handler({
|
||||||
|
runId: "run-1",
|
||||||
|
seq: 1,
|
||||||
|
stream: "assistant",
|
||||||
|
ts: Date.now(),
|
||||||
|
data: { text: "HEARTBEAT_OK" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat");
|
||||||
|
expect(chatCalls).toHaveLength(0);
|
||||||
|
|
||||||
|
const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat");
|
||||||
|
expect(sessionChatCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -5,21 +5,13 @@ import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
|
|||||||
import { loadSessionEntry } from "./session-utils.js";
|
import { loadSessionEntry } from "./session-utils.js";
|
||||||
import { formatForLog } from "./ws-log.js";
|
import { formatForLog } from "./ws-log.js";
|
||||||
|
|
||||||
/**
|
function resolveWebchatHeartbeatShowOk(): boolean {
|
||||||
* Check if webchat broadcasts should be suppressed for heartbeat runs.
|
|
||||||
* Returns true if the run is a heartbeat and showOk is false.
|
|
||||||
*/
|
|
||||||
function shouldSuppressHeartbeatBroadcast(runId: string): boolean {
|
|
||||||
const runContext = getAgentRunContext(runId);
|
|
||||||
if (!runContext?.isHeartbeat) return false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
|
return resolveHeartbeatVisibility({ cfg, channel: "webchat" }).showOk;
|
||||||
return !visibility.showOk;
|
|
||||||
} catch {
|
} catch {
|
||||||
// Default to suppressing if we can't load config
|
// Default: hide HEARTBEAT_OK noise from webchat
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,7 +125,21 @@ export function createAgentEventHandler({
|
|||||||
resolveSessionKeyForRun,
|
resolveSessionKeyForRun,
|
||||||
clearAgentRunContext,
|
clearAgentRunContext,
|
||||||
}: AgentEventHandlerOptions) {
|
}: AgentEventHandlerOptions) {
|
||||||
const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => {
|
const webchatHeartbeatShowOk = resolveWebchatHeartbeatShowOk();
|
||||||
|
|
||||||
|
const shouldSuppressHeartbeatBroadcast = (agentRunId: string): boolean => {
|
||||||
|
const runContext = getAgentRunContext(agentRunId);
|
||||||
|
if (!runContext?.isHeartbeat) return false;
|
||||||
|
return !webchatHeartbeatShowOk;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitChatDelta = (
|
||||||
|
sessionKey: string,
|
||||||
|
clientRunId: string,
|
||||||
|
seq: number,
|
||||||
|
text: string,
|
||||||
|
agentRunId: string,
|
||||||
|
) => {
|
||||||
chatRunState.buffers.set(clientRunId, text);
|
chatRunState.buffers.set(clientRunId, text);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0;
|
const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0;
|
||||||
@ -151,7 +157,7 @@ export function createAgentEventHandler({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
// Suppress webchat broadcast for heartbeat runs when showOk is false
|
// Suppress webchat broadcast for heartbeat runs when showOk is false
|
||||||
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
|
if (!shouldSuppressHeartbeatBroadcast(agentRunId)) {
|
||||||
broadcast("chat", payload, { dropIfSlow: true });
|
broadcast("chat", payload, { dropIfSlow: true });
|
||||||
}
|
}
|
||||||
nodeSendToSession(sessionKey, "chat", payload);
|
nodeSendToSession(sessionKey, "chat", payload);
|
||||||
@ -162,7 +168,8 @@ export function createAgentEventHandler({
|
|||||||
clientRunId: string,
|
clientRunId: string,
|
||||||
seq: number,
|
seq: number,
|
||||||
jobState: "done" | "error",
|
jobState: "done" | "error",
|
||||||
error?: unknown,
|
error: unknown | undefined,
|
||||||
|
agentRunId: string,
|
||||||
) => {
|
) => {
|
||||||
const text = chatRunState.buffers.get(clientRunId)?.trim() ?? "";
|
const text = chatRunState.buffers.get(clientRunId)?.trim() ?? "";
|
||||||
chatRunState.buffers.delete(clientRunId);
|
chatRunState.buffers.delete(clientRunId);
|
||||||
@ -182,7 +189,7 @@ export function createAgentEventHandler({
|
|||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
// Suppress webchat broadcast for heartbeat runs when showOk is false
|
// Suppress webchat broadcast for heartbeat runs when showOk is false
|
||||||
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
|
if (!shouldSuppressHeartbeatBroadcast(agentRunId)) {
|
||||||
broadcast("chat", payload);
|
broadcast("chat", payload);
|
||||||
}
|
}
|
||||||
nodeSendToSession(sessionKey, "chat", payload);
|
nodeSendToSession(sessionKey, "chat", payload);
|
||||||
@ -250,7 +257,7 @@ export function createAgentEventHandler({
|
|||||||
if (sessionKey) {
|
if (sessionKey) {
|
||||||
nodeSendToSession(sessionKey, "agent", agentPayload);
|
nodeSendToSession(sessionKey, "agent", agentPayload);
|
||||||
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
||||||
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
|
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text, evt.runId);
|
||||||
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
||||||
if (chatLink) {
|
if (chatLink) {
|
||||||
const finished = chatRunState.registry.shift(evt.runId);
|
const finished = chatRunState.registry.shift(evt.runId);
|
||||||
@ -264,6 +271,7 @@ export function createAgentEventHandler({
|
|||||||
evt.seq,
|
evt.seq,
|
||||||
lifecyclePhase === "error" ? "error" : "done",
|
lifecyclePhase === "error" ? "error" : "done",
|
||||||
evt.data?.error,
|
evt.data?.error,
|
||||||
|
evt.runId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
emitChatFinal(
|
emitChatFinal(
|
||||||
@ -272,6 +280,7 @@ export function createAgentEventHandler({
|
|||||||
evt.seq,
|
evt.seq,
|
||||||
lifecyclePhase === "error" ? "error" : "done",
|
lifecyclePhase === "error" ? "error" : "done",
|
||||||
evt.data?.error,
|
evt.data?.error,
|
||||||
|
evt.runId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
} else if (isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user