Gateway: suppress NO_REPLY lead-fragment chat leaks

This commit is contained in:
liuxiaopai-ai
2026-03-03 03:56:14 +08:00
committed by Peter Steinberger
parent 3de7768b11
commit bf0653846e
3 changed files with 65 additions and 1 deletions

View File

@@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
- Webchat/stream finalization: persist streamed assistant text when final events omit `message`, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
- Cron/store migration: normalize legacy cron jobs with string `schedule` and top-level `command`/`timeout` fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
- Tools/fsPolicy propagation: honor `tools.fs.workspaceOnly` for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.

View File

@@ -220,6 +220,52 @@ describe("agent event handler", () => {
nowSpy?.mockRestore();
});
it("suppresses NO_REPLY lead fragments and does not leak NO in final chat message", () => {
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
now: 2_100,
});
chatRunState.registry.add("run-3", { sessionKey: "session-3", clientRunId: "client-3" });
for (const text of ["NO", "NO_", "NO_RE", "NO_REPLY"]) {
handler({
runId: "run-3",
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { text },
});
}
emitLifecycleEnd(handler, "run-3");
const payload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
expect(payload.message).toBeUndefined();
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
nowSpy?.mockRestore();
});
it("keeps final short replies like 'No' even when lead-fragment deltas are suppressed", () => {
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
now: 2_200,
});
chatRunState.registry.add("run-4", { sessionKey: "session-4", clientRunId: "client-4" });
handler({
runId: "run-4",
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { text: "No" },
});
emitLifecycleEnd(handler, "run-4");
const payload = expectSingleFinalChatPayload(broadcast) as {
message?: { content?: Array<{ text?: string }> };
};
expect(payload.message?.content?.[0]?.text).toBe("No");
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
nowSpy?.mockRestore();
});
it("cleans up agent run sequence tracking when lifecycle completes", () => {
const { agentRunSeq, chatRunState, handler, nowSpy } = createHarness({ now: 2_500 });
chatRunState.registry.add("run-cleanup", {

View File

@@ -75,6 +75,20 @@ function normalizeHeartbeatChatFinalText(params: {
return { suppress: false, text: stripped.text };
}
function isSilentReplyLeadFragment(text: string): boolean {
const normalized = text.trim().toUpperCase();
if (!normalized) {
return false;
}
if (!/^[A-Z_]+$/.test(normalized)) {
return false;
}
if (normalized === SILENT_REPLY_TOKEN) {
return false;
}
return SILENT_REPLY_TOKEN.startsWith(normalized);
}
export type ChatRunEntry = {
sessionKey: string;
clientRunId: string;
@@ -288,10 +302,13 @@ export function createAgentEventHandler({
if (!cleaned) {
return;
}
chatRunState.buffers.set(clientRunId, cleaned);
if (isSilentReplyText(cleaned, SILENT_REPLY_TOKEN)) {
return;
}
chatRunState.buffers.set(clientRunId, cleaned);
if (isSilentReplyLeadFragment(cleaned)) {
return;
}
if (shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) {
return;
}