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:
ademczuk
2026-03-02 23:39:08 +01:00
committed by GitHub
parent 48155729fc
commit 0743463b88
7 changed files with 492 additions and 19 deletions

View File

@@ -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();
});
});

View File

@@ -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,
{