FN-5722: suppress __SKIP__ sentinel room replies

Treat __SKIP__ room responder outputs as intentional silence so they never appear in persisted or rendered chat history.

- export ROOM_SKIP_SENTINEL and helper detection in chat orchestration
- skip persisting responder outputs that are trimmed-exact __SKIP__ sentinels
- avoid room reply failure errors when responders intentionally skip
- filter sentinel-only room messages in ChatView rendering
- add backend and UI tests covering sentinel suppression and substring pass-through
- document sentinel no-op behavior in dashboard chat room guide

Files changed:
 docs/dashboard-guide.md                            |  1 +
 packages/dashboard/app/components/ChatView.tsx     |  8 ++-
 packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx   | 16 +++++
 packages/dashboard/src/__tests__/chat.rooms.test.ts     | 80 +++++++++++++++++++++-
 packages/dashboard/src/chat.ts                     | 15 +++-
 5 files changed, 116 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-5722

Fusion-Task-Lineage: 242085fc-4b88-46c1-81aa-e398f5276fec
This commit is contained in:
gsxdsm
2026-05-30 10:21:49 -07:00
parent d57ded1461
commit 16a82cf0ad
5 changed files with 116 additions and 4 deletions

View File

@@ -165,6 +165,7 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are
- The room composer clears immediately when send is dispatched so the user gets instant feedback; on success the optimistic message is reconciled with persisted server data and the transcript is refreshed to authoritative history.
- On mobile, room threads use the same keyboard-aware thread anchoring as direct chat, keeping the composer pinned above the soft keyboard while typing.
- The dashboard backend now orchestrates room responders on that POST: mentioned members are routed as direct responders, additional ambient members may reply (up to the room ambient responder cap), and each assistant reply is persisted with `senderAgentId` via `chatStore.addRoomMessage(...)`.
- Room responders can intentionally stay silent by returning the `__SKIP__` sentinel; that sentinel is treated as a no-op and is never persisted, emitted over SSE, or rendered in room transcripts.
- If room replies cannot be generated (for example no resolvable responders or all responders fail), the POST fails with an API error (HTTP 502) instead of silently returning only the user message.
- If room responders cannot be resolved or all room-reply generations fail, the POST now returns an error instead of silently succeeding with only the user message, so failures are surfaced deterministically.
- Room responder prompt construction now keeps the most recent room messages verbatim and, when the room runs long, prepends a compacted summary of older history (span, participants, and key highlights) plus an explicit latest-user-message marker so replies stay thread-aware without unbounded prompt growth.

View File

@@ -57,6 +57,8 @@ export interface ChatViewProps {
// Keep a generous cap so pasted multi-paragraph text stays visible while
// still preventing the composer from overtaking the message pane on short viewports.
const CHAT_INPUT_MAX_HEIGHT_PX = 640;
/** Canonical definition lives in packages/dashboard/src/chat.ts (ROOM_SKIP_SENTINEL). */
const ROOM_SKIP_SENTINEL = "__SKIP__";
let chatViewWasPreviouslyInactive = false;
export function resolveChatInputOverflowY(scrollHeight: number): "auto" | "hidden" {
@@ -2945,10 +2947,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
{rooms.messagesLoading ? (
<div className="chat-empty-state">Loading messages...</div>
) : rooms.messages.length === 0 ? (
) : rooms.messages.filter((message) => message.content.trim() !== ROOM_SKIP_SENTINEL).length === 0 ? (
<div className="chat-empty-state">No messages yet. Start the conversation!</div>
) : (
rooms.messages.map((message) => {
rooms.messages
.filter((message) => message.content.trim() !== ROOM_SKIP_SENTINEL)
.map((message) => {
const senderName = message.senderAgentId ? (agentsMap.get(message.senderAgentId)?.name ?? message.senderAgentId.slice(0, 30)) : "You";
const roomMessage: ChatMessageInfo = {
id: message.id,

View File

@@ -259,6 +259,22 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
expect(selectRoom).toHaveBeenCalledWith("room-b");
});
it("filters room messages that are trimmed-exact skip sentinels", async () => {
setup({}, {
messages: [
{ id: "rmsg-skip", roomId: "room-a", role: "assistant", content: " __SKIP__ ", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] },
{ id: "rmsg-token", roomId: "room-a", role: "assistant", content: "use __SKIP__ as a token", createdAt: "2026-04-08T00:01:00.000Z", senderAgentId: "agent-1", mentions: [] },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(screen.queryByTestId("chat-message-rmsg-skip")).not.toBeInTheDocument();
expect(screen.getByTestId("chat-message-rmsg-token")).toBeInTheDocument();
});
});
it("shows Create room in mobile footer for Rooms scope and hides New Chat + rooms header", () => {
const viewportSpy = mockMobileViewport();

View File

@@ -1,5 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChatManager, RoomReplyGenerationError, __setCreateResolvedAgentSession, __resetChatState } from "../chat.js";
import {
ChatManager,
ROOM_SKIP_SENTINEL,
RoomReplyGenerationError,
__setCreateResolvedAgentSession,
__resetChatState,
} from "../chat.js";
const mockChatStore = {
listRoomMembers: vi.fn(),
@@ -89,6 +95,78 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
expect(assistantWrite).toMatchObject({ role: "assistant", senderAgentId: "agent-a", content: "Room reply" });
});
it("suppresses trimmed skip sentinel replies while persisting normal co-responder replies", async () => {
mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },
{ roomId: "room-1", agentId: "agent-b", role: "member", addedAt: "2026-01-01" },
]);
mockAgentStore.listAgents.mockResolvedValue([
{ id: "agent-a", name: "Alpha", role: "executor" },
{ id: "agent-b", name: "Beta", role: "executor" },
]);
const replySpy = vi.spyOn(ChatManager.prototype as any, "generateRoomResponderReply")
.mockResolvedValueOnce({ content: ` ${ROOM_SKIP_SENTINEL} `, thinkingOutput: null })
.mockResolvedValueOnce({ content: "Room reply from Beta", thinkingOutput: null });
const manager = new ChatManager(mockChatStore as any, "/tmp", mockAgentStore as any);
const result = await manager.sendRoomMessage("room-1", "hello");
expect(result.responders).toEqual(["agent-b"]);
const assistantWrites = mockChatStore.addRoomMessage.mock.calls
.map((call: any[]) => call[1])
.filter((entry: any) => entry.role === "assistant");
expect(assistantWrites).toHaveLength(1);
expect(assistantWrites[0]).toMatchObject({
content: "Room reply from Beta",
senderAgentId: "agent-b",
});
replySpy.mockRestore();
});
it("treats all-skip responder outcomes as intentional no-op without throwing", async () => {
mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },
]);
mockAgentStore.listAgents.mockResolvedValue([{ id: "agent-a", name: "Alpha", role: "executor" }]);
const replySpy = vi.spyOn(ChatManager.prototype as any, "generateRoomResponderReply")
.mockResolvedValue({ content: ` ${ROOM_SKIP_SENTINEL} `, thinkingOutput: null });
const manager = new ChatManager(mockChatStore as any, "/tmp", mockAgentStore as any);
await expect(manager.sendRoomMessage("room-1", "hello")).resolves.toMatchObject({ responders: [] });
const assistantWrites = mockChatStore.addRoomMessage.mock.calls
.map((call: any[]) => call[1])
.filter((entry: any) => entry.role === "assistant");
expect(assistantWrites).toHaveLength(0);
replySpy.mockRestore();
});
it("persists replies that only contain the skip token as a substring", async () => {
mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },
]);
mockAgentStore.listAgents.mockResolvedValue([{ id: "agent-a", name: "Alpha", role: "executor" }]);
const replySpy = vi.spyOn(ChatManager.prototype as any, "generateRoomResponderReply")
.mockResolvedValue({ content: "use __SKIP__ as a token", thinkingOutput: null });
const manager = new ChatManager(mockChatStore as any, "/tmp", mockAgentStore as any);
const result = await manager.sendRoomMessage("room-1", "hello");
expect(result.responders).toEqual(["agent-a"]);
const assistantWrite = mockChatStore.addRoomMessage.mock.calls
.map((call: any[]) => call[1])
.find((entry: any) => entry.role === "assistant");
expect(assistantWrite).toMatchObject({ content: "use __SKIP__ as a token" });
replySpy.mockRestore();
});
it("fails deterministically when no member responder can be resolved", async () => {
mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },

View File

@@ -138,6 +138,13 @@ const MAX_MESSAGES_PER_IP_PER_MINUTE = 30;
/** Maximum file size for # mentions (50KB). Files larger than this are skipped. */
const MAX_REFERENCED_FILE_SIZE = 50 * 1024;
const ROOM_AMBIENT_MAX_RESPONDERS = 5;
/** Sentinel response from room responders indicating an intentional no-op/silence. */
export const ROOM_SKIP_SENTINEL = "__SKIP__";
export function isRoomSkipSentinel(content: string): boolean {
return content.trim() === ROOM_SKIP_SENTINEL;
}
const DEFAULT_ROOM_THREAD_RECENT_VERBATIM_MESSAGES = 25;
const DEFAULT_ROOM_THREAD_COMPACTION_FETCH_LIMIT = 200;
const ROOM_THREAD_CONTEXT_MAX_CHARS = 20_000;
@@ -1157,6 +1164,7 @@ export class ChatManager {
}
const successfulResponderIds: string[] = [];
const skippedResponderIds: string[] = [];
const responderFailures: string[] = [];
for (const responder of responders) {
@@ -1172,6 +1180,11 @@ export class ChatManager {
modelId,
});
if (isRoomSkipSentinel(response.content)) {
skippedResponderIds.push(responder.id);
continue;
}
this.chatStore.addRoomMessage(roomId, {
role: "assistant",
content: response.content,
@@ -1188,7 +1201,7 @@ export class ChatManager {
}
}
if (successfulResponderIds.length === 0) {
if (successfulResponderIds.length === 0 && skippedResponderIds.length === 0) {
throw new RoomReplyGenerationError(
`Failed to generate room replies for room ${roomId}: ${responderFailures.join("; ")}`,
roomId,