Gateway/TUI: filter heartbeat ACK noise in chat events
This commit is contained in:
parent
1ded4c672a
commit
2227840989
@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/Pairing: default `pairing list` and `pairing approve` to the sole available pairing channel when omitted, so TUI-only setups can recover from `pairing required` without guessing channel arguments. (#21527) Thanks @losts1.
|
||||
- TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux.
|
||||
- TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer.
|
||||
- TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when `showOk` is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton.
|
||||
- Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr.
|
||||
- Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii.
|
||||
- Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek.
|
||||
|
||||
@ -1,12 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js";
|
||||
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
|
||||
import {
|
||||
createAgentEventHandler,
|
||||
createChatRunState,
|
||||
createToolEventRecipientRegistry,
|
||||
} from "./server-chat.js";
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/heartbeat-visibility.js", () => ({
|
||||
resolveHeartbeatVisibility: vi.fn(() => ({
|
||||
showOk: false,
|
||||
showAlerts: true,
|
||||
useIndicator: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("agent event handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(loadConfig).mockReturnValue({});
|
||||
vi.mocked(resolveHeartbeatVisibility).mockReturnValue({
|
||||
showOk: false,
|
||||
showAlerts: true,
|
||||
useIndicator: true,
|
||||
});
|
||||
resetAgentRunContextForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetAgentRunContextForTest();
|
||||
});
|
||||
|
||||
function createHarness(params?: {
|
||||
now?: number;
|
||||
resolveSessionKeyForRun?: (runId: string) => string | undefined;
|
||||
@ -393,4 +421,93 @@ describe("agent event handler", () => {
|
||||
expect(payload.runId).toBe("run-tool-client");
|
||||
resetAgentRunContextForTest();
|
||||
});
|
||||
|
||||
it("suppresses heartbeat ack-like chat output when showOk is false", () => {
|
||||
const { broadcast, nodeSendToSession, chatRunState, handler } = createHarness({
|
||||
now: 2_000,
|
||||
});
|
||||
chatRunState.registry.add("run-heartbeat", {
|
||||
sessionKey: "session-heartbeat",
|
||||
clientRunId: "client-heartbeat",
|
||||
});
|
||||
registerAgentRunContext("run-heartbeat", {
|
||||
sessionKey: "session-heartbeat",
|
||||
isHeartbeat: true,
|
||||
verboseLevel: "off",
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-heartbeat",
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
text: "HEARTBEAT_OK Read HEARTBEAT.md if it exists (workspace context). Follow it strictly.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0);
|
||||
|
||||
handler({
|
||||
runId: "run-heartbeat",
|
||||
seq: 2,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
const chatCalls = chatBroadcastCalls(broadcast);
|
||||
expect(chatCalls).toHaveLength(1);
|
||||
const finalPayload = chatCalls[0]?.[1] as { state?: string; message?: unknown };
|
||||
expect(finalPayload.state).toBe("final");
|
||||
expect(finalPayload.message).toBeUndefined();
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps heartbeat alert text in final chat output when remainder exceeds ackMaxChars", () => {
|
||||
vi.mocked(loadConfig).mockReturnValue({
|
||||
agents: { defaults: { heartbeat: { ackMaxChars: 10 } } },
|
||||
});
|
||||
|
||||
const { broadcast, chatRunState, handler } = createHarness({ now: 3_000 });
|
||||
chatRunState.registry.add("run-heartbeat-alert", {
|
||||
sessionKey: "session-heartbeat-alert",
|
||||
clientRunId: "client-heartbeat-alert",
|
||||
});
|
||||
registerAgentRunContext("run-heartbeat-alert", {
|
||||
sessionKey: "session-heartbeat-alert",
|
||||
isHeartbeat: true,
|
||||
verboseLevel: "off",
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-heartbeat-alert",
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
text: "HEARTBEAT_OK Disk usage crossed 95 percent on /data and needs cleanup now.",
|
||||
},
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-heartbeat-alert",
|
||||
seq: 2,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
const chatCalls = chatBroadcastCalls(broadcast);
|
||||
expect(chatCalls).toHaveLength(1);
|
||||
const payload = chatCalls[0]?.[1] as {
|
||||
state?: string;
|
||||
message?: { content?: Array<{ text?: string }> };
|
||||
};
|
||||
expect(payload.state).toBe("final");
|
||||
expect(payload.message?.content?.[0]?.text).toBe(
|
||||
"Disk usage crossed 95 percent on /data and needs cleanup now.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-reply/heartbeat.js";
|
||||
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
@ -6,12 +7,37 @@ import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
|
||||
import { loadSessionEntry } from "./session-utils.js";
|
||||
import { formatForLog } from "./ws-log.js";
|
||||
|
||||
function resolveHeartbeatAckMaxChars(): number {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
return Math.max(
|
||||
0,
|
||||
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHeartbeatContext(runId: string, sourceRunId?: string) {
|
||||
const primary = getAgentRunContext(runId);
|
||||
if (primary?.isHeartbeat) {
|
||||
return primary;
|
||||
}
|
||||
if (sourceRunId && sourceRunId !== runId) {
|
||||
const source = getAgentRunContext(sourceRunId);
|
||||
if (source?.isHeartbeat) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if webchat broadcasts should be suppressed for heartbeat runs.
|
||||
* Returns true if the run is a heartbeat and showOk is false.
|
||||
* Check if heartbeat ACK/noise should be hidden from interactive chat surfaces.
|
||||
*/
|
||||
function shouldSuppressHeartbeatBroadcast(runId: string): boolean {
|
||||
const runContext = getAgentRunContext(runId);
|
||||
function shouldHideHeartbeatChatOutput(runId: string, sourceRunId?: string): boolean {
|
||||
const runContext = resolveHeartbeatContext(runId, sourceRunId);
|
||||
if (!runContext?.isHeartbeat) {
|
||||
return false;
|
||||
}
|
||||
@ -26,6 +52,28 @@ function shouldSuppressHeartbeatBroadcast(runId: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHeartbeatChatFinalText(params: {
|
||||
runId: string;
|
||||
sourceRunId?: string;
|
||||
text: string;
|
||||
}): { suppress: boolean; text: string } {
|
||||
if (!shouldHideHeartbeatChatOutput(params.runId, params.sourceRunId)) {
|
||||
return { suppress: false, text: params.text };
|
||||
}
|
||||
|
||||
const stripped = stripHeartbeatToken(params.text, {
|
||||
mode: "heartbeat",
|
||||
maxAckChars: resolveHeartbeatAckMaxChars(),
|
||||
});
|
||||
if (!stripped.didStrip) {
|
||||
return { suppress: false, text: params.text };
|
||||
}
|
||||
if (stripped.shouldSkip) {
|
||||
return { suppress: true, text: "" };
|
||||
}
|
||||
return { suppress: false, text: stripped.text };
|
||||
}
|
||||
|
||||
export type ChatRunEntry = {
|
||||
sessionKey: string;
|
||||
clientRunId: string;
|
||||
@ -228,11 +276,20 @@ export function createAgentEventHandler({
|
||||
clearAgentRunContext,
|
||||
toolEventRecipients,
|
||||
}: AgentEventHandlerOptions) {
|
||||
const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => {
|
||||
const emitChatDelta = (
|
||||
sessionKey: string,
|
||||
clientRunId: string,
|
||||
sourceRunId: string,
|
||||
seq: number,
|
||||
text: string,
|
||||
) => {
|
||||
if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
|
||||
return;
|
||||
}
|
||||
chatRunState.buffers.set(clientRunId, text);
|
||||
if (shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0;
|
||||
if (now - last < 150) {
|
||||
@ -250,22 +307,27 @@ export function createAgentEventHandler({
|
||||
timestamp: now,
|
||||
},
|
||||
};
|
||||
// Suppress webchat broadcast for heartbeat runs when showOk is false
|
||||
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
|
||||
broadcast("chat", payload, { dropIfSlow: true });
|
||||
}
|
||||
broadcast("chat", payload, { dropIfSlow: true });
|
||||
nodeSendToSession(sessionKey, "chat", payload);
|
||||
};
|
||||
|
||||
const emitChatFinal = (
|
||||
sessionKey: string,
|
||||
clientRunId: string,
|
||||
sourceRunId: string,
|
||||
seq: number,
|
||||
jobState: "done" | "error",
|
||||
error?: unknown,
|
||||
) => {
|
||||
const text = chatRunState.buffers.get(clientRunId)?.trim() ?? "";
|
||||
const shouldSuppressSilent = isSilentReplyText(text, SILENT_REPLY_TOKEN);
|
||||
const bufferedText = chatRunState.buffers.get(clientRunId)?.trim() ?? "";
|
||||
const normalizedHeartbeatText = normalizeHeartbeatChatFinalText({
|
||||
runId: clientRunId,
|
||||
sourceRunId,
|
||||
text: bufferedText,
|
||||
});
|
||||
const text = normalizedHeartbeatText.text.trim();
|
||||
const shouldSuppressSilent =
|
||||
normalizedHeartbeatText.suppress || isSilentReplyText(text, SILENT_REPLY_TOKEN);
|
||||
chatRunState.buffers.delete(clientRunId);
|
||||
chatRunState.deltaSentAt.delete(clientRunId);
|
||||
if (jobState === "done") {
|
||||
@ -283,10 +345,7 @@ export function createAgentEventHandler({
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
// Suppress webchat broadcast for heartbeat runs when showOk is false
|
||||
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
|
||||
broadcast("chat", payload);
|
||||
}
|
||||
broadcast("chat", payload);
|
||||
nodeSendToSession(sessionKey, "chat", payload);
|
||||
return;
|
||||
}
|
||||
@ -388,7 +447,7 @@ export function createAgentEventHandler({
|
||||
nodeSendToSession(sessionKey, "agent", isToolEvent ? toolPayload : agentPayload);
|
||||
}
|
||||
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
||||
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
|
||||
emitChatDelta(sessionKey, clientRunId, evt.runId, evt.seq, evt.data.text);
|
||||
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
||||
if (chatLink) {
|
||||
const finished = chatRunState.registry.shift(evt.runId);
|
||||
@ -399,6 +458,7 @@ export function createAgentEventHandler({
|
||||
emitChatFinal(
|
||||
finished.sessionKey,
|
||||
finished.clientRunId,
|
||||
evt.runId,
|
||||
evt.seq,
|
||||
lifecyclePhase === "error" ? "error" : "done",
|
||||
evt.data?.error,
|
||||
@ -407,6 +467,7 @@ export function createAgentEventHandler({
|
||||
emitChatFinal(
|
||||
sessionKey,
|
||||
eventRunId,
|
||||
evt.runId,
|
||||
evt.seq,
|
||||
lifecyclePhase === "error" ? "error" : "done",
|
||||
evt.data?.error,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user