FN-7909: add room-level thinking effort override for Chat Rooms

Adds a per-room thinking-effort (reasoning level) override for Chat Rooms so all room responders can share a consistent override instead of relying only on per-agent/global defaults.

- Persist `chat_rooms.thinkingLevel` with a new core DB migration and store read/write support
- Extend chat-store and chat-types with thinkingLevel plumbing for room create/update
- Wire the dashboard chat room API/routes and legacy handlers to accept and return thinkingLevel
- Add a ChatView room settings control (with CSS) and useChatRooms hook support for setting/clearing the override
- Resolve room responder defaultThinkingLevel from the room override when present
- Update docs (dashboard-guide, settings-reference) and add a minor changeset for the feature

Files changed:
 .changeset/fn-7909-room-thinking-level.md          |  7 +++
 docs/dashboard-guide.md                            |  1 +
 docs/settings-reference.md                         |  2 +-
 packages/core/src/__tests__/chat-store.test.ts     | 21 ++++++++
 packages/core/src/__tests__/db-migrate.test.ts     | 57 ++++++++++++++++++++++
 packages/core/src/chat-store.ts                    | 12 ++++-
 packages/core/src/chat-types.ts                    | 10 ++++
 packages/core/src/db.ts                            | 28 ++++++++++-
 packages/dashboard/app/api/__tests__/chat-rooms-api.test.ts       |  8 +--
 packages/dashboard/app/api/legacy.ts               |  4 +-
 packages/dashboard/app/components/ChatView.css     | 16 ++++++
 packages/dashboard/app/components/ChatView.tsx     | 29 ++++++++++-
 packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx   | 24 +++++++++
 packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts       | 22 +++++++++
 packages/dashboard/app/hooks/useChatRooms.ts       | 16 ++++++
 packages/dashboard/src/__tests__/chat-room-routes.test.ts         | 26 ++++++++++
 packages/dashboard/src/__tests__/chat.rooms.test.ts     | 42 ++++++++++++++++
 packages/dashboard/src/chat.ts                     |  8 +++
 packages/dashboard/src/routes/register-chat-room-routes.ts        | 21 ++++++--
 19 files changed, 338 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-7909

Fusion-Task-Lineage: 2741eca9-5305-4f6c-81bf-ae644a9fe307

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 22:12:18 -07:00
parent 4ea5dd6739
commit daf3f15fd1
19 changed files with 338 additions and 16 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a Chat Room thinking-effort override for all room responders.
category: feature
dev: Adds chat_rooms.thinkingLevel persistence, API/client wiring, and room responder defaultThinkingLevel resolution.

View File

@@ -571,6 +571,7 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are
- Each room row includes a trash action (`aria-label="Delete room {name}"`, `data-testid="chat-room-delete-{slug}"`) that opens a **Delete Room?** confirmation dialog with **Cancel** and **Delete** actions.
- Confirming delete calls `rooms.deleteRoom(roomId)` and permanently removes the room and its messages ("This action cannot be undone. This room and all its messages will be permanently deleted."); failures surface a `Failed to delete room` toast.
- Selecting a room opens the room thread pane with loading and empty states, then renders room messages from `rooms.messages` as `ChatMessageInfo` entries in the same thread UI used for direct Chat.
- The room header includes a **Thinking effort** selector with **Use default**, **off**, **minimal**, **low**, **medium**, **high**, and **Very High**. It stores one room-level default for every responder in that room; **Use default** clears the room override so responders inherit the resolved project/global reasoning-effort default. Per-member thinking overrides are not supported.
- Submitting the room composer calls `rooms.sendRoomMessage(...)`, which immediately inserts a temporary local user message and then posts to `POST /api/chat/rooms/:id/messages`.
- 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.

View File

@@ -985,7 +985,7 @@ Short-lived token bounds are enforced server-side:
Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. General-scope fallback selection remains the global Fallback Model picker in Settings -> General Models.
Direct-chat defaults are project-scoped and independent of task workflow lanes. Configure them in **Settings -> Project Models -> Chat**. `chatDefaultKind: "agent"` resolves only when `chatDefaultAgentId` is set; `chatDefaultKind: "model"` resolves only when both `chatDefaultModelProvider` and `chatDefaultModelId` are set, with optional `chatDefaultThinkingLevel`. If `chatNewSessionMode` is `"always-default"` and that target resolves, every New Chat entry point creates the session directly. If the target is incomplete, or the mode is unset/`"prompt"`, Fusion opens the New Chat dialog instead and preselects the resolved default when one exists.
Direct-chat defaults are project-scoped and independent of task workflow lanes. Configure them in **Settings -> Project Models -> Chat**. `chatDefaultKind: "agent"` resolves only when `chatDefaultAgentId` is set; `chatDefaultKind: "model"` resolves only when both `chatDefaultModelProvider` and `chatDefaultModelId` are set, with optional `chatDefaultThinkingLevel`. If `chatNewSessionMode` is `"always-default"` and that target resolves, every New Chat entry point creates the session directly. If the target is incomplete, or the mode is unset/`"prompt"`, Fusion opens the New Chat dialog instead and preselects the resolved default when one exists. Chat Rooms additionally support a per-room `thinkingLevel` default that applies to every room responder; clearing it inherits the resolved project/global default.
Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Primary workflow lanes declare `executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel` per `(workflow, project)`; planning/reviewer fallback lanes declare `planningFallbackThinkingLevel` and `validatorFallbackThinkingLevel`; global fallback uses `fallbackThinkingLevel`; and project title summarization fallback uses `titleSummarizerFallbackThinkingLevel`. Empty thinking values inherit through the lane/global/default chain and explicit values are cleared by the lane reset action. Runtime thinking precedence for task/workflow execution is node/step `config.thinkingLevel` > task `thinkingLevel` > workflow lane thinking override > global lane thinking override > project default thinking override > global `defaultThinkingLevel`. Model-mode Chat sessions use the same executor-lane resolver with session `thinkingLevel` in the task slot, so an empty chat-session value inherits project/global defaults while a concrete New Chat selection wins for that session. The resolved value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters).

View File

@@ -1132,6 +1132,27 @@ describe("ChatStore", () => {
expect(members.find((m) => m.agentId === "agent-owner")?.role).toBe("owner");
});
it("round-trips room thinkingLevel through accessors and update clears", () => {
const inheritedRoom = store.createRoom({ name: "inherit defaults", projectId: "proj-1" });
expect(inheritedRoom.thinkingLevel).toBeNull();
expect(store.getRoom(inheritedRoom.id)?.thinkingLevel).toBeNull();
const explicitRoom = store.createRoom({
name: "deep thinking",
projectId: "proj-1",
memberAgentIds: ["agent-1"],
thinkingLevel: "high",
});
expect(store.getRoom(explicitRoom.id)?.thinkingLevel).toBe("high");
expect(store.getRoomBySlug("proj-1", explicitRoom.slug)?.thinkingLevel).toBe("high");
expect(store.listRooms({ projectId: "proj-1" }).find((room) => room.id === explicitRoom.id)?.thinkingLevel).toBe("high");
expect(store.listRoomsForAgent("agent-1", { projectId: "proj-1" }).find((room) => room.id === explicitRoom.id)?.thinkingLevel).toBe("high");
expect(store.updateRoom(explicitRoom.id, { thinkingLevel: "minimal" })?.thinkingLevel).toBe("minimal");
expect(store.updateRoom(explicitRoom.id, { thinkingLevel: null })?.thinkingLevel).toBeNull();
});
it("rejects slug collision in same project and allows across projects", () => {
store.createRoom({ name: "engineering", projectId: "proj-1" });
expect(() => store.createRoom({ name: "#Engineering", projectId: "proj-1" })).toThrow(

View File

@@ -1382,6 +1382,63 @@ describe("schema migration", () => {
db.close();
});
it("adds thinkingLevel to chat_rooms when migrating from schema version 142", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '142')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`
CREATE TABLE IF NOT EXISTS chat_rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT,
projectId TEXT,
createdBy TEXT,
status TEXT NOT NULL DEFAULT 'active',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.init();
const columns = db.prepare("PRAGMA table_info(chat_rooms)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("thinkingLevel");
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
db.close();
});
it("repairs v143 chat_rooms tables missing thinkingLevel", () => {
const db = new Database(fusionDir);
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '143')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.exec(`
CREATE TABLE IF NOT EXISTS chat_rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT,
projectId TEXT,
createdBy TEXT,
status TEXT NOT NULL DEFAULT 'active',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
db.exec(`INSERT INTO chat_rooms (id, name, slug, status, createdAt, updatedAt) VALUES ('room-legacy', 'Legacy', 'legacy', 'active', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`);
db.init();
const columns = db.prepare("PRAGMA table_info(chat_rooms)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("thinkingLevel");
const row = db.prepare("SELECT id, thinkingLevel FROM chat_rooms WHERE id = 'room-legacy'").get() as { id: string; thinkingLevel: string | null };
expect(row).toEqual({ id: "room-legacy", thinkingLevel: null });
expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION);
db.close();
});
it("creates cli_sessions on a fresh database (fresh-create path)", () => {
const db = new Database(fusionDir);
db.init();

View File

@@ -113,6 +113,7 @@ interface ChatRoomRow {
projectId: string | null;
createdBy: string | null;
status: string;
thinkingLevel: string | null;
createdAt: string;
updatedAt: string;
}
@@ -214,6 +215,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
projectId: row.projectId ?? null,
createdBy: row.createdBy ?? null,
status: row.status as ChatRoomStatus,
thinkingLevel: row.thinkingLevel ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -963,6 +965,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
projectId: input.projectId ?? null,
createdBy: input.createdBy ?? null,
status: "active",
thinkingLevel: input.thinkingLevel ?? null,
createdAt: now,
updatedAt: now,
};
@@ -978,8 +981,8 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
this.db.transaction(() => {
this.db.prepare(`
INSERT INTO chat_rooms (id, name, slug, description, projectId, createdBy, status, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO chat_rooms (id, name, slug, description, projectId, createdBy, status, thinkingLevel, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
room.id,
room.name,
@@ -988,6 +991,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
room.projectId,
room.createdBy,
room.status,
room.thinkingLevel,
room.createdAt,
room.updatedAt,
);
@@ -1069,6 +1073,10 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
setClauses.push("status = ?");
params.push(input.status);
}
if (input.thinkingLevel !== undefined) {
setClauses.push("thinkingLevel = ?");
params.push(input.thinkingLevel);
}
params.push(id);
this.db.prepare(`UPDATE chat_rooms SET ${setClauses.join(", ")} WHERE id = ?`).run(...params);

View File

@@ -271,6 +271,12 @@ export interface ChatRoom {
projectId: string | null;
createdBy: string | null;
status: ChatRoomStatus;
/** Optional room-level thinking/reasoning-effort default for all responders; NULL means inherit the resolved project/global default. */
/*
* FNXC:Chat-ThinkingLevel 2026-07-12-00:00:
* Chat Rooms model one conversation-level reasoning-effort default shared by every responder. Per-member thinking overrides are intentionally not represented here so room delivery preserves one predictable setting surface.
*/
thinkingLevel: string | null;
createdAt: string;
updatedAt: string;
}
@@ -305,12 +311,16 @@ export interface ChatRoomCreateInput {
description?: string | null;
projectId?: string | null;
createdBy?: string | null;
/** Optional room-level thinking/reasoning-effort default; undefined/NULL means inherit the resolved project/global default. */
thinkingLevel?: string | null;
}
export interface ChatRoomUpdateInput {
name?: string;
description?: string | null;
status?: ChatRoomStatus;
/** Optional room-level thinking/reasoning-effort default; undefined leaves unchanged, NULL clears to inherit the resolved project/global default. */
thinkingLevel?: string | null;
}
export interface ChatRoomMessageCreateInput {

View File

@@ -184,7 +184,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 142;
const SCHEMA_VERSION = 144;
const TASKS_FTS_AUTOMERGE = 8;
const TASKS_FTS_CRISISMERGE = 16;
@@ -1681,6 +1681,7 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record<string, Record<string, string>
projectId: "TEXT",
createdBy: "TEXT",
status: "TEXT NOT NULL DEFAULT 'active'",
thinkingLevel: "TEXT",
createdAt: "TEXT NOT NULL",
updatedAt: "TEXT NOT NULL",
},
@@ -4314,6 +4315,7 @@ export class Database {
projectId TEXT,
createdBy TEXT,
status TEXT NOT NULL DEFAULT 'active',
thinkingLevel TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
@@ -5688,6 +5690,30 @@ export class Database {
});
}
if (version < 143) {
/*
* FNXC:Chat-ThinkingLevel 2026-07-12-00:00:
* Chat Rooms persist an optional room-level reasoning-effort default for all responders; NULL keeps existing project/global default inheritance semantics and avoids modeling per-member overrides.
*/
this.applyMigration(143, () => {
if (this.hasTable("chat_rooms")) {
this.addColumnIfMissing("chat_rooms", "thinkingLevel", "TEXT");
}
});
}
if (version < 144) {
/*
* FNXC:Chat-ThinkingLevelRepair 2026-07-12-00:00:
* Re-run the additive chat_rooms.thinkingLevel migration under a fresh schema version so any database that advanced past the initial add without the column converges safely.
*/
this.applyMigration(144, () => {
if (this.hasTable("chat_rooms")) {
this.addColumnIfMissing("chat_rooms", "thinkingLevel", "TEXT");
}
});
}
}
/**

View File

@@ -39,19 +39,19 @@ describe("chat room legacy API client", () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true }));
await fetchChatRoom("room-1", "proj-1");
await createChatRoom({ name: "Engineering" }, "proj-1");
await updateChatRoom("room-1", { description: "desc" }, "proj-1");
await createChatRoom({ name: "Engineering", thinkingLevel: "high" }, "proj-1");
await updateChatRoom("room-1", { description: "desc", thinkingLevel: null }, "proj-1");
await deleteChatRoom("room-1", "proj-1");
expect((fetchMock.mock.calls[0] as [string])[0]).toContain("/api/chat/rooms/room-1?projectId=proj-1");
const [, createInit] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(createInit.method).toBe("POST");
expect(createInit.body).toBe(JSON.stringify({ name: "Engineering", projectId: "proj-1" }));
expect(createInit.body).toBe(JSON.stringify({ name: "Engineering", thinkingLevel: "high", projectId: "proj-1" }));
const [, updateInit] = fetchMock.mock.calls[2] as [string, RequestInit];
expect(updateInit.method).toBe("PATCH");
expect(updateInit.body).toBe(JSON.stringify({ description: "desc" }));
expect(updateInit.body).toBe(JSON.stringify({ description: "desc", thinkingLevel: null }));
const [, deleteInit] = fetchMock.mock.calls[3] as [string, RequestInit];
expect(deleteInit.method).toBe("DELETE");

View File

@@ -10399,7 +10399,7 @@ export function fetchChatRoom(id: string, projectId?: string): Promise<ChatRoomR
}
export function createChatRoom(
input: { name: string; description?: string | null; createdBy?: string | null; memberAgentIds?: string[] },
input: { name: string; description?: string | null; createdBy?: string | null; memberAgentIds?: string[]; thinkingLevel?: string | null },
projectId?: string,
): Promise<ChatRoomResponse> {
const body = { ...input, ...(projectId ? { projectId } : {}) };
@@ -10411,7 +10411,7 @@ export function createChatRoom(
export function updateChatRoom(
id: string,
updates: { name?: string; description?: string | null; status?: "active" | "archived" },
updates: { name?: string; description?: string | null; status?: "active" | "archived"; thinkingLevel?: string | null },
projectId?: string,
): Promise<{ room: ChatRoom }> {
return api<{ room: ChatRoom }>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}`, projectId), {

View File

@@ -250,6 +250,17 @@ When the movable chat popup is resized narrow, collapse Direct/Rooms labels to i
border-bottom: 1px solid var(--border);
}
.chat-room-thinking-level-field {
display: flex;
align-items: center;
flex: 0 1 calc(var(--space-2xl) * 6);
min-width: min-content;
}
.chat-room-thinking-level-select {
width: 100%;
}
.chat-room-thread-members {
margin-left: auto;
display: flex;
@@ -2589,6 +2600,11 @@ Queued-message banners stack above the composer input with a capped scroll area,
padding: var(--space-sm) var(--space-md);
}
.chat-room-thinking-level-field {
min-width: 0;
flex: 1 1 auto;
}
.chat-room-thread-header .btn-icon {
min-height: 36px;
min-width: 36px;

View File

@@ -24,7 +24,7 @@ import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/
import { useChatUnread } from "../hooks/useChatUnread";
import { useViewportMode } from "./Header";
import { fetchSettings, updateGlobalSettings, type DiscoveredSkill } from "../api";
import type { Agent, Settings } from "@fusion/core";
import { THINKING_LEVELS, type Agent, type Settings, type ThinkingLevel } from "@fusion/core";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl";
import { AgentMentionPopup } from "./AgentMentionPopup";
@@ -3497,6 +3497,33 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</div>
)}
</div>
<div className="chat-room-thinking-level-field">
{/* FNXC:Chat-ThinkingLevel 2026-07-12-00:00: Room thinking effort is a header-level room setting, not a composer control, because it acts as the default reasoning effort for every responder in the conversation. */}
<label className="sr-only" htmlFor="chat-room-thinking-level">
{t("chat.roomThinkingLevel", "Room thinking effort")}
</label>
<select
id="chat-room-thinking-level"
className="input chat-room-thinking-level-select"
data-testid="chat-room-thinking-level"
aria-label={t("chat.roomThinkingLevel", "Room thinking effort")}
value={rooms.activeRoom.thinkingLevel ?? ""}
onChange={(event) => {
const selectedLevel = event.target.value;
const thinkingLevel = THINKING_LEVELS.includes(selectedLevel as ThinkingLevel) ? selectedLevel : null;
void rooms.updateRoomSettings(rooms.activeRoom!.id, { thinkingLevel }).catch(() => {
addToast(t("chat.failedToUpdateRoomThinkingLevel", "Failed to update room thinking effort"), "error");
});
}}
>
<option value="">{t("models.useDefault", "Use default")}</option>
{THINKING_LEVELS.map((level) => (
<option key={level} value={level}>
{t(`models.options.${level}`, level === "xhigh" ? "Very High" : level.charAt(0).toUpperCase() + level.slice(1))}
</option>
))}
</select>
</div>
<div className="chat-room-thread-members">
{rooms.activeRoomMembers.map((member) => (
<AgentAvatar

View File

@@ -96,6 +96,7 @@ const roomA = {
projectId: "proj-123",
createdBy: "agent-1",
status: "active" as const,
thinkingLevel: null,
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
@@ -110,6 +111,7 @@ const defaultRoomsState: UseChatRoomsResult = {
messagesLoading: false,
selectRoom: vi.fn(),
createRoom: vi.fn(),
updateRoomSettings: vi.fn(),
deleteRoom: vi.fn(),
sendRoomMessage: vi.fn(),
clearRoom: vi.fn(),
@@ -365,6 +367,27 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
});
});
it("renders room header thinking picker and updates room settings", async () => {
const updateRoomSettings = vi.fn().mockResolvedValue({ ...roomA, thinkingLevel: "high" });
setup({}, { activeRoom: { ...roomA, thinkingLevel: "medium" }, updateRoomSettings });
const { container } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const select = screen.getByTestId("chat-room-thinking-level") as HTMLSelectElement;
expect(select.value).toBe("medium");
expect(within(select).getByRole("option", { name: "Use default" })).toBeDefined();
for (const label of ["Off", "Minimal", "Low", "Medium", "High", "Very High"]) {
expect(within(select).getByRole("option", { name: label })).toBeDefined();
}
await userEvent.selectOptions(select, "high");
expect(updateRoomSettings).toHaveBeenCalledWith("room-a", { thinkingLevel: "high" });
await userEvent.selectOptions(select, "");
expect(updateRoomSettings).toHaveBeenCalledWith("room-a", { thinkingLevel: null });
expect(container.querySelector(".chat-input-area [data-testid='chat-room-thinking-level']")).toBeNull();
});
it("passes attachment file list shape to room sends", async () => {
const addToast = vi.fn();
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
@@ -587,6 +610,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
setup();
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-item-room-a"));
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
mediaSpy.mockRestore();

View File

@@ -13,6 +13,7 @@ vi.mock("../../api", () => ({
fetchChatRoomMessages: vi.fn(),
deleteChatRoom: vi.fn(),
postChatRoomMessage: vi.fn(),
updateChatRoom: vi.fn(),
uploadChatRoomAttachment: vi.fn(),
clearChatRoomMessages: vi.fn(),
}));
@@ -33,6 +34,7 @@ const mockFetchChatRoomMembers = vi.mocked(apiModule.fetchChatRoomMembers);
const mockFetchChatRoomMessages = vi.mocked(apiModule.fetchChatRoomMessages);
const mockDeleteChatRoom = vi.mocked(apiModule.deleteChatRoom);
const mockPostChatRoomMessage = vi.mocked(apiModule.postChatRoomMessage);
const mockUpdateChatRoom = vi.mocked(apiModule.updateChatRoom);
const mockUploadChatRoomAttachment = vi.mocked(apiModule.uploadChatRoomAttachment);
const mockClearChatRoomMessages = vi.mocked(apiModule.clearChatRoomMessages);
const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse);
@@ -46,6 +48,7 @@ function room(id: string, name: string, updatedAt: string): ChatRoom {
projectId: "proj-1",
createdBy: null,
status: "active",
thinkingLevel: null,
createdAt: updatedAt,
updatedAt,
};
@@ -88,6 +91,7 @@ describe("useChatRooms", () => {
mockCreateChatRoom.mockResolvedValue({ room: room("room-new", "new", "2026-05-09T01:00:00.000Z") });
mockDeleteChatRoom.mockResolvedValue({ success: true });
mockPostChatRoomMessage.mockResolvedValue({ message: roomMessage("msg-posted", "room-new", "posted") });
mockUpdateChatRoom.mockResolvedValue({ room: { ...room("room-new", "new", "2026-05-09T02:00:00.000Z"), thinkingLevel: "high" } });
mockUploadChatRoomAttachment.mockResolvedValue({
attachment: {
id: "att-uploaded",
@@ -162,6 +166,24 @@ describe("useChatRooms", () => {
expect(result.current.messages).toHaveLength(1);
});
it("updateRoomSettings upserts returned room and refreshes active room thinkingLevel", async () => {
const existing = room("room-new", "new", "2026-05-09T01:00:00.000Z");
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [existing] });
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.rooms).toHaveLength(1));
act(() => result.current.selectRoom("room-new"));
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-new"));
await act(async () => {
await result.current.updateRoomSettings("room-new", { thinkingLevel: "high" });
});
expect(mockUpdateChatRoom).toHaveBeenCalledWith("room-new", { thinkingLevel: "high" }, "proj-1");
expect(result.current.activeRoom?.thinkingLevel).toBe("high");
expect(result.current.rooms.find((candidate) => candidate.id === "room-new")?.thinkingLevel).toBe("high");
});
it("selectRoom loads messages and clears previous messages", async () => {
const first = room("room-1", "one", "2026-05-09T01:00:00.000Z");
const second = room("room-2", "two", "2026-05-09T02:00:00.000Z");

View File

@@ -9,6 +9,7 @@ import {
fetchChatRoomMessages,
fetchChatRooms,
postChatRoomMessage,
updateChatRoom,
uploadChatRoomAttachment,
} from "../api";
import { subscribeSse } from "../sse-bus";
@@ -46,6 +47,7 @@ export interface UseChatRoomsResult {
messagesLoading: boolean;
selectRoom: (roomId: string | null) => void;
createRoom: (input: { name: string; memberAgentIds: string[] }) => Promise<ChatRoom>;
updateRoomSettings: (roomId: string, updates: { thinkingLevel?: string | null }) => Promise<ChatRoom>;
deleteRoom: (roomId: string) => Promise<void>;
sendRoomMessage: (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[] }) => Promise<void>;
clearRoom: (roomId: string) => Promise<void>;
@@ -287,6 +289,18 @@ export function useChatRooms(
return nextRoom;
}, [activeRoomCacheKey, loadRoomData, projectId]);
const updateRoomSettings = useCallback(async (roomId: string, updates: { thinkingLevel?: string | null }) => {
const response = await updateChatRoom(roomId, updates, projectId);
const nextRoom = response.room;
setRooms((previous) => upsertRoom(previous, nextRoom));
if (activeRoomRef.current?.id === nextRoom.id) {
activeRoomRef.current = nextRoom;
setActiveRoom(nextRoom);
}
return nextRoom;
}, [projectId]);
const deleteRoomLocal = useCallback(async (roomId: string) => {
await deleteChatRoom(roomId, projectId);
setRooms((previous) => previous.filter((room) => room.id !== roomId));
@@ -474,6 +488,7 @@ export function useChatRooms(
if (!room) return;
setRooms((previous) => upsertRoom(previous, room));
if (activeRoomRef.current?.id === room.id) {
activeRoomRef.current = room;
setActiveRoom(room);
}
},
@@ -607,6 +622,7 @@ export function useChatRooms(
messagesLoading,
selectRoom,
createRoom: createRoomLocal,
updateRoomSettings,
deleteRoom: deleteRoomLocal,
sendRoomMessage,
clearRoom,

View File

@@ -72,6 +72,32 @@ describe("Chat Room API Routes", () => {
expect((delRes.body as any).success).toBe(true);
});
it("creates, updates, clears, and validates room thinkingLevel", async () => {
const createRes = await request(app, "POST", "/api/chat/rooms", JSON.stringify({ name: "Reasoning", thinkingLevel: "high" }), {
"content-type": "application/json",
});
expect(createRes.status).toBe(201);
expect((createRes.body as any).room.thinkingLevel).toBe("high");
const roomId = (createRes.body as any).room.id as string;
const setRes = await request(app, "PATCH", `/api/chat/rooms/${roomId}`, JSON.stringify({ thinkingLevel: "minimal" }), {
"content-type": "application/json",
});
expect(setRes.status).toBe(200);
expect((setRes.body as any).room.thinkingLevel).toBe("minimal");
const clearRes = await request(app, "PATCH", `/api/chat/rooms/${roomId}`, JSON.stringify({ thinkingLevel: null }), {
"content-type": "application/json",
});
expect(clearRes.status).toBe(200);
expect((clearRes.body as any).room.thinkingLevel).toBeNull();
const invalidRes = await request(app, "PATCH", `/api/chat/rooms/${roomId}`, JSON.stringify({ thinkingLevel: "extreme" }), {
"content-type": "application/json",
});
expect(invalidRes.status).toBe(400);
});
it("validates create and slug collision", async () => {
const missingName = await request(app, "POST", "/api/chat/rooms", JSON.stringify({}), {
"content-type": "application/json",

View File

@@ -95,6 +95,48 @@ describe("Chat orchestration — rooms (FN-3805..FN-3811 contract)", () => {
expect(assistantWrite).toMatchObject({ role: "assistant", senderAgentId: "agent-a", content: "Room reply" });
});
it("passes resolved room thinkingLevel to every direct and ambient responder session", async () => {
const createResolvedSession = vi.fn(async () => ({
session: {
prompt: vi.fn(),
dispose: vi.fn(),
state: { messages: [{ role: "assistant", content: "Room reply" }] },
},
provider: "test",
model: "test",
fallbackInfo: undefined,
} as any));
__setCreateResolvedAgentSession(createResolvedSession as any);
mockChatStore.getRoom.mockReturnValue({ id: "room-1", name: "room-1", thinkingLevel: "high", projectId: "project-1" });
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" },
]);
mockAgentStore.getAgent.mockResolvedValue(null);
const manager = new ChatManager(
mockChatStore as any,
"/tmp",
mockAgentStore as any,
undefined,
async () => ({ executionThinkingLevel: "medium" } as any),
);
await manager.sendRoomMessage("room-1", "hello @Alpha");
expect(createResolvedSession).toHaveBeenCalledTimes(2);
expect(createResolvedSession.mock.calls.map((call) => call[0].defaultThinkingLevel)).toEqual(["high", "high"]);
createResolvedSession.mockClear();
mockChatStore.getRoom.mockReturnValue({ id: "room-1", name: "room-1", thinkingLevel: null, projectId: "project-1" });
await manager.sendRoomMessage("room-1", "hello @Alpha");
expect(createResolvedSession.mock.calls.map((call) => call[0].defaultThinkingLevel)).toEqual(["medium", "medium"]);
});
it("requests responder and enabled plugin skills for room responder sessions", async () => {
mockChatStore.listRoomMembers.mockReturnValue([
{ roomId: "room-1", agentId: "agent-a", role: "member", addedAt: "2026-01-01" },

View File

@@ -1621,6 +1621,7 @@ export class ChatManager {
roomId,
roomName: room.name,
roomProjectId: room.projectId ?? null,
roomThinkingLevel: room.thinkingLevel ?? null,
content: trimmedContent,
latestUserMessageId: userMessage.id,
attachments,
@@ -1690,6 +1691,7 @@ export class ChatManager {
roomId: string;
roomName: string;
roomProjectId?: string | null;
roomThinkingLevel?: string | null;
content: string;
latestUserMessageId: string;
attachments?: ChatAttachment[];
@@ -1759,6 +1761,11 @@ export class ChatManager {
*/
const effectiveModelProvider = input.modelProvider ?? responderRuntimeModel.provider ?? chatModelSettings.defaultProvider;
const effectiveModelId = input.modelId ?? responderRuntimeModel.modelId ?? chatModelSettings.defaultModelId;
/*
* FNXC:Chat-ThinkingLevel 2026-07-12-00:00:
* Room responders apply the room-level reasoning-effort default through the engine `defaultThinkingLevel` session option. An unset room value inherits the resolved project/global chat default and every direct or ambient responder in the room receives the same effective level.
*/
const effectiveThinkingLevel = resolveExecutorThinkingLevel(input.roomThinkingLevel ?? undefined, chatModelSettings);
/*
* FNXC:ChatModels 2026-07-01-16:42:
* Room responders should pass configured fallback models even when the room send chose an explicit model. The engine still swaps only for retryable provider/model-selection failures, so an unavailable Sonnet 5 can recover without making ordinary prompt errors ambiguous.
@@ -1803,6 +1810,7 @@ export class ChatManager {
defaultModelId: effectiveModelId,
}
: {}),
...(effectiveThinkingLevel ? { defaultThinkingLevel: effectiveThinkingLevel } : {}),
...(allowFallback && chatModelSettings.fallbackProvider && chatModelSettings.fallbackModelId
? {
fallbackProvider: chatModelSettings.fallbackProvider,

View File

@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import type { ChatAttachment, ChatRoomCreateInput, ChatRoomStatus, ChatRoomUpdateInput } from "@fusion/core";
import { THINKING_LEVELS, type ChatAttachment, type ChatRoomCreateInput, type ChatRoomStatus, type ChatRoomUpdateInput } from "@fusion/core";
import type { Request } from "express";
import { RoomReplyGenerationError } from "../chat.js";
import { createProjectScopedChatManager, resolveProjectChatContext } from "../chat-project-services.js";
@@ -16,6 +16,14 @@ function isSlugCollisionError(err: unknown): boolean {
return message.includes("slug") || message.includes("exists");
}
function parseRoomThinkingLevel(value: unknown): string | null {
if (value === null) return null;
if (typeof value === "string" && THINKING_LEVELS.includes(value as (typeof THINKING_LEVELS)[number])) {
return value;
}
throw badRequest("thinkingLevel must be one of off, minimal, low, medium, high, xhigh, or null");
}
interface ChatRoomRouteDeps {
upload: import("multer").Multer;
}
@@ -126,12 +134,13 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext, deps: ChatRoomRout
router.post("/chat/rooms", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
const { name, description, projectId, createdBy, memberAgentIds } = req.body as {
const { name, description, projectId, createdBy, memberAgentIds, thinkingLevel } = req.body as {
name?: string;
description?: string | null;
projectId?: string | null;
createdBy?: string | null;
memberAgentIds?: string[];
thinkingLevel?: unknown;
};
const { chatStore } = await resolveRoomScopedServices(req, projectId);
@@ -144,6 +153,7 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext, deps: ChatRoomRout
...(description !== undefined ? { description } : {}),
...(projectId !== undefined ? { projectId } : {}),
...(createdBy !== undefined ? { createdBy } : {}),
...(thinkingLevel !== undefined ? { thinkingLevel: parseRoomThinkingLevel(thinkingLevel) } : {}),
...(Array.isArray(memberAgentIds) ? { memberAgentIds } : {}),
};
@@ -184,16 +194,17 @@ export function registerChatRoomRoutes(ctx: ApiRoutesContext, deps: ChatRoomRout
try {
const roomId = String(req.params.id);
const { chatStore } = await resolveRoomScopedServices(req, getRequestedProjectId(req));
const { name, description, status } = req.body as { name?: string; description?: string | null; status?: ChatRoomStatus };
const { name, description, status, thinkingLevel } = req.body as { name?: string; description?: string | null; status?: ChatRoomStatus; thinkingLevel?: unknown };
if (name === undefined && description === undefined && status === undefined) {
throw badRequest("at least one of name, description, or status is required");
if (name === undefined && description === undefined && status === undefined && thinkingLevel === undefined) {
throw badRequest("at least one of name, description, status, or thinkingLevel is required");
}
const input: ChatRoomUpdateInput = {
...(name !== undefined ? { name: name.trim() } : {}),
...(description !== undefined ? { description } : {}),
...(status !== undefined ? { status } : {}),
...(thinkingLevel !== undefined ? { thinkingLevel: parseRoomThinkingLevel(thinkingLevel) } : {}),
};
let room;