feat(FN-4180): compact room transcript context to reduce token usage
Adds room transcript context compaction (FN-4180) to the dashboard chat layer — widens the fetch window then compacts older messages into a summary window to reduce token overhead while keeping full context recent. Includes a new 127-line test suite covering the compaction logic, a small fix to reus Fusion-Task-Id: FN-4180 Fusion-Task-Lineage: 9e7e3a32-f2da-4c86-871b-37891f747561
This commit is contained in:
127
packages/dashboard/src/__tests__/chat-room-compaction.test.ts
Normal file
127
packages/dashboard/src/__tests__/chat-room-compaction.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCompactedRoomTranscript } from "../chat.js";
|
||||
|
||||
function makeMessage(index: number, overrides: Partial<{
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
createdAt: string;
|
||||
senderAgentId: string | null;
|
||||
}> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? `msg-${index}`,
|
||||
role: overrides.role ?? (index % 2 === 0 ? "user" : "assistant"),
|
||||
content: overrides.content ?? `message-${index}`,
|
||||
createdAt: overrides.createdAt ?? `2026-01-01T00:00:${String(index).padStart(2, "0")}.000Z`,
|
||||
senderAgentId: "senderAgentId" in overrides ? (overrides.senderAgentId ?? null) : (index % 2 === 0 ? null : "agent-a"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildCompactedRoomTranscript", () => {
|
||||
it("returns all messages verbatim when the transcript fits inside the recent window", () => {
|
||||
const messages = Array.from({ length: 6 }, (_, index) => makeMessage(index));
|
||||
|
||||
const transcript = buildCompactedRoomTranscript(messages, "msg-4");
|
||||
|
||||
expect(transcript).not.toContain("## Earlier room context (compacted)");
|
||||
expect(transcript).toContain("message-0");
|
||||
expect(transcript).toContain("message-5");
|
||||
expect(transcript.match(/\[LATEST USER MESSAGE — ANSWER THIS\]/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("prepends a compacted summary and keeps the last 12 messages verbatim", () => {
|
||||
const messages = Array.from({ length: 30 }, (_, index) => {
|
||||
const olderUserLengths = [40, 80, 120, 160, 200, 220, 60, 70, 90];
|
||||
const content = index < 18 && index % 2 === 0
|
||||
? `older-user-${index}-` + "u".repeat(olderUserLengths[index / 2] ?? 20)
|
||||
: `message-${index}`;
|
||||
return makeMessage(index, { content });
|
||||
});
|
||||
const latestUserMessageId = "msg-28";
|
||||
|
||||
const transcript = buildCompactedRoomTranscript(messages, latestUserMessageId);
|
||||
|
||||
expect(transcript).toContain("## Earlier room context (compacted)");
|
||||
expect(transcript).toContain("- Span: 18 messages from 2026-01-01T00:00:00.000Z to 2026-01-01T00:00:17.000Z");
|
||||
expect(transcript).toContain("- Participants: User, Agent agent-a");
|
||||
const [summaryBlock] = transcript.split("\n\n");
|
||||
const highlightLines = summaryBlock.split("\n").filter((line) => line.startsWith(" - "));
|
||||
expect(highlightLines).toHaveLength(5);
|
||||
const highlightTimestamps = highlightLines.map((line) => line.match(/\[(.*?)\]/)?.[1] ?? "");
|
||||
expect(highlightTimestamps).toEqual([...highlightTimestamps].sort());
|
||||
|
||||
for (let index = 18; index < 30; index += 1) {
|
||||
expect(transcript).toContain(`- [2026-01-01T00:00:${String(index).padStart(2, "0")}.000Z]`);
|
||||
}
|
||||
expect(transcript).toContain("(user) User: message-28 [LATEST USER MESSAGE — ANSWER THIS]");
|
||||
});
|
||||
|
||||
it("preserves the latest marker exactly once even when the transcript must shrink", () => {
|
||||
const messages = Array.from({ length: 30 }, (_, index) => makeMessage(index, {
|
||||
role: index === 29 ? "user" : (index % 3 === 0 ? "assistant" : "user"),
|
||||
senderAgentId: index % 3 === 0 ? "agent-a" : null,
|
||||
content: `message-${index}-` + "x".repeat(1500),
|
||||
}));
|
||||
|
||||
const transcript = buildCompactedRoomTranscript(messages, "msg-29");
|
||||
|
||||
expect(transcript.length).toBeLessThanOrEqual(8000);
|
||||
expect(transcript.match(/\[LATEST USER MESSAGE — ANSWER THIS\]/g)).toHaveLength(1);
|
||||
expect(transcript).toContain("message-29-");
|
||||
});
|
||||
|
||||
it("drops summary highlights from the bottom when the summary exceeds its cap", () => {
|
||||
const olderMessages = Array.from({ length: 18 }, (_, index) => makeMessage(index, {
|
||||
role: "user",
|
||||
content: `older-${index}-` + "z".repeat(500),
|
||||
}));
|
||||
const recentMessages = Array.from({ length: 12 }, (_, index) => makeMessage(index + 18, {
|
||||
role: index === 11 ? "user" : "assistant",
|
||||
senderAgentId: index === 11 ? null : `agent-${index}`,
|
||||
content: `recent-${index}`,
|
||||
}));
|
||||
|
||||
const transcript = buildCompactedRoomTranscript([...olderMessages, ...recentMessages], "msg-29");
|
||||
const [summaryBlock] = transcript.split("\n\n");
|
||||
const highlightLines = summaryBlock.split("\n").filter((line) => line.startsWith(" - "));
|
||||
|
||||
expect(summaryBlock).toContain("## Earlier room context (compacted)");
|
||||
expect(summaryBlock).toContain("- Span: 18 messages");
|
||||
expect(summaryBlock).toContain("- Participants: User");
|
||||
expect(summaryBlock).toContain("- Highlights:");
|
||||
expect(summaryBlock.length).toBeLessThanOrEqual(1500);
|
||||
expect(highlightLines.length).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it("keeps the total transcript under the overall cap", () => {
|
||||
const messages = Array.from({ length: 80 }, (_, index) => makeMessage(index, {
|
||||
role: index === 79 ? "user" : (index % 4 === 0 ? "system" : index % 2 === 0 ? "assistant" : "user"),
|
||||
senderAgentId: index % 2 === 0 && index % 4 !== 0 ? `agent-${index}` : null,
|
||||
content: `message-${index}-` + "q".repeat(4000),
|
||||
}));
|
||||
|
||||
const transcript = buildCompactedRoomTranscript(messages, "msg-79");
|
||||
|
||||
expect(transcript.length).toBeLessThanOrEqual(8000);
|
||||
expect(transcript).toContain("message-79-");
|
||||
});
|
||||
|
||||
it("computes unique participant labels from older messages", () => {
|
||||
const older = [
|
||||
makeMessage(0, { role: "user", senderAgentId: null, content: "user older" }),
|
||||
makeMessage(1, { role: "assistant", senderAgentId: "agent-a", content: "agent a older" }),
|
||||
makeMessage(2, { role: "system", senderAgentId: null, content: "system older" }),
|
||||
makeMessage(3, { role: "assistant", senderAgentId: null, content: "assistant older" }),
|
||||
makeMessage(4, { role: "assistant", senderAgentId: "agent-b", content: "agent b older" }),
|
||||
];
|
||||
const recent = Array.from({ length: 12 }, (_, index) => makeMessage(index + 5, {
|
||||
role: index === 11 ? "user" : "assistant",
|
||||
senderAgentId: index === 11 ? null : "agent-c",
|
||||
content: `recent-${index}`,
|
||||
}));
|
||||
|
||||
const transcript = buildCompactedRoomTranscript([...older, ...recent], "msg-16");
|
||||
|
||||
expect(transcript).toContain("- Participants: User, Agent agent-a, System, Assistant, Agent agent-b");
|
||||
});
|
||||
});
|
||||
@@ -205,7 +205,7 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
expect(mockChatStore.getRoomMessages).toHaveBeenCalledWith("room-1", { limit: expect.any(Number) });
|
||||
});
|
||||
|
||||
it("trims older room context entries from the prompt window", async () => {
|
||||
it("compacts older room context entries in the responder prompt", async () => {
|
||||
mockChatStore.listRoomMembers.mockReturnValue([
|
||||
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },
|
||||
]);
|
||||
@@ -213,6 +213,11 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
mockAgentStore.getAgent.mockResolvedValue({ id: "agent-a", name: "Alpha", role: "executor" });
|
||||
|
||||
const promptSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockChatStore.addRoomMessage.mockImplementationOnce((_roomId: string, input: any) => ({
|
||||
id: "history-latest",
|
||||
roomId: "room-1",
|
||||
...input,
|
||||
}));
|
||||
__setCreateResolvedAgentSession(async () => ({
|
||||
session: {
|
||||
prompt: promptSpy,
|
||||
@@ -232,7 +237,7 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
}));
|
||||
history[history.length - 1] = {
|
||||
...history[history.length - 1],
|
||||
id: "msg-1",
|
||||
id: "history-latest",
|
||||
role: "user",
|
||||
senderAgentId: null,
|
||||
content: "hello @Alpha",
|
||||
@@ -246,8 +251,13 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
|
||||
await manager.sendRoomMessage("room-1", "hello @Alpha");
|
||||
|
||||
const prompt = promptSpy.mock.calls[0]?.[0] as string;
|
||||
expect(prompt).toContain("## Earlier room context (compacted)");
|
||||
expect(prompt).toContain("- Span: 18 messages from 2026-01-01T00:00:00.000Z to 2026-01-01T00:00:17.000Z");
|
||||
expect(prompt).toContain("history-item-28");
|
||||
expect(prompt).not.toContain("history-item-0");
|
||||
expect(prompt).toContain(" - [2026-01-01T00:00:00.000Z] User: history-item-0");
|
||||
expect(prompt).not.toContain("- [2026-01-01T00:00:00.000Z] (user) User: history-item-0");
|
||||
expect(prompt).toContain("- [2026-01-01T00:00:29.000Z] (user) User: hello @Alpha [LATEST USER MESSAGE — ANSWER THIS]");
|
||||
expect(prompt.match(/\[LATEST USER MESSAGE — ANSWER THIS\]/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws surfaced error when room has members but no resolvable responders", async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ChatAttachment,
|
||||
ChatInFlightGenerationState,
|
||||
ChatStore,
|
||||
ChatRoomMessage,
|
||||
ChatSession,
|
||||
ChatSessionCreateInput,
|
||||
MessageStore,
|
||||
@@ -137,11 +138,126 @@ 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;
|
||||
const ROOM_THREAD_CONTEXT_MAX_MESSAGES = 16;
|
||||
const ROOM_THREAD_RECENT_VERBATIM_MESSAGES = 12;
|
||||
const ROOM_THREAD_COMPACTION_FETCH_LIMIT = 80;
|
||||
const ROOM_THREAD_CONTEXT_MAX_CHARS = 8_000;
|
||||
const ROOM_THREAD_MESSAGE_CONTENT_MAX_CHARS = 1_200;
|
||||
const ROOM_THREAD_SUMMARY_MAX_CHARS = 1_500;
|
||||
const IN_FLIGHT_PERSIST_DEBOUNCE_MS = 200;
|
||||
|
||||
type RoomTranscriptMessage = Pick<ChatRoomMessage, "id" | "role" | "content" | "createdAt" | "senderAgentId">;
|
||||
|
||||
function getRoomSenderLabel(message: Pick<RoomTranscriptMessage, "role" | "senderAgentId">): string {
|
||||
return message.role === "user"
|
||||
? "User"
|
||||
: message.role === "system"
|
||||
? "System"
|
||||
: (message.senderAgentId ? `Agent ${message.senderAgentId}` : "Assistant");
|
||||
}
|
||||
|
||||
function truncateWithEllipsis(content: string, maxChars: number): string {
|
||||
return content.length > maxChars
|
||||
? `${content.slice(0, maxChars - 1)}…`
|
||||
: content;
|
||||
}
|
||||
|
||||
function formatRoomThreadLine(message: RoomTranscriptMessage, latestUserMessageId: string): string {
|
||||
const marker = message.id === latestUserMessageId ? " [LATEST USER MESSAGE — ANSWER THIS]" : "";
|
||||
return `- [${message.createdAt}] (${message.role}) ${getRoomSenderLabel(message)}: ${truncateWithEllipsis(message.content, ROOM_THREAD_MESSAGE_CONTENT_MAX_CHARS)}${marker}`;
|
||||
}
|
||||
|
||||
function formatRoomThreadContext(messages: RoomTranscriptMessage[], latestUserMessageId: string): string {
|
||||
return messages.map((message) => formatRoomThreadLine(message, latestUserMessageId)).join("\n");
|
||||
}
|
||||
|
||||
function buildRoomSummaryBlock(olderMessages: RoomTranscriptMessage[]): string {
|
||||
if (olderMessages.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const participants = Array.from(new Set(olderMessages.map((message) => getRoomSenderLabel(message))));
|
||||
const rankedHighlights = olderMessages
|
||||
.map((message, index) => ({
|
||||
message,
|
||||
index,
|
||||
score: (message.role === "user" ? 2 : message.role === "assistant" ? 1 : 0) * 1000 + message.content.length,
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score || left.index - right.index)
|
||||
.slice(0, 5)
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map(({ message }) => ` - [${message.createdAt}] ${getRoomSenderLabel(message)}: ${truncateWithEllipsis(message.content, 240)}`);
|
||||
|
||||
const summaryLines = [
|
||||
"## Earlier room context (compacted)",
|
||||
`- Span: ${olderMessages.length} messages from ${olderMessages[0]?.createdAt ?? ""} to ${olderMessages.at(-1)?.createdAt ?? ""}`,
|
||||
`- Participants: ${participants.join(", ")}`,
|
||||
"- Highlights:",
|
||||
];
|
||||
|
||||
const baseSummary = summaryLines.join("\n");
|
||||
if (rankedHighlights.length === 0) {
|
||||
return baseSummary;
|
||||
}
|
||||
|
||||
const highlights = [...rankedHighlights];
|
||||
while (`${baseSummary}\n${highlights.join("\n")}`.length > ROOM_THREAD_SUMMARY_MAX_CHARS && highlights.length > 0) {
|
||||
highlights.pop();
|
||||
}
|
||||
|
||||
return highlights.length > 0
|
||||
? `${baseSummary}\n${highlights.join("\n")}`
|
||||
: baseSummary;
|
||||
}
|
||||
|
||||
export function buildCompactedRoomTranscript(
|
||||
messages: RoomTranscriptMessage[],
|
||||
latestUserMessageId: string,
|
||||
): string {
|
||||
if (messages.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const messageIndexes = new Map(messages.map((message, index) => [message.id, index]));
|
||||
const latestUserMessage = messages.find((message) => message.id === latestUserMessageId);
|
||||
const splitIndex = Math.max(0, messages.length - ROOM_THREAD_RECENT_VERBATIM_MESSAGES);
|
||||
let olderMessages = messages.slice(0, splitIndex);
|
||||
let recentMessages = messages.slice(splitIndex);
|
||||
|
||||
if (latestUserMessage && !recentMessages.some((message) => message.id === latestUserMessageId)) {
|
||||
olderMessages = olderMessages.filter((message) => message.id !== latestUserMessageId);
|
||||
recentMessages = [...recentMessages, latestUserMessage]
|
||||
.sort((left, right) => (messageIndexes.get(left.id) ?? 0) - (messageIndexes.get(right.id) ?? 0));
|
||||
}
|
||||
|
||||
const summaryLines = buildRoomSummaryBlock(olderMessages).split("\n").filter((line) => line.length > 0);
|
||||
|
||||
const renderTranscript = () => {
|
||||
const summary = summaryLines.length > 0 ? summaryLines.join("\n") : "";
|
||||
const recent = formatRoomThreadContext(recentMessages, latestUserMessageId);
|
||||
if (summary && recent) {
|
||||
return `${summary}\n\n${recent}`;
|
||||
}
|
||||
return summary || recent;
|
||||
};
|
||||
|
||||
let transcript = renderTranscript();
|
||||
while (transcript.length > ROOM_THREAD_CONTEXT_MAX_CHARS && summaryLines.at(-1)?.startsWith(" - ")) {
|
||||
summaryLines.pop();
|
||||
transcript = renderTranscript();
|
||||
}
|
||||
|
||||
while (transcript.length > ROOM_THREAD_CONTEXT_MAX_CHARS && recentMessages.length > 1) {
|
||||
const removableIndex = recentMessages.findIndex((message) => message.id !== latestUserMessageId);
|
||||
if (removableIndex === -1) {
|
||||
break;
|
||||
}
|
||||
recentMessages.splice(removableIndex, 1);
|
||||
transcript = renderTranscript();
|
||||
}
|
||||
|
||||
return transcript;
|
||||
}
|
||||
|
||||
function formatAttachmentSize(size: number): string {
|
||||
if (size < 1024) return `${size}B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`;
|
||||
@@ -1072,12 +1188,12 @@ export class ChatManager {
|
||||
}
|
||||
systemPrompt = `${systemPrompt}\n\n${CHAT_AGENT_MESSAGE_ROUTING_GUIDANCE}`;
|
||||
|
||||
const roomMessages = this.chatStore.getRoomMessages(input.roomId, { limit: ROOM_THREAD_CONTEXT_MAX_MESSAGES });
|
||||
const roomMessages = this.chatStore.getRoomMessages(input.roomId, { limit: ROOM_THREAD_COMPACTION_FETCH_LIMIT });
|
||||
const roomPrompt = [
|
||||
`You are replying as ${input.responder.name} in room #${input.roomName}.`,
|
||||
"Reply to the latest user room message in the context of this shared room thread.",
|
||||
"Room transcript (oldest to newest, bounded):",
|
||||
this.formatRoomThreadContext(roomMessages, input.latestUserMessageId),
|
||||
this.compactRoomThreadContext(roomMessages, input.latestUserMessageId),
|
||||
"Latest user message to answer:",
|
||||
input.content,
|
||||
].join("\n\n");
|
||||
@@ -1147,35 +1263,15 @@ export class ChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
private formatRoomThreadContext(
|
||||
messages: Array<{ id: string; role: "user" | "assistant" | "system"; content: string; createdAt: string; senderAgentId?: string | null }>,
|
||||
/**
|
||||
* Preserve the newest room turns verbatim while compacting older history into
|
||||
* a deterministic summary block so long-running rooms keep continuity.
|
||||
*/
|
||||
private compactRoomThreadContext(
|
||||
messages: RoomTranscriptMessage[],
|
||||
latestUserMessageId: string,
|
||||
): string {
|
||||
const trimmedFromTail: string[] = [];
|
||||
let totalChars = 0;
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const senderLabel = message.role === "user"
|
||||
? "User"
|
||||
: message.role === "system"
|
||||
? "System"
|
||||
: (message.senderAgentId ? `Agent ${message.senderAgentId}` : "Assistant");
|
||||
const content = message.content.length > ROOM_THREAD_MESSAGE_CONTENT_MAX_CHARS
|
||||
? `${message.content.slice(0, ROOM_THREAD_MESSAGE_CONTENT_MAX_CHARS - 1)}…`
|
||||
: message.content;
|
||||
const marker = message.id === latestUserMessageId ? " [LATEST USER MESSAGE — ANSWER THIS]" : "";
|
||||
const line = `- [${message.createdAt}] (${message.role}) ${senderLabel}: ${content}${marker}`;
|
||||
|
||||
if (trimmedFromTail.length > 0 && totalChars + line.length > ROOM_THREAD_CONTEXT_MAX_CHARS) {
|
||||
break;
|
||||
}
|
||||
|
||||
trimmedFromTail.push(line);
|
||||
totalChars += line.length;
|
||||
}
|
||||
|
||||
return trimmedFromTail.reverse().join("\n");
|
||||
return buildCompactedRoomTranscript(messages, latestUserMessageId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -173,6 +173,10 @@ function cleanupTempDir(dir?: string): void {
|
||||
|
||||
sleepSync(attempt * 25);
|
||||
}
|
||||
|
||||
if (existsSync(dir)) {
|
||||
throw new Error(`failed to clean temp dir: ${dir}`);
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -87,19 +87,29 @@ const STUB_SETTINGS = {
|
||||
|
||||
const createdDirs = new Set<string>();
|
||||
|
||||
function sleepSync(ms: number): void {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function cleanupTempDir(dir?: string): void {
|
||||
if (!dir) return;
|
||||
createdDirs.delete(dir);
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||
try {
|
||||
if (!existsSync(dir)) return;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
return;
|
||||
if (!existsSync(dir)) return;
|
||||
} catch (error) {
|
||||
if (attempt === 3) {
|
||||
if (attempt === 5) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
sleepSync(attempt * 25);
|
||||
}
|
||||
|
||||
if (existsSync(dir)) {
|
||||
throw new Error(`failed to clean temp dir: ${dir}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user