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:
7
.changeset/fn-7909-room-thinking-level.md
Normal file
7
.changeset/fn-7909-room-thinking-level.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user