mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 20:51:10 +00:00
fix(gateway): suppress announce/reply skip chat leakage (#51739)
Merged via squash.
Prepared head SHA: 2f53f3b0b7
Co-authored-by: Pinghuachiu <9033138+Pinghuachiu@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
@@ -46,7 +46,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/dependency audit: bump Hono to `4.12.12` and `@hono/node-server` to `1.19.13` in production resolution paths.
|
||||
- Slack/partial streaming: keep the final fallback reply path active when preview finalization fails so stale preview text cannot suppress the actual final answer. (#62859) Thanks @gumadeiras.
|
||||
- Plugins/contracts: keep test-only helpers out of production contract barrels, load shared contract harnesses through bundled test surfaces, and harden guardrails so indirect re-exports and canonical `*.test.ts` files stay blocked. (#63311) Thanks @altaywtf.
|
||||
- Sessions/routing: preserve established external routes on inter-session announce traffic so `sessions_send` follow-ups do not steal delivery from Telegram, Discord, or other external channels. (#58013) thanks @duqaXxX.
|
||||
- Sessions/routing: preserve established external routes on inter-session announce traffic so `sessions_send` follow-ups do not steal delivery from Telegram, Discord, or other external channels. (#58013) Thanks @duqaXxX.
|
||||
- Gateway/chat: suppress exact and streamed `ANNOUNCE_SKIP` / `REPLY_SKIP` control replies across live chat updates and history sanitization so internal agent-to-agent control tokens no longer leak into user-facing gateway chat surfaces. (#51739) Thanks @Pinghuachiu.
|
||||
|
||||
## 2026.4.8
|
||||
|
||||
@@ -1481,7 +1482,6 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Agents/edit tool: accept common path/text alias spellings, show current file contents on exact-match failures, and avoid false edit failures after successful writes. (#52516) thanks @mbelinky.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
### Changes
|
||||
|
||||
62
src/gateway/control-reply-text.ts
Normal file
62
src/gateway/control-reply-text.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
|
||||
const SUPPRESSED_CONTROL_REPLY_TOKENS = [
|
||||
SILENT_REPLY_TOKEN,
|
||||
"ANNOUNCE_SKIP",
|
||||
"REPLY_SKIP",
|
||||
] as const;
|
||||
|
||||
const MIN_BARE_PREFIX_LENGTH_BY_TOKEN: Readonly<
|
||||
Record<(typeof SUPPRESSED_CONTROL_REPLY_TOKENS)[number], number>
|
||||
> = {
|
||||
[SILENT_REPLY_TOKEN]: 2,
|
||||
ANNOUNCE_SKIP: 3,
|
||||
REPLY_SKIP: 3,
|
||||
};
|
||||
|
||||
function normalizeSuppressedControlReplyFragment(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const normalized = trimmed.toUpperCase();
|
||||
if (/[^A-Z_]/.test(normalized)) {
|
||||
return "";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when a chat-visible reply is exactly an internal control token.
|
||||
*/
|
||||
export function isSuppressedControlReplyText(text: string): boolean {
|
||||
const normalized = text.trim();
|
||||
return SUPPRESSED_CONTROL_REPLY_TOKENS.some((token) => isSilentReplyText(normalized, token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when streamed assistant text looks like the leading fragment of a control token.
|
||||
*/
|
||||
export function isSuppressedControlReplyLeadFragment(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
const normalized = normalizeSuppressedControlReplyFragment(text);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return SUPPRESSED_CONTROL_REPLY_TOKENS.some((token) => {
|
||||
const tokenUpper = token.toUpperCase();
|
||||
if (normalized === tokenUpper) {
|
||||
return false;
|
||||
}
|
||||
if (!tokenUpper.startsWith(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.includes("_")) {
|
||||
return true;
|
||||
}
|
||||
if (token !== SILENT_REPLY_TOKEN && trimmed !== trimmed.toUpperCase()) {
|
||||
return false;
|
||||
}
|
||||
return normalized.length >= MIN_BARE_PREFIX_LENGTH_BY_TOKEN[token];
|
||||
});
|
||||
}
|
||||
@@ -215,36 +215,42 @@ describe("agent event handler", () => {
|
||||
nowSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it("does not emit chat delta for NO_REPLY streaming text", () => {
|
||||
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
|
||||
createHarness({ now: 1_000 }),
|
||||
" NO_REPLY ",
|
||||
);
|
||||
expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0);
|
||||
nowSpy?.mockRestore();
|
||||
});
|
||||
it.each([" NO_REPLY ", " ANNOUNCE_SKIP ", " REPLY_SKIP "])(
|
||||
"does not emit chat delta for suppressed control text %s",
|
||||
(replyText) => {
|
||||
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
|
||||
createHarness({ now: 1_000 }),
|
||||
replyText,
|
||||
);
|
||||
expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0);
|
||||
nowSpy?.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not include NO_REPLY text in chat final message", () => {
|
||||
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
|
||||
now: 2_000,
|
||||
});
|
||||
chatRunState.registry.add("run-2", { sessionKey: "session-2", clientRunId: "client-2" });
|
||||
it.each(["NO_REPLY", "ANNOUNCE_SKIP", "REPLY_SKIP"])(
|
||||
"does not include %s text in chat final message",
|
||||
(replyText) => {
|
||||
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
|
||||
now: 2_000,
|
||||
});
|
||||
chatRunState.registry.add("run-2", { sessionKey: "session-2", clientRunId: "client-2" });
|
||||
|
||||
handler({
|
||||
runId: "run-2",
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { text: "NO_REPLY" },
|
||||
});
|
||||
emitLifecycleEnd(handler, "run-2");
|
||||
handler({
|
||||
runId: "run-2",
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { text: replyText },
|
||||
});
|
||||
emitLifecycleEnd(handler, "run-2");
|
||||
|
||||
const payload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
|
||||
expect(payload.message).toBeUndefined();
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
|
||||
nowSpy?.mockRestore();
|
||||
});
|
||||
const payload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
|
||||
expect(payload.message).toBeUndefined();
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
|
||||
nowSpy?.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
it("suppresses NO_REPLY lead fragments and does not leak NO in final chat message", () => {
|
||||
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
|
||||
@@ -269,6 +275,38 @@ describe("agent event handler", () => {
|
||||
nowSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ANNOUNCE_SKIP", ["ANN", "ANNOUNCE_", "ANNOUNCE_SKIP"]],
|
||||
["REPLY_SKIP", ["REP", "REPLY_", "REPLY_SKIP"]],
|
||||
] as const)(
|
||||
"suppresses %s lead fragments and does not leak the streamed prefix in the final chat message",
|
||||
(_replyText, fragments) => {
|
||||
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
|
||||
now: 2_150,
|
||||
});
|
||||
chatRunState.registry.add("run-control", {
|
||||
sessionKey: "session-control",
|
||||
clientRunId: "client-control",
|
||||
});
|
||||
|
||||
for (const text of fragments) {
|
||||
handler({
|
||||
runId: "run-control",
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Date.now(),
|
||||
data: { text },
|
||||
});
|
||||
}
|
||||
emitLifecycleEnd(handler, "run-control");
|
||||
|
||||
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,
|
||||
|
||||
@@ -10,6 +10,10 @@ import { loadConfig } from "../config/config.js";
|
||||
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
|
||||
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
|
||||
import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js";
|
||||
import {
|
||||
isSuppressedControlReplyLeadFragment,
|
||||
isSuppressedControlReplyText,
|
||||
} from "./control-reply-text.js";
|
||||
import { loadGatewaySessionRow } from "./server-chat.load-gateway-session-row.runtime.js";
|
||||
import { persistGatewaySessionLifecycleEvent } from "./server-chat.persist-session-lifecycle.runtime.js";
|
||||
import { deriveGatewaySessionLifecycleSnapshot } from "./session-lifecycle-state.js";
|
||||
@@ -83,20 +87,6 @@ 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);
|
||||
}
|
||||
|
||||
function appendUniqueSuffix(base: string, suffix: string): string {
|
||||
if (!suffix) {
|
||||
return base;
|
||||
@@ -692,11 +682,11 @@ export function createAgentEventHandler({
|
||||
return;
|
||||
}
|
||||
chatRunState.rawBuffers.set(clientRunId, mergedRawText);
|
||||
if (isSilentReplyText(mergedRawText, SILENT_REPLY_TOKEN)) {
|
||||
if (isSuppressedControlReplyText(mergedRawText)) {
|
||||
chatRunState.buffers.set(clientRunId, "");
|
||||
return;
|
||||
}
|
||||
if (isSilentReplyLeadFragment(mergedRawText)) {
|
||||
if (isSuppressedControlReplyLeadFragment(mergedRawText)) {
|
||||
chatRunState.buffers.set(clientRunId, mergedRawText);
|
||||
return;
|
||||
}
|
||||
@@ -704,6 +694,12 @@ export function createAgentEventHandler({
|
||||
? stripLeadingSilentToken(mergedRawText, SILENT_REPLY_TOKEN)
|
||||
: mergedRawText;
|
||||
chatRunState.buffers.set(clientRunId, mergedText);
|
||||
if (isSuppressedControlReplyText(mergedText)) {
|
||||
return;
|
||||
}
|
||||
if (isSuppressedControlReplyLeadFragment(mergedText)) {
|
||||
return;
|
||||
}
|
||||
if (shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) {
|
||||
return;
|
||||
}
|
||||
@@ -740,7 +736,7 @@ export function createAgentEventHandler({
|
||||
});
|
||||
const text = normalizedHeartbeatText.text.trim();
|
||||
const shouldSuppressSilent =
|
||||
normalizedHeartbeatText.suppress || isSilentReplyText(text, SILENT_REPLY_TOKEN);
|
||||
normalizedHeartbeatText.suppress || isSuppressedControlReplyText(text);
|
||||
return { text, shouldSuppressSilent };
|
||||
};
|
||||
|
||||
@@ -751,7 +747,7 @@ export function createAgentEventHandler({
|
||||
seq: number,
|
||||
) => {
|
||||
const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId);
|
||||
const shouldSuppressSilentLeadFragment = isSilentReplyLeadFragment(text);
|
||||
const shouldSuppressSilentLeadFragment = isSuppressedControlReplyLeadFragment(text);
|
||||
const shouldSuppressHeartbeatStreaming = shouldHideHeartbeatChatOutput(
|
||||
clientRunId,
|
||||
sourceRunId,
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
||||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { resolveSessionFilePath } from "../../config/sessions.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "../chat-attachments.js";
|
||||
import { stripEnvelopeFromMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js";
|
||||
import { augmentChatHistoryWithCliSessionImports } from "../cli-session-history.js";
|
||||
import { isSuppressedControlReplyText } from "../control-reply-text.js";
|
||||
import { ADMIN_SCOPE } from "../method-scopes.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_CAPS,
|
||||
@@ -732,7 +733,7 @@ function shouldDropAssistantHistoryMessage(message: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
const text = extractAssistantTextForSilentCheck(message);
|
||||
if (text === undefined || !isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
|
||||
if (text === undefined || !isSuppressedControlReplyText(text)) {
|
||||
return false;
|
||||
}
|
||||
return !hasAssistantNonTextContent(message);
|
||||
@@ -745,14 +746,14 @@ export function sanitizeChatHistoryMessages(messages: unknown[], maxChars: numbe
|
||||
let changed = false;
|
||||
const next: unknown[] = [];
|
||||
for (const message of messages) {
|
||||
// Drop assistant commentary-only entries and NO_REPLY-only entries, but
|
||||
const res = sanitizeChatHistoryMessage(message, maxChars);
|
||||
changed ||= res.changed;
|
||||
// Drop assistant commentary-only entries and exact control replies, but
|
||||
// keep mixed assistant entries that still carry non-text content.
|
||||
if (shouldDropAssistantHistoryMessage(message)) {
|
||||
if (shouldDropAssistantHistoryMessage(res.message)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
const res = sanitizeChatHistoryMessage(message, maxChars);
|
||||
changed ||= res.changed;
|
||||
next.push(res.message);
|
||||
}
|
||||
return changed ? next : messages;
|
||||
|
||||
@@ -565,6 +565,50 @@ describe("gateway server chat", () => {
|
||||
expect(collectHistoryTextValues(historyMessages)).toEqual(["hello", "real reply"]);
|
||||
});
|
||||
|
||||
test("chat.history hides assistant announce/reply skip-only entries", async () => {
|
||||
const historyMessages = await loadChatHistoryWithMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "ANNOUNCE_SKIP" }],
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "REPLY_SKIP" }],
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
text: "real text field reply",
|
||||
content: "ANNOUNCE_SKIP",
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "real reply" }],
|
||||
timestamp: 4,
|
||||
},
|
||||
]);
|
||||
const roleAndText = historyMessages
|
||||
.map((message) => {
|
||||
const role =
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
typeof (message as { role?: unknown }).role === "string"
|
||||
? (message as { role: string }).role
|
||||
: "unknown";
|
||||
const text =
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
typeof (message as { text?: unknown }).text === "string"
|
||||
? (message as { text: string }).text
|
||||
: (extractFirstTextBlock(message) ?? "");
|
||||
return `${role}:${text}`;
|
||||
})
|
||||
.filter((entry) => entry !== "unknown:");
|
||||
|
||||
expect(roleAndText).toEqual(["assistant:real text field reply", "assistant:real reply"]);
|
||||
});
|
||||
test("routes chat.send slash commands without agent runs", async () => {
|
||||
await withMainSessionStore(async () => {
|
||||
const spy = vi.mocked(agentCommand);
|
||||
|
||||
Reference in New Issue
Block a user