diff --git a/.changeset/fn-7909-room-thinking-level.md b/.changeset/fn-7909-room-thinking-level.md new file mode 100644 index 0000000000..bf4885e2fd --- /dev/null +++ b/.changeset/fn-7909-room-thinking-level.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 16104dfb83..00119e7689 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 528eeb8398..174927ab5e 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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). diff --git a/packages/core/src/__tests__/chat-store.test.ts b/packages/core/src/__tests__/chat-store.test.ts index adb65c4021..a690f3dc03 100644 --- a/packages/core/src/__tests__/chat-store.test.ts +++ b/packages/core/src/__tests__/chat-store.test.ts @@ -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( diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index 43e85034c0..ce418e0747 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -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(); diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index 4c4d7d521e..83ac266117 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -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 { 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 { 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 { 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 { room.projectId, room.createdBy, room.status, + room.thinkingLevel, room.createdAt, room.updatedAt, ); @@ -1069,6 +1073,10 @@ export class ChatStore extends EventEmitter { 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); diff --git a/packages/core/src/chat-types.ts b/packages/core/src/chat-types.ts index e47774094c..d611a51af5 100644 --- a/packages/core/src/chat-types.ts +++ b/packages/core/src/chat-types.ts @@ -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 { diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 3990d14e47..5f439e00e3 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -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 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"); + } + }); + } + } /** diff --git a/packages/dashboard/app/api/__tests__/chat-rooms-api.test.ts b/packages/dashboard/app/api/__tests__/chat-rooms-api.test.ts index a750b1e388..be825cd268 100644 --- a/packages/dashboard/app/api/__tests__/chat-rooms-api.test.ts +++ b/packages/dashboard/app/api/__tests__/chat-rooms-api.test.ts @@ -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"); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 15ff71ebd6..4809d6a2a5 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -10399,7 +10399,7 @@ export function fetchChatRoom(id: string, projectId?: string): Promise { 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), { diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 8e2b61bb31..274d2dc7f1 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -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; diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 0433f37fc2..6e4258b1f8 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -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 )} +
+ {/* 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. */} + + +
{rooms.activeRoomMembers.map((member) => ( { }); }); + 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(); + + 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(); + await userEvent.click(screen.getByTestId("chat-room-item-room-a")); expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); mediaSpy.mockRestore(); diff --git a/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts b/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts index cf212ffeee..9109f32528 100644 --- a/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts @@ -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"); diff --git a/packages/dashboard/app/hooks/useChatRooms.ts b/packages/dashboard/app/hooks/useChatRooms.ts index d2acf39dff..acb7c5fa4d 100644 --- a/packages/dashboard/app/hooks/useChatRooms.ts +++ b/packages/dashboard/app/hooks/useChatRooms.ts @@ -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; + updateRoomSettings: (roomId: string, updates: { thinkingLevel?: string | null }) => Promise; deleteRoom: (roomId: string) => Promise; sendRoomMessage: (content: string, opts?: { attachments?: ChatAttachment[]; files?: File[] }) => Promise; clearRoom: (roomId: string) => Promise; @@ -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, diff --git a/packages/dashboard/src/__tests__/chat-room-routes.test.ts b/packages/dashboard/src/__tests__/chat-room-routes.test.ts index 41b863acdd..fc6e89cf8f 100644 --- a/packages/dashboard/src/__tests__/chat-room-routes.test.ts +++ b/packages/dashboard/src/__tests__/chat-room-routes.test.ts @@ -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", diff --git a/packages/dashboard/src/__tests__/chat.rooms.test.ts b/packages/dashboard/src/__tests__/chat.rooms.test.ts index c83cae1d7c..18c758960f 100644 --- a/packages/dashboard/src/__tests__/chat.rooms.test.ts +++ b/packages/dashboard/src/__tests__/chat.rooms.test.ts @@ -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" }, diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 5389f023e6..c89069b976 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -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, diff --git a/packages/dashboard/src/routes/register-chat-room-routes.ts b/packages/dashboard/src/routes/register-chat-room-routes.ts index 5f74a25bb8..3873c5175d 100644 --- a/packages/dashboard/src/routes/register-chat-room-routes.ts +++ b/packages/dashboard/src/routes/register-chat-room-routes.ts @@ -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;