mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(webchat): suppress NO_REPLY token in chat transcript rendering (#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 <noreply@anthropic.com>
* 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 #32015
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(webchat): align isAssistantSilentReply text/content precedence with gateway
* webchat: tighten NO_REPLY transcript and delta filtering
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { handleChatEvent, type ChatEventPayload, type ChatState } from "./chat.ts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleChatEvent, loadChatHistory, type ChatEventPayload, type ChatState } from "./chat.ts";
|
||||
|
||||
function createState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
@@ -53,6 +53,23 @@ describe("handleChatEvent", () => {
|
||||
expect(state.chatStream).toBe("Hello");
|
||||
});
|
||||
|
||||
it("ignores NO_REPLY delta updates", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "Hello",
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] },
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("delta");
|
||||
expect(state.chatStream).toBe("Hello");
|
||||
});
|
||||
|
||||
it("appends final payload from another run without clearing active stream", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
@@ -77,6 +94,30 @@ describe("handleChatEvent", () => {
|
||||
expect(state.chatMessages[0]).toEqual(payload.message);
|
||||
});
|
||||
|
||||
it("drops NO_REPLY final payload from another run without clearing active stream", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Working...",
|
||||
chatStreamStartedAt: 123,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "NO_REPLY" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatRunId).toBe("run-user");
|
||||
expect(state.chatStream).toBe("Working...");
|
||||
expect(state.chatStreamStartedAt).toBe(123);
|
||||
expect(state.chatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns final for another run when payload has no message", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
@@ -325,4 +366,203 @@ describe("handleChatEvent", () => {
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
expect(state.chatMessages).toEqual([existingMessage]);
|
||||
});
|
||||
|
||||
it("drops NO_REPLY final payload from another run", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Working...",
|
||||
chatStreamStartedAt: 123,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "NO_REPLY" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatMessages).toEqual([]);
|
||||
expect(state.chatRunId).toBe("run-user");
|
||||
expect(state.chatStream).toBe("Working...");
|
||||
});
|
||||
|
||||
it("drops NO_REPLY final payload from own run", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "NO_REPLY",
|
||||
chatStreamStartedAt: 100,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "NO_REPLY" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatMessages).toEqual([]);
|
||||
expect(state.chatRunId).toBe(null);
|
||||
expect(state.chatStream).toBe(null);
|
||||
});
|
||||
|
||||
it("does not persist NO_REPLY stream text on final without message", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "NO_REPLY",
|
||||
chatStreamStartedAt: 100,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not persist NO_REPLY stream text on abort", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "NO_REPLY",
|
||||
chatStreamStartedAt: 100,
|
||||
});
|
||||
const payload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
message: "not-an-assistant-message",
|
||||
} as unknown as ChatEventPayload;
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("aborted");
|
||||
expect(state.chatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps user messages containing NO_REPLY text", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Working...",
|
||||
chatStreamStartedAt: 123,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "NO_REPLY" }],
|
||||
},
|
||||
};
|
||||
|
||||
// User messages with NO_REPLY text should NOT be filtered — only assistant messages.
|
||||
// normalizeFinalAssistantMessage returns null for user role, so this falls through.
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
});
|
||||
|
||||
it("keeps assistant message when text field has real reply but content is NO_REPLY", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "",
|
||||
chatStreamStartedAt: 100,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
text: "real reply",
|
||||
content: "NO_REPLY",
|
||||
},
|
||||
};
|
||||
|
||||
// entry.text takes precedence — "real reply" is NOT silent, so the message is kept.
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatMessages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadChatHistory", () => {
|
||||
it("filters NO_REPLY assistant messages from history", async () => {
|
||||
const messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Real answer" }] },
|
||||
{ role: "assistant", text: " NO_REPLY " },
|
||||
];
|
||||
const mockClient = {
|
||||
request: vi.fn().mockResolvedValue({ messages, thinkingLevel: "low" }),
|
||||
};
|
||||
const state = createState({
|
||||
client: mockClient as unknown as ChatState["client"],
|
||||
connected: true,
|
||||
});
|
||||
|
||||
await loadChatHistory(state);
|
||||
|
||||
expect(state.chatMessages).toHaveLength(2);
|
||||
expect(state.chatMessages[0]).toEqual(messages[0]);
|
||||
expect(state.chatMessages[1]).toEqual(messages[2]);
|
||||
expect(state.chatThinkingLevel).toBe("low");
|
||||
expect(state.chatLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps assistant message when text field has real content but content is NO_REPLY", async () => {
|
||||
const messages = [{ role: "assistant", text: "real reply", content: "NO_REPLY" }];
|
||||
const mockClient = {
|
||||
request: vi.fn().mockResolvedValue({ messages }),
|
||||
};
|
||||
const state = createState({
|
||||
client: mockClient as unknown as ChatState["client"],
|
||||
connected: true,
|
||||
});
|
||||
|
||||
await loadChatHistory(state);
|
||||
|
||||
// text takes precedence — "real reply" is NOT silent, so message is kept.
|
||||
expect(state.chatMessages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadChatHistory", () => {
|
||||
it("filters assistant NO_REPLY messages and keeps user NO_REPLY messages", async () => {
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{ role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "visible answer" }] },
|
||||
{ role: "user", content: [{ type: "text", text: "NO_REPLY" }] },
|
||||
],
|
||||
thinkingLevel: "low",
|
||||
});
|
||||
const state = createState({
|
||||
connected: true,
|
||||
client: { request } as unknown as ChatState["client"],
|
||||
});
|
||||
|
||||
await loadChatHistory(state);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("chat.history", {
|
||||
sessionKey: "main",
|
||||
limit: 200,
|
||||
});
|
||||
expect(state.chatMessages).toEqual([
|
||||
{ role: "assistant", content: [{ type: "text", text: "visible answer" }] },
|
||||
{ role: "user", content: [{ type: "text", text: "NO_REPLY" }] },
|
||||
]);
|
||||
expect(state.chatThinkingLevel).toBe("low");
|
||||
expect(state.chatLoading).toBe(false);
|
||||
expect(state.lastError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,29 @@ import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ChatAttachment } from "../ui-types.ts";
|
||||
import { generateUUID } from "../uuid.ts";
|
||||
|
||||
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
|
||||
|
||||
function isSilentReplyStream(text: string): boolean {
|
||||
return SILENT_REPLY_PATTERN.test(text);
|
||||
}
|
||||
/** Client-side defense-in-depth: detect assistant messages whose text is purely NO_REPLY. */
|
||||
function isAssistantSilentReply(message: unknown): boolean {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
const entry = message as Record<string, unknown>;
|
||||
const role = typeof entry.role === "string" ? entry.role.toLowerCase() : "";
|
||||
if (role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
// entry.text takes precedence — matches gateway extractAssistantTextForSilentCheck
|
||||
if (typeof entry.text === "string") {
|
||||
return isSilentReplyStream(entry.text);
|
||||
}
|
||||
const text = extractText(message);
|
||||
return typeof text === "string" && isSilentReplyStream(text);
|
||||
}
|
||||
|
||||
export type ChatState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
@@ -41,7 +64,8 @@ export async function loadChatHistory(state: ChatState) {
|
||||
limit: 200,
|
||||
},
|
||||
);
|
||||
state.chatMessages = Array.isArray(res.messages) ? res.messages : [];
|
||||
const messages = Array.isArray(res.messages) ? res.messages : [];
|
||||
state.chatMessages = messages.filter((message) => !isAssistantSilentReply(message));
|
||||
state.chatThinkingLevel = res.thinkingLevel ?? null;
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
@@ -230,7 +254,7 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) {
|
||||
if (payload.state === "final") {
|
||||
const finalMessage = normalizeFinalAssistantMessage(payload.message);
|
||||
if (finalMessage) {
|
||||
if (finalMessage && !isAssistantSilentReply(finalMessage)) {
|
||||
state.chatMessages = [...state.chatMessages, finalMessage];
|
||||
return null;
|
||||
}
|
||||
@@ -241,7 +265,7 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
|
||||
if (payload.state === "delta") {
|
||||
const next = extractText(payload.message);
|
||||
if (typeof next === "string") {
|
||||
if (typeof next === "string" && !isSilentReplyStream(next)) {
|
||||
const current = state.chatStream ?? "";
|
||||
if (!current || next.length >= current.length) {
|
||||
state.chatStream = next;
|
||||
@@ -249,9 +273,9 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
}
|
||||
} else if (payload.state === "final") {
|
||||
const finalMessage = normalizeFinalAssistantMessage(payload.message);
|
||||
if (finalMessage) {
|
||||
if (finalMessage && !isAssistantSilentReply(finalMessage)) {
|
||||
state.chatMessages = [...state.chatMessages, finalMessage];
|
||||
} else if (state.chatStream?.trim()) {
|
||||
} else if (state.chatStream?.trim() && !isSilentReplyStream(state.chatStream)) {
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
@@ -266,11 +290,11 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
state.chatStreamStartedAt = null;
|
||||
} else if (payload.state === "aborted") {
|
||||
const normalizedMessage = normalizeAbortedAssistantMessage(payload.message);
|
||||
if (normalizedMessage) {
|
||||
if (normalizedMessage && !isAssistantSilentReply(normalizedMessage)) {
|
||||
state.chatMessages = [...state.chatMessages, normalizedMessage];
|
||||
} else {
|
||||
const streamedText = state.chatStream ?? "";
|
||||
if (streamedText.trim()) {
|
||||
if (streamedText.trim() && !isSilentReplyStream(streamedText)) {
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user