feat(chat): multi-tab coordination via BroadcastChannel

This commit is contained in:
Eric Allam
2026-04-17 16:18:47 +01:00
parent 964794a447
commit 29097d7d08
7 changed files with 639 additions and 5 deletions
+63
View File
@@ -103,9 +103,72 @@ export function useTriggerChatTransport<TTask extends AnyTask = AnyTask>(
ref.current?.setTriggerTask(triggerTask);
}, [triggerTask]);
// Note: dispose() is NOT called in effect cleanup because React strict mode
// runs cleanup+re-setup, but the transport lives in a ref and isn't recreated.
// Calling dispose() would permanently close the BroadcastChannel.
// The coordinator's beforeunload handler handles tab close cleanup instead.
return ref.current;
}
/**
* Sync chat messages across browser tabs.
*
* Requires `multiTab: true` on the transport. Handles:
* - Tracking read-only state (`isReadOnly`) when another tab is active
* - Broadcasting messages from the active tab to other tabs
* - Receiving messages from other tabs and updating local state via `setMessages`
*
* @example
* ```tsx
* const transport = useTriggerChatTransport({ task: "my-chat", multiTab: true, accessToken });
* const { messages, setMessages } = useChat({ id: chatId, transport });
* const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages);
*
* <input disabled={isReadOnly} placeholder={isReadOnly ? "Active in another tab" : "Type a message..."} />
* ```
*/
export function useMultiTabChat<T = unknown>(
transport: TriggerChatTransport,
chatId: string,
messages: T[],
setMessages: (messages: T[]) => void
): { isReadOnly: boolean } {
const [isReadOnly, setIsReadOnly] = useState(() => transport.isReadOnly(chatId));
// Track read-only state
useEffect(() => {
const listener = (id: string, readOnly: boolean) => {
if (id === chatId) setIsReadOnly(readOnly);
};
transport.addReadOnlyListener(listener);
setIsReadOnly(transport.isReadOnly(chatId));
return () => transport.removeReadOnlyListener(listener);
}, [transport, chatId]);
// Active tab: broadcast messages to other tabs on change.
// Only broadcast when THIS tab holds the claim (is the current sender).
// Using !isReadOnly alone causes a feedback loop when both tabs are idle.
useEffect(() => {
if (transport.hasClaim(chatId) && messages.length > 0) {
transport.broadcastMessages(chatId, messages as unknown[]);
}
}, [transport, chatId, messages]);
// Read-only tab: receive messages from the active tab
useEffect(() => {
const listener = (id: string, msgs: unknown[]) => {
if (id === chatId) {
setMessages(msgs as T[]);
}
};
transport.addMessagesListener(listener);
return () => transport.removeMessagesListener(listener);
}, [transport, chatId, setMessages]);
return { isReadOnly };
}
// ---------------------------------------------------------------------------
// usePendingMessages — manage steering messages during streaming
// ---------------------------------------------------------------------------
@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ChatTabCoordinator } from "./chat-tab-coordinator.js";
// Mock BroadcastChannel for testing
class MockBroadcastChannel {
static instances: MockBroadcastChannel[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
closed = false;
constructor(public name: string) {
MockBroadcastChannel.instances.push(this);
}
postMessage(data: unknown): void {
if (this.closed) return;
// Deliver to all OTHER instances on the same channel
for (const instance of MockBroadcastChannel.instances) {
if (instance !== this && instance.name === this.name && !instance.closed) {
instance.onmessage?.({ data } as MessageEvent);
}
}
}
close(): void {
this.closed = true;
MockBroadcastChannel.instances = MockBroadcastChannel.instances.filter((i) => i !== this);
}
}
describe("ChatTabCoordinator", () => {
beforeEach(() => {
MockBroadcastChannel.instances = [];
vi.stubGlobal("BroadcastChannel", MockBroadcastChannel);
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("tab A claims, tab B sees isReadOnly", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
expect(b.isReadOnly("chat-1")).toBe(false);
a.claim("chat-1");
expect(b.isReadOnly("chat-1")).toBe(true);
expect(a.isReadOnly("chat-1")).toBe(false); // Owner is not read-only
a.dispose();
b.dispose();
});
it("tab A releases, tab B sees isReadOnly = false", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
a.claim("chat-1");
expect(b.isReadOnly("chat-1")).toBe(true);
a.release("chat-1");
expect(b.isReadOnly("chat-1")).toBe(false);
a.dispose();
b.dispose();
});
it("fires listener on claim and release", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
const listener = vi.fn();
b.addListener(listener);
a.claim("chat-1");
expect(listener).toHaveBeenCalledWith("chat-1", true);
a.release("chat-1");
expect(listener).toHaveBeenCalledWith("chat-1", false);
a.dispose();
b.dispose();
});
it("removeListener stops notifications", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
const listener = vi.fn();
b.addListener(listener);
b.removeListener(listener);
a.claim("chat-1");
expect(listener).not.toHaveBeenCalled();
a.dispose();
b.dispose();
});
it("claim returns false when another tab holds the chatId", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
expect(a.claim("chat-1")).toBe(true);
expect(b.claim("chat-1")).toBe(false);
a.dispose();
b.dispose();
});
it("supports multiple independent chatIds", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
a.claim("chat-1");
b.claim("chat-2");
expect(a.isReadOnly("chat-1")).toBe(false);
expect(a.isReadOnly("chat-2")).toBe(true);
expect(b.isReadOnly("chat-1")).toBe(true);
expect(b.isReadOnly("chat-2")).toBe(false);
a.dispose();
b.dispose();
});
it("heartbeat timeout clears stale claim from crashed tab", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
const listener = vi.fn();
b.addListener(listener);
a.claim("chat-1");
expect(b.isReadOnly("chat-1")).toBe(true);
// Simulate tab A crashing (close its channel, stop heartbeats)
a.dispose();
// Advance past heartbeat timeout (10s)
vi.advanceTimersByTime(11_000);
expect(b.isReadOnly("chat-1")).toBe(false);
expect(listener).toHaveBeenCalledWith("chat-1", false);
b.dispose();
});
it("dispose releases all claims", () => {
const a = new ChatTabCoordinator();
const b = new ChatTabCoordinator();
a.claim("chat-1");
a.claim("chat-2");
expect(b.isReadOnly("chat-1")).toBe(true);
expect(b.isReadOnly("chat-2")).toBe(true);
a.dispose();
expect(b.isReadOnly("chat-1")).toBe(false);
expect(b.isReadOnly("chat-2")).toBe(false);
b.dispose();
});
it("gracefully degrades when BroadcastChannel is unavailable", () => {
vi.stubGlobal("BroadcastChannel", undefined);
const coord = new ChatTabCoordinator();
// All operations are no-ops
expect(coord.claim("chat-1")).toBe(true);
expect(coord.isReadOnly("chat-1")).toBe(false);
coord.release("chat-1"); // No error
coord.dispose(); // No error
});
});
@@ -0,0 +1,268 @@
/**
* Coordinates multi-tab access to chat sessions via BroadcastChannel.
*
* When multiple browser tabs open the same chat, only one can be the active
* sender. Others enter read-only mode. The coordinator uses a simple
* claim/release/heartbeat protocol to track ownership per chatId.
*
* Gracefully degrades to a no-op when BroadcastChannel is unavailable
* (SSR, Node.js, old browsers).
*
* @internal
*/
const CHANNEL_NAME = "trigger-chat-tab-coord";
const HEARTBEAT_INTERVAL_MS = 5_000;
const HEARTBEAT_TIMEOUT_MS = 10_000;
type TabMessage =
| { type: "claim"; chatId: string; tabId: string }
| { type: "release"; chatId: string; tabId: string }
| { type: "heartbeat"; chatId: string; tabId: string }
| { type: "messages"; chatId: string; tabId: string; messages: unknown[] }
| { type: "session"; chatId: string; tabId: string; session: { lastEventId?: string } };
type ReadOnlyListener = (chatId: string, isReadOnly: boolean) => void;
type MessagesListener = (chatId: string, messages: unknown[]) => void;
type SessionListener = (chatId: string, session: { lastEventId?: string }) => void;
export class ChatTabCoordinator {
private tabId: string;
private channel: BroadcastChannel | null = null;
/** Claims held by OTHER tabs: chatId -> { tabId, lastSeen } */
private claims = new Map<string, { tabId: string; lastSeen: number }>();
/** chatIds that THIS tab has claimed */
private myClaims = new Set<string>();
private listeners = new Set<ReadOnlyListener>();
private messagesListeners = new Set<MessagesListener>();
private sessionListeners = new Set<SessionListener>();
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private beforeUnloadHandler: (() => void) | null = null;
constructor() {
this.tabId =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
if (typeof BroadcastChannel === "undefined") {
return; // No-op mode
}
this.channel = new BroadcastChannel(CHANNEL_NAME);
this.channel.onmessage = (event: MessageEvent<TabMessage>) => {
this.handleMessage(event.data);
};
// Heartbeat: send for our claims + check for stale claims from other tabs
this.heartbeatTimer = setInterval(() => {
this.sendHeartbeats();
this.expireStaleClaimsFromOtherTabs();
}, HEARTBEAT_INTERVAL_MS);
// Best-effort release on tab close
this.beforeUnloadHandler = () => this.releaseAll();
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", this.beforeUnloadHandler);
}
}
/**
* Attempt to claim a chatId for sending.
* Returns false if another tab already holds it.
*/
claim(chatId: string): boolean {
if (!this.channel) return true; // No-op mode
const existing = this.claims.get(chatId);
if (existing && existing.tabId !== this.tabId) {
return false; // Another tab holds this chat
}
this.myClaims.add(chatId);
this.broadcast({ type: "claim", chatId, tabId: this.tabId });
return true;
}
/** Release a chatId so other tabs can claim it. */
release(chatId: string): void {
if (!this.channel) return;
if (!this.myClaims.has(chatId)) return;
this.myClaims.delete(chatId);
this.broadcast({ type: "release", chatId, tabId: this.tabId });
}
/** Check if THIS tab currently holds a claim for the chatId. */
hasClaim(chatId: string): boolean {
return this.myClaims.has(chatId);
}
/** Check if another tab holds this chatId. */
isReadOnly(chatId: string): boolean {
if (!this.channel) return false;
const claim = this.claims.get(chatId);
return claim != null && claim.tabId !== this.tabId;
}
addListener(fn: ReadOnlyListener): void {
this.listeners.add(fn);
}
removeListener(fn: ReadOnlyListener): void {
this.listeners.delete(fn);
}
/** Broadcast the current messages to other tabs (for real-time sync). */
broadcastMessages(chatId: string, messages: unknown[]): void {
if (!this.channel) return;
this.broadcast({ type: "messages", chatId, tabId: this.tabId, messages });
}
addMessagesListener(fn: MessagesListener): void {
this.messagesListeners.add(fn);
}
removeMessagesListener(fn: MessagesListener): void {
this.messagesListeners.delete(fn);
}
/** Broadcast session state (lastEventId) to other tabs. */
broadcastSession(chatId: string, session: { lastEventId?: string }): void {
if (!this.channel) return;
this.broadcast({ type: "session", chatId, tabId: this.tabId, session });
}
addSessionListener(fn: SessionListener): void {
this.sessionListeners.add(fn);
}
removeSessionListener(fn: SessionListener): void {
this.sessionListeners.delete(fn);
}
/** Clean up channel, timers, and event listeners. */
dispose(): void {
this.releaseAll();
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
if (this.beforeUnloadHandler && typeof window !== "undefined") {
window.removeEventListener("beforeunload", this.beforeUnloadHandler);
this.beforeUnloadHandler = null;
}
if (this.channel) {
this.channel.close();
this.channel = null;
}
this.listeners.clear();
this.messagesListeners.clear();
this.sessionListeners.clear();
}
// --- Private ---
private handleMessage(msg: TabMessage): void {
if (msg.tabId === this.tabId) return; // Ignore own messages
switch (msg.type) {
case "claim": {
const wasReadOnly = this.isReadOnly(msg.chatId);
this.claims.set(msg.chatId, { tabId: msg.tabId, lastSeen: Date.now() });
if (!wasReadOnly) {
this.notify(msg.chatId, true);
}
break;
}
case "release": {
const claim = this.claims.get(msg.chatId);
if (claim && claim.tabId === msg.tabId) {
this.claims.delete(msg.chatId);
this.notify(msg.chatId, false);
}
break;
}
case "heartbeat": {
const claim = this.claims.get(msg.chatId);
if (claim && claim.tabId === msg.tabId) {
claim.lastSeen = Date.now();
}
break;
}
case "messages": {
this.notifyMessages(msg.chatId, msg.messages);
break;
}
case "session": {
this.notifySession(msg.chatId, msg.session);
break;
}
}
}
private sendHeartbeats(): void {
for (const chatId of this.myClaims) {
this.broadcast({ type: "heartbeat", chatId, tabId: this.tabId });
}
}
private expireStaleClaimsFromOtherTabs(): void {
const now = Date.now();
for (const [chatId, claim] of this.claims) {
if (claim.tabId !== this.tabId && now - claim.lastSeen > HEARTBEAT_TIMEOUT_MS) {
this.claims.delete(chatId);
this.notify(chatId, false);
}
}
}
private releaseAll(): void {
for (const chatId of [...this.myClaims]) {
this.release(chatId);
}
}
private broadcast(msg: TabMessage): void {
try {
this.channel?.postMessage(msg);
} catch {
// Channel may be closed
}
}
private notify(chatId: string, isReadOnly: boolean): void {
for (const fn of this.listeners) {
try {
fn(chatId, isReadOnly);
} catch {
// Non-fatal
}
}
}
private notifyMessages(chatId: string, messages: unknown[]): void {
for (const fn of this.messagesListeners) {
try {
fn(chatId, messages);
} catch {
// Non-fatal
}
}
}
private notifySession(chatId: string, session: { lastEventId?: string }): void {
for (const fn of this.sessionListeners) {
try {
fn(chatId, session);
} catch {
// Non-fatal
}
}
}
}
+117
View File
@@ -37,6 +37,7 @@ function isRunPatAuthError(error: unknown): boolean {
return e.name === "TriggerApiError" && (e.status === 401 || e.status === 403);
}
import { CHAT_MESSAGES_STREAM_ID, CHAT_STOP_STREAM_ID } from "./chat-constants.js";
import { ChatTabCoordinator } from "./chat-tab-coordinator.js";
const DEFAULT_STREAM_KEY = "chat";
const DEFAULT_BASE_URL = "https://api.trigger.dev";
@@ -258,6 +259,20 @@ type TriggerChatTransportOptionsBase<TClientData = unknown> = {
params: RenewRunAccessTokenParams
) => string | undefined | null | Promise<string | undefined | null>;
/**
* Enable multi-tab coordination. When `true`, only one browser tab
* can send messages to a given chatId at a time. Other tabs enter
* read-only mode. Uses `BroadcastChannel` for cross-tab communication.
*
* Use `transport.isReadOnly(chatId)` or the `useChatTabCoordination` hook
* to check read-only state and disable the input UI.
*
* No-op when `BroadcastChannel` is unavailable (SSR, Node.js).
*
* @default false
*/
multiTab?: boolean;
/**
* Read-only "watch" mode for observing an existing chat run from the
* outside (e.g. a dashboard viewer that wants to show an agent run's
@@ -384,6 +399,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
private readonly defaultMetadata: Record<string, unknown> | undefined;
private readonly triggerOptions: TriggerChatTransportOptions["triggerOptions"];
private readonly watchMode: boolean;
private coordinator: ChatTabCoordinator | null = null;
private _onSessionChange:
| ((
chatId: string,
@@ -423,6 +439,19 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
this.renewRunAccessToken = options.renewRunAccessToken;
this.watchMode = options.watch ?? false;
if (options.multiTab && !this.watchMode) {
this.coordinator = new ChatTabCoordinator();
// Sync session state (lastEventId) from other tabs so this tab
// doesn't replay old SSE events when it takes over sending.
this.coordinator.addSessionListener((chatId, sessionUpdate) => {
const session = this.sessions.get(chatId);
if (session && sessionUpdate.lastEventId) {
session.lastEventId = sessionUpdate.lastEventId;
}
});
}
// Restore sessions from external storage
if (options.sessions) {
for (const [chatId, session] of Object.entries(options.sessions)) {
@@ -447,6 +476,14 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
): Promise<ReadableStream<UIMessageChunk>> => {
const { trigger, chatId, messageId, messages, abortSignal, body, metadata } = options;
// Multi-tab coordination: prevent sending from a read-only tab
if (this.coordinator) {
if (this.coordinator.isReadOnly(chatId)) {
throw new Error("This chat is active in another tab");
}
this.coordinator.claim(chatId);
}
const mergedMetadata =
this.defaultMetadata || metadata
? { ...(this.defaultMetadata ?? {}), ...((metadata as Record<string, unknown>) ?? {}) }
@@ -501,6 +538,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
} else {
previousRunId = session.runId;
this.sessions.delete(chatId);
this.coordinator?.release(chatId);
this.notifySessionChange(chatId, null);
isContinuation = true;
}
@@ -739,6 +777,14 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
chatId: string,
action: unknown
): Promise<ReadableStream<UIMessageChunk>> => {
// Multi-tab coordination: prevent sending from a read-only tab
if (this.coordinator) {
if (this.coordinator.isReadOnly(chatId)) {
throw new Error("This chat is active in another tab");
}
this.coordinator.claim(chatId);
}
const session = this.sessions.get(chatId);
if (session?.runId) {
@@ -844,6 +890,59 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
this.triggerTaskFn = fn;
}
/**
* Check if a chatId is read-only (another tab is actively sending).
* Always returns `false` when `multiTab` is not enabled.
*/
isReadOnly(chatId: string): boolean {
return this.coordinator?.isReadOnly(chatId) ?? false;
}
/**
* Check if THIS tab currently holds a claim for the chatId
* (i.e. this tab called sendMessages and the turn is in-flight).
*/
hasClaim(chatId: string): boolean {
return this.coordinator?.hasClaim(chatId) ?? false;
}
/**
* Listen for read-only state changes across tabs.
* The listener receives `(chatId, isReadOnly)`.
*/
addReadOnlyListener(fn: (chatId: string, isReadOnly: boolean) => void): void {
this.coordinator?.addListener(fn);
}
/** Remove a previously added read-only listener. */
removeReadOnlyListener(fn: (chatId: string, isReadOnly: boolean) => void): void {
this.coordinator?.removeListener(fn);
}
/** Broadcast messages to other tabs for real-time sync. */
broadcastMessages(chatId: string, messages: unknown[]): void {
this.coordinator?.broadcastMessages(chatId, messages);
}
/** Listen for message updates from other tabs. */
addMessagesListener(fn: (chatId: string, messages: unknown[]) => void): void {
this.coordinator?.addMessagesListener(fn);
}
/** Remove a previously added messages listener. */
removeMessagesListener(fn: (chatId: string, messages: unknown[]) => void): void {
this.coordinator?.removeMessagesListener(fn);
}
/**
* Clean up resources (BroadcastChannel, heartbeat timers).
* Call on component unmount when using `multiTab`.
*/
dispose(): void {
this.coordinator?.dispose();
this.coordinator = null;
}
/**
* Inject or update a session for a chat. Useful for resuming conversations
* from persisted state without recreating the transport.
@@ -1175,6 +1274,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
// Clear session so triggerNewRun creates a fresh one
this.sessions.delete(chatId);
this.coordinator?.release(chatId);
this.notifySessionChange(chatId, null);
try {
@@ -1233,6 +1333,17 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
this.notifySessionChange(chatId, session);
}
// Release multi-tab claim so other tabs can send
this.coordinator?.release(chatId);
// Broadcast session to other tabs so they have the latest
// lastEventId (prevents replaying old SSE events on next send)
if (session) {
this.coordinator?.broadcastSession(chatId, {
lastEventId: session.lastEventId,
});
}
// Watch mode: keep the subscription open across turn
// boundaries so the consumer sees turn 2, 3, etc. through
// a single long-lived ReadableStream. Filter the control
@@ -1269,6 +1380,12 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
}
controller.error(error);
} finally {
// Release multi-tab claim when the stream closes for any reason
// (turn-complete, error, abort, stream end)
if (chatId) {
this.coordinator?.release(chatId);
}
}
},
});
@@ -63,6 +63,7 @@ export function ChatView({
sessions,
onSessionChange: handleSessionChange,
clientData: { userId: "user_123" },
multiTab: true,
triggerOptions: {
tags: ["user:user_123"],
},
+13 -4
View File
@@ -8,7 +8,7 @@ import {
import type { ChatUiMessage } from "@/lib/chat-tools";
import type { TriggerChatTransport } from "@trigger.dev/sdk/chat";
import type { CompactionChunkData } from "@trigger.dev/sdk/ai";
import { usePendingMessages } from "@trigger.dev/sdk/chat/react";
import { usePendingMessages, useMultiTabChat } from "@trigger.dev/sdk/chat/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { MODEL_OPTIONS } from "@/lib/models";
@@ -353,6 +353,9 @@ export function Chat({
lastAssistantMessageIsCompleteWithToolCalls(opts),
});
// Multi-tab coordination: sync messages between tabs
const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages);
// Use transport.stopGeneration for reliable stop after reconnect.
// Once the AI SDK passes abortSignal through reconnectToStream,
// aiStop() alone will suffice. Until then, this covers both cases.
@@ -768,17 +771,23 @@ export function Chat({
}}
className="shrink-0 border-t border-gray-200 bg-white p-4"
>
{isReadOnly && (
<div className="mb-2 rounded border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-700">
This chat is active in another tab. Messages are read-only.
</div>
)}
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
placeholder={isReadOnly ? "Chat is active in another tab" : "Type a message..."}
disabled={isReadOnly}
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400"
/>
<button
type="submit"
disabled={!input.trim()}
disabled={!input.trim() || isReadOnly}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Send
+1 -1
View File
@@ -176,7 +176,7 @@ export const aiChat = chat
.agent({
id: "ai-chat",
idleTimeoutInSeconds: 60,
chatAccessTokenTTL: "1m",
chatAccessTokenTTL: "1h",
// #region Compaction — automatic context window management
compaction: {