Skip to content

Commit 0743463

Browse files
ademczukclaudeTakhoffman
authored
fix(webchat): suppress NO_REPLY token in chat transcript rendering (openclaw#32183)
* fix(types): resolve pre-existing TS errors in agent-components and pairing-store - agent-components.ts: normalizeDiscordAllowList returns {allowAll, ids, names}, not an array — use ids.values().next().value instead of [0] indexing - pairing-store.ts: add non-null assertions for stat after cache-miss guard (resolveAllowFromReadCacheOrMissing returns early when stat is null) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(webchat): suppress NO_REPLY token in chat transcript rendering Filter assistant NO_REPLY-only entries from chat.history responses at the gateway API boundary and add client-side defense-in-depth guards in the UI chat controller so internal silent tokens never render as visible chat bubbles. Two-layer fix: 1. Gateway: extractAssistantTextForSilentCheck + isSilentReplyText filter in sanitizeChatHistoryMessages (entry.text takes precedence over entry.content to avoid dropping messages with real text) 2. UI: isAssistantSilentReply + isSilentReplyStream guards on all 5 message insertion points in handleChatEvent and loadChatHistory Fixes openclaw#32015 Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(webchat): align isAssistantSilentReply text/content precedence with gateway * webchat: tighten NO_REPLY transcript and delta filtering --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 4815572 commit 0743463

File tree

7 files changed

+492
-19
lines changed

7 files changed

+492
-19
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ Docs: https://docs.openclaw.ai
5050
- Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like `source`/`provider`), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.
5151
- Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so `openclaw models status` no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
5252
- Config/backups hardening: enforce owner-only (`0600`) permissions on rotated config backups and clean orphan `.bak.*` files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
53+
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
54+
- Webchat/silent token leak: filter assistant `NO_REPLY`-only transcript entries from `chat.history` responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.
5355
- Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like `/usr/bin/g++` and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
5456
- Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.
5557
- Hooks/after_tool_call: include embedded session context (`sessionKey`, `agentId`) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.

src/discord/monitor/agent-components.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -871,7 +871,7 @@ async function dispatchDiscordComponentEvent(params: {
871871
normalizeEntry: (entry) => {
872872
const normalized = normalizeDiscordAllowList([entry], ["discord:", "user:", "pk:"]);
873873
const candidate = normalized?.ids.values().next().value;
874-
return candidate && /^\d+$/.test(candidate) ? candidate : undefined;
874+
return typeof candidate === "string" && /^\d+$/.test(candidate) ? candidate : undefined;
875875
},
876876
})
877877
: null;

src/gateway/server-methods/chat.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
77
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
88
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
99
import type { MsgContext } from "../../auto-reply/templating.js";
10+
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
1011
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
1112
import { resolveSessionFilePath } from "../../config/sessions.js";
1213
import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
@@ -186,16 +187,61 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang
186187
return { message: changed ? entry : message, changed };
187188
}
188189

190+
/**
191+
* Extract the visible text from an assistant history message for silent-token checks.
192+
* Returns `undefined` for non-assistant messages or messages with no extractable text.
193+
* When `entry.text` is present it takes precedence over `entry.content` to avoid
194+
* dropping messages that carry real text alongside a stale `content: "NO_REPLY"`.
195+
*/
196+
function extractAssistantTextForSilentCheck(message: unknown): string | undefined {
197+
if (!message || typeof message !== "object") {
198+
return undefined;
199+
}
200+
const entry = message as Record<string, unknown>;
201+
if (entry.role !== "assistant") {
202+
return undefined;
203+
}
204+
if (typeof entry.text === "string") {
205+
return entry.text;
206+
}
207+
if (typeof entry.content === "string") {
208+
return entry.content;
209+
}
210+
if (!Array.isArray(entry.content) || entry.content.length === 0) {
211+
return undefined;
212+
}
213+
214+
const texts: string[] = [];
215+
for (const block of entry.content) {
216+
if (!block || typeof block !== "object") {
217+
return undefined;
218+
}
219+
const typed = block as { type?: unknown; text?: unknown };
220+
if (typed.type !== "text" || typeof typed.text !== "string") {
221+
return undefined;
222+
}
223+
texts.push(typed.text);
224+
}
225+
return texts.length > 0 ? texts.join("\n") : undefined;
226+
}
227+
189228
function sanitizeChatHistoryMessages(messages: unknown[]): unknown[] {
190229
if (messages.length === 0) {
191230
return messages;
192231
}
193232
let changed = false;
194-
const next = messages.map((message) => {
233+
const next: unknown[] = [];
234+
for (const message of messages) {
195235
const res = sanitizeChatHistoryMessage(message);
196236
changed ||= res.changed;
197-
return res.message;
198-
});
237+
// Drop assistant messages whose entire visible text is the silent reply token.
238+
const text = extractAssistantTextForSilentCheck(res.message);
239+
if (text !== undefined && isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
240+
changed = true;
241+
continue;
242+
}
243+
next.push(res.message);
244+
}
199245
return changed ? next : messages;
200246
}
201247

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

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,77 @@ describe("gateway server chat", () => {
304304
}
305305
});
306306

307+
test("chat.history hides assistant NO_REPLY-only entries", async () => {
308+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
309+
try {
310+
testState.sessionStorePath = path.join(dir, "sessions.json");
311+
await writeSessionStore({
312+
entries: {
313+
main: {
314+
sessionId: "sess-main",
315+
updatedAt: Date.now(),
316+
},
317+
},
318+
});
319+
320+
const messages = [
321+
{
322+
role: "user",
323+
content: [{ type: "text", text: "hello" }],
324+
timestamp: 1,
325+
},
326+
{
327+
role: "assistant",
328+
content: [{ type: "text", text: "NO_REPLY" }],
329+
timestamp: 2,
330+
},
331+
{
332+
role: "assistant",
333+
content: [{ type: "text", text: "real reply" }],
334+
timestamp: 3,
335+
},
336+
{
337+
role: "assistant",
338+
text: "real text field reply",
339+
content: "NO_REPLY",
340+
timestamp: 4,
341+
},
342+
{
343+
role: "user",
344+
content: [{ type: "text", text: "NO_REPLY" }],
345+
timestamp: 5,
346+
},
347+
];
348+
const lines = messages.map((message) => JSON.stringify({ message }));
349+
await fs.writeFile(path.join(dir, "sess-main.jsonl"), lines.join("\n"), "utf-8");
350+
351+
const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
352+
sessionKey: "main",
353+
});
354+
expect(res.ok).toBe(true);
355+
const historyMessages = res.payload?.messages ?? [];
356+
const textValues = historyMessages
357+
.map((message) => {
358+
if (message && typeof message === "object") {
359+
const entry = message as { text?: unknown };
360+
if (typeof entry.text === "string") {
361+
return entry.text;
362+
}
363+
}
364+
return extractFirstTextBlock(message);
365+
})
366+
.filter((value): value is string => typeof value === "string");
367+
// The NO_REPLY assistant message (content block) should be dropped.
368+
// The assistant with text="real text field reply" + content="NO_REPLY" stays
369+
// because entry.text takes precedence over entry.content for the silent check.
370+
// The user message with NO_REPLY text is preserved (only assistant filtered).
371+
expect(textValues).toEqual(["hello", "real reply", "real text field reply", "NO_REPLY"]);
372+
} finally {
373+
testState.sessionStorePath = undefined;
374+
await fs.rm(dir, { recursive: true, force: true });
375+
}
376+
});
377+
307378
test("routes chat.send slash commands without agent runs", async () => {
308379
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
309380
try {
@@ -342,6 +413,94 @@ describe("gateway server chat", () => {
342413
}
343414
});
344415

416+
test("chat.history hides assistant NO_REPLY-only entries and keeps mixed-content assistant entries", async () => {
417+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
418+
try {
419+
testState.sessionStorePath = path.join(dir, "sessions.json");
420+
await writeSessionStore({
421+
entries: {
422+
main: {
423+
sessionId: "sess-main",
424+
updatedAt: Date.now(),
425+
},
426+
},
427+
});
428+
429+
const messages = [
430+
{
431+
role: "user",
432+
content: [{ type: "text", text: "hello" }],
433+
timestamp: 1,
434+
},
435+
{
436+
role: "assistant",
437+
content: [{ type: "text", text: "NO_REPLY" }],
438+
timestamp: 2,
439+
},
440+
{
441+
role: "assistant",
442+
content: [{ type: "text", text: "real reply" }],
443+
timestamp: 3,
444+
},
445+
{
446+
role: "assistant",
447+
text: "real text field reply",
448+
content: "NO_REPLY",
449+
timestamp: 4,
450+
},
451+
{
452+
role: "user",
453+
content: [{ type: "text", text: "NO_REPLY" }],
454+
timestamp: 5,
455+
},
456+
{
457+
role: "assistant",
458+
content: [
459+
{ type: "text", text: "NO_REPLY" },
460+
{ type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
461+
],
462+
timestamp: 6,
463+
},
464+
];
465+
const lines = messages.map((message) => JSON.stringify({ message }));
466+
await fs.writeFile(path.join(dir, "sess-main.jsonl"), lines.join("\n"), "utf-8");
467+
468+
const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
469+
sessionKey: "main",
470+
});
471+
expect(res.ok).toBe(true);
472+
const historyMessages = res.payload?.messages ?? [];
473+
const roleAndText = historyMessages
474+
.map((message) => {
475+
const role =
476+
message &&
477+
typeof message === "object" &&
478+
typeof (message as { role?: unknown }).role === "string"
479+
? (message as { role: string }).role
480+
: "unknown";
481+
const text =
482+
message &&
483+
typeof message === "object" &&
484+
typeof (message as { text?: unknown }).text === "string"
485+
? (message as { text: string }).text
486+
: (extractFirstTextBlock(message) ?? "");
487+
return `${role}:${text}`;
488+
})
489+
.filter((entry) => entry !== "unknown:");
490+
491+
expect(roleAndText).toEqual([
492+
"user:hello",
493+
"assistant:real reply",
494+
"assistant:real text field reply",
495+
"user:NO_REPLY",
496+
"assistant:NO_REPLY",
497+
]);
498+
} finally {
499+
testState.sessionStorePath = undefined;
500+
await fs.rm(dir, { recursive: true, force: true });
501+
}
502+
});
503+
345504
test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => {
346505
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
347506
testState.sessionStorePath = path.join(dir, "sessions.json");

src/pairing/pairing-store.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -374,10 +374,11 @@ async function readAllowFromStateForPathWithExists(
374374
allowFrom: [],
375375
});
376376
const entries = normalizeAllowFromList(channel, value);
377+
// stat is guaranteed non-null here: resolveAllowFromReadCacheOrMissing returns early when stat is null.
377378
setAllowFromReadCache(filePath, {
378379
exists,
379-
mtimeMs: stat.mtimeMs,
380-
size: stat.size,
380+
mtimeMs: stat!.mtimeMs,
381+
size: stat!.size,
381382
entries,
382383
});
383384
return { entries, exists };
@@ -419,22 +420,23 @@ function readAllowFromStateForPathSyncWithExists(
419420
}
420421
return { entries: [], exists: false };
421422
}
423+
// stat is guaranteed non-null here: resolveAllowFromReadCacheOrMissing returns early when stat is null.
422424
try {
423425
const parsed = JSON.parse(raw) as AllowFromStore;
424426
const entries = normalizeAllowFromList(channel, parsed);
425427
setAllowFromReadCache(filePath, {
426428
exists: true,
427-
mtimeMs: stat.mtimeMs,
428-
size: stat.size,
429+
mtimeMs: stat!.mtimeMs,
430+
size: stat!.size,
429431
entries,
430432
});
431433
return { entries, exists: true };
432434
} catch {
433435
// Keep parity with async reads: malformed JSON still means the file exists.
434436
setAllowFromReadCache(filePath, {
435437
exists: true,
436-
mtimeMs: stat.mtimeMs,
437-
size: stat.size,
438+
mtimeMs: stat!.mtimeMs,
439+
size: stat!.size,
438440
entries: [],
439441
});
440442
return { entries: [], exists: true };

0 commit comments

Comments
 (0)