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:
7
.changeset/fn-4180-room-context-compaction.md
Normal file
7
.changeset/fn-4180-room-context-compaction.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Chat rooms now intelligently compact older messages into a summary header
|
||||
when transcripts exceed the verbatim window, preserving long-running
|
||||
context for agent replies instead of silently dropping earlier turns.
|
||||
@@ -287,6 +287,7 @@ Intentional exclusions from shared snapshots:
|
||||
- The hook subscribes to `/api/events` and consumes `chat:room:created`, `chat:room:updated`, `chat:room:deleted`, `chat:room:member:added`, `chat:room:member:removed`, `chat:room:message:added`, `chat:room:message:updated`, and `chat:room:message:deleted` to keep UI state in sync.
|
||||
- Room messages persist through `POST /api/chat/rooms/:id/messages`; the route persists the user message first, then calls `ChatManager.sendRoomMessage(...)` to orchestrate room-member responders and persist assistant room replies with `chatStore.addRoomMessage(...)` (including `senderAgentId` for each responder).
|
||||
- `sendRoomMessage(...)` uses existing room-member + mention resolution rules: mentioned members are direct responders, non-mentioned members are ambient responders (capped by `ROOM_AMBIENT_MAX_RESPONDERS`), and non-member mentions are handled explicitly by the manager instead of silently disappearing.
|
||||
- Room responder prompt context is compacted deterministically: the newest 12 room messages stay verbatim, while older fetched history is summarized into a structured header (span, participants, and ranked highlights) before prompt size caps are enforced.
|
||||
- Room-reply generation is now non-silent on failure: if a room has members but no active responders can be resolved, or all responder generations fail/return empty output, `sendRoomMessage(...)` throws `RoomReplyGenerationError` and the route surfaces HTTP 502 instead of returning a silent user-only success.
|
||||
- `useChatRooms.sendRoomMessage()` now follows direct-chat style optimistic UX: append a temporary local user room message before `POST /api/chat/rooms/:id/messages`, reconcile that temp entry to the persisted user message on success, then refresh authoritative transcript state while continuing `chat:room:message:*` live SSE updates.
|
||||
- On failures, `useChatRooms.sendRoomMessage()` performs state reconciliation (rollback temp entry or replace with persisted transcript when POST partially succeeded) and rethrows; `ChatView` owns the single user-facing toast and keeps composer text for retry.
|
||||
|
||||
@@ -119,7 +119,7 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are
|
||||
- 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(...)`.
|
||||
- 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 includes a bounded recent room transcript (role, sender label, timestamp, content) plus an explicit latest-user-message marker, so replies stay thread-aware without unbounded prompt growth.
|
||||
- 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.
|
||||
- On send failure, `useChatRooms` rolls back/reconciles optimistic state and rethrows; `ChatView` catches once, preserves composer text for retry/edit, and surfaces a single error toast (no duplicate hook+view notifications).
|
||||
- After each send attempt, the room transcript still re-fetches authoritative messages so persisted user/assistant replies remain visible even when SSE delivery is delayed, and `chat:room:message:*` SSE updates continue live fan-out.
|
||||
- Relationship summary: direct Chat runs one target (agent or model) per session; rooms are shared threads with multiple agent members and now use the same message contract as direct Chat; Quick Chat stays a floating single-target panel and does not host rooms.
|
||||
|
||||
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