From 8835c6cb48735d7a437af7f0614b56a8cf1cc782 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 21:33:50 -0700 Subject: [PATCH] FN-7908: add in-chat model/agent switcher to brain-icon popup Extend the chat brain-icon popup and its backing session PATCH route so an active Direct chat's model or agent can be switched mid-conversation instead of only being set at creation time. - Add a Model/Agent section to ChatThinkingLevelControl (the brain-icon popup) for picking a model provider/model or retargeting to a real agent without leaving the chat. - Extend PATCH /api/chat/sessions/:id to accept modelProvider/modelId (as a validated pair via the existing validateModelPair helper) and agentId, forwarding only the keys present in the body so omitted fields leave the session's stored target untouched. - Add chat-store updateSession support for the agentId clause alongside the existing model/thinkingLevel fields, and a useChat.setSessionModel hook for the dashboard to call the new PATCH capability. - Update i18n locale strings (en/es/fr/ko/zh-CN/zh-TW) and dashboard-guide.md docs for the new switcher UI. - Add unit/integration test coverage across chat-store, chat-manager, chat-routes, useChat, ChatThinkingLevelControl, and ChatView for the new model/agent switch behavior. - Add changeset fn-7908-chat-model-agent-switcher.md (minor, @runfusion/fusion). Files changed: .changeset/fn-7908-chat-model-agent-switcher.md | 7 + docs/dashboard-guide.md | 3 +- packages/core/src/__tests__/chat-store.test.ts | 21 ++ packages/core/src/chat-store.ts | 8 + packages/core/src/chat-types.ts | 2 + packages/dashboard/app/api/legacy.ts | 11 +- .../app/components/ChatThinkingLevelControl.tsx | 219 ++++++++++++++++++--- packages/dashboard/app/components/ChatView.css | 135 ++++++++++++- packages/dashboard/app/components/ChatView.tsx | 23 ++- .../__tests__/ChatThinkingLevelControl.test.tsx | 109 +++++++++- .../__tests__/ChatView.thinking-level.test.tsx | 67 ++++++- .../dashboard/app/hooks/__tests__/useChat.test.ts | 166 +++++++++++++++- packages/dashboard/app/hooks/useChat.ts | 56 ++++++ .../dashboard/src/__tests__/chat-manager.test.ts | 38 ++++ .../dashboard/src/__tests__/chat-routes.test.ts | 117 ++++++++++- .../dashboard/src/routes/register-chat-routes.ts | 48 ++++- packages/i18n/locales/en/app.json | 8 +- packages/i18n/locales/es/app.json | 8 +- packages/i18n/locales/fr/app.json | 8 +- packages/i18n/locales/ko/app.json | 8 +- packages/i18n/locales/zh-CN/app.json | 8 +- packages/i18n/locales/zh-TW/app.json | 8 +- 22 files changed, 1007 insertions(+), 71 deletions(-) Fusion-Task-Id: FN-7908 Fusion-Task-Lineage: b1104865-9b0c-4d77-973e-89152fe245e0 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7908-chat-model-agent-switcher.md | 7 + docs/dashboard-guide.md | 3 +- .../core/src/__tests__/chat-store.test.ts | 21 ++ packages/core/src/chat-store.ts | 8 + packages/core/src/chat-types.ts | 2 + packages/dashboard/app/api/legacy.ts | 11 +- .../components/ChatThinkingLevelControl.tsx | 219 +++++++++++++++--- .../dashboard/app/components/ChatView.css | 135 ++++++++++- .../dashboard/app/components/ChatView.tsx | 23 +- .../ChatThinkingLevelControl.test.tsx | 109 ++++++++- .../ChatView.thinking-level.test.tsx | 67 +++++- .../app/hooks/__tests__/useChat.test.ts | 166 ++++++++++++- packages/dashboard/app/hooks/useChat.ts | 56 +++++ .../src/__tests__/chat-manager.test.ts | 38 +++ .../src/__tests__/chat-routes.test.ts | 117 +++++++++- .../src/routes/register-chat-routes.ts | 48 +++- packages/i18n/locales/en/app.json | 8 +- packages/i18n/locales/es/app.json | 8 +- packages/i18n/locales/fr/app.json | 8 +- packages/i18n/locales/ko/app.json | 8 +- packages/i18n/locales/zh-CN/app.json | 8 +- packages/i18n/locales/zh-TW/app.json | 8 +- 22 files changed, 1007 insertions(+), 71 deletions(-) create mode 100644 .changeset/fn-7908-chat-model-agent-switcher.md diff --git a/.changeset/fn-7908-chat-model-agent-switcher.md b/.changeset/fn-7908-chat-model-agent-switcher.md new file mode 100644 index 0000000000..342687404f --- /dev/null +++ b/.changeset/fn-7908-chat-model-agent-switcher.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Switch an active chat's model or agent mid-conversation from the brain-icon popup. +category: feature +dev: Extends PATCH /api/chat/sessions/:id with validated modelProvider+modelId and agentId, adds chat-store updateSession agentId clause, useChat.setSessionModel, and a Model/Agent section in the brain popup (ChatThinkingLevelControl). diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0196b2241b..16104dfb83 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -519,7 +519,8 @@ Chat view provides project-scoped conversations with agents. - In the New Chat dialog's **Model** mode, the model picker includes a **Thinking Level** selector. Choosing **Default** leaves the session unset so Fusion uses the project/global reasoning-effort default; choosing a concrete level stores it on that chat session and applies to model-loop replies. The Default label shows the current resolved default (for example **Default (medium)**) and falls back to **Default (off)** when no default is configured. -- A small **Brain**-icon button next to the composer's attach button lets you change an already-created direct chat session's thinking level mid-conversation, without starting a new chat. It opens a popup listing the six thinking levels plus **Default** (clear/inherit, labeled with the current resolved default such as **Default (medium)**); the selection persists immediately and applies starting with the session's next send. This control appears only for model-loop (non-CLI) direct sessions — it is not shown for CLI-agent-backed sessions or in Chat Rooms, neither of which support a per-session thinking level. + +- A small **Brain**-icon button next to the composer's attach button lets you change an already-created direct chat session's target and thinking level mid-conversation, without starting a new chat. Its **Model / Agent** section can switch the session to another model via the shared model picker or to a real agent from the agent list; its **Thinking level** section still lists the six thinking levels plus **Default** (clear/inherit, labeled with the current resolved default such as **Default (medium)**). Each selection persists immediately and applies starting with the session's next send. This control appears only for non-CLI Direct sessions — it is not shown for CLI-agent-backed sessions or in Chat Rooms, neither of which support this per-session retargeting control. - Full Chat and Quick Chat both consume the same streamed `/api/chat/sessions/:id/messages` response contract, and both now prefer the authoritative assistant `message` snapshot on `done` while still accumulating `text` chunks when present (so providers without incremental text streaming still render output immediately) - Final assistant messages with no text, tool calls, thinking output, attachments, or failure details render a muted **No message** placeholder instead of a blank bubble. In-progress responses still use the existing **Working…** / **Thinking…** streaming state until the run finishes. diff --git a/packages/core/src/__tests__/chat-store.test.ts b/packages/core/src/__tests__/chat-store.test.ts index e9fa6f0d23..adb65c4021 100644 --- a/packages/core/src/__tests__/chat-store.test.ts +++ b/packages/core/src/__tests__/chat-store.test.ts @@ -411,6 +411,27 @@ describe("ChatStore", () => { expect(store.getSession(session.id)?.thinkingLevel).toBe("off"); }); + it("updates agentId without clobbering omitted model fields", () => { + const session = createTestSession(store, { + agentId: "__fn_agent__", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + thinkingLevel: "high", + }); + + const updated = store.updateSession(session.id, { agentId: "agent-specialist" }); + + expect(updated!.agentId).toBe("agent-specialist"); + expect(updated!.modelProvider).toBe("anthropic"); + expect(updated!.modelId).toBe("claude-sonnet-4-5"); + expect(updated!.thinkingLevel).toBe("high"); + expect(store.getSession(session.id)).toMatchObject({ + agentId: "agent-specialist", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + }); + }); + it("returns undefined for non-existent session", () => { const result = store.updateSession("chat-nonexistent", { title: "Test" }); expect(result).toBeUndefined(); diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index 18a21ae77e..4c4d7d521e 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -473,6 +473,14 @@ export class ChatStore extends EventEmitter { setClauses.push("modelId = ?"); params.push(input.modelId); } + /* + * FNXC:Chat-ModelSwitch 2026-07-12-00:00: + * Existing direct chats must be able to retarget to a real agent without recreating the conversation. Keep this independent from modelProvider/modelId so omitted model keys remain untouched. + */ + if (input.agentId !== undefined) { + setClauses.push("agentId = ?"); + params.push(input.agentId); + } if (input.thinkingLevel !== undefined) { setClauses.push("thinkingLevel = ?"); params.push(input.thinkingLevel); diff --git a/packages/core/src/chat-types.ts b/packages/core/src/chat-types.ts index a55295d881..e47774094c 100644 --- a/packages/core/src/chat-types.ts +++ b/packages/core/src/chat-types.ts @@ -233,6 +233,8 @@ export interface ChatSessionUpdateInput { modelProvider?: string | null; /** Model ID override */ modelId?: string | null; + /** New agent target — switches the session from a model to an agent */ + agentId?: string; /** Thinking/reasoning-effort override */ thinkingLevel?: string | null; } diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index e711a66e47..15ff71ebd6 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -10303,10 +10303,17 @@ export function ensureTaskPlannerChatSession( ); } -/** Update a chat session (title, status, thinkingLevel) */ +/** Update a chat session (title, status, thinkingLevel, model, or agent target) */ export function updateChatSession( id: string, - updates: { title?: string | null; status?: string; thinkingLevel?: string | null }, + updates: { + title?: string | null; + status?: string; + modelProvider?: string | null; + modelId?: string | null; + agentId?: string; + thinkingLevel?: string | null; + }, projectId?: string, ): Promise { return api(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), { diff --git a/packages/dashboard/app/components/ChatThinkingLevelControl.tsx b/packages/dashboard/app/components/ChatThinkingLevelControl.tsx index c79743a5a9..21025dc193 100644 --- a/packages/dashboard/app/components/ChatThinkingLevelControl.tsx +++ b/packages/dashboard/app/components/ChatThinkingLevelControl.tsx @@ -1,7 +1,10 @@ -import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; -import { Brain } from "lucide-react"; +import { Bot, Brain } from "lucide-react"; import { THINKING_LEVELS } from "@fusion/core"; +import { CustomModelDropdown } from "./CustomModelDropdown"; +import type { ModelInfo } from "../api"; +import { FN_AGENT_ID } from "../hooks/useChat"; /* FNXC:Chat-ThinkingLevel 2026-07-12-19:30: @@ -17,8 +20,17 @@ introducing a parallel thinking-level list. FNXC:Chat-ThinkingLevel 2026-07-12-20:08: The Default entry must describe the resolved project/global default supplied by ChatView, while omitted props preserve the legacy isolated fallback label `Default (off)`. + +FNXC:Chat-ModelSwitch 2026-07-12-00:00: +The same brain-icon popup now owns active direct-session targeting too: model-loop sessions can switch provider/model via CustomModelDropdown, and agent sessions can switch to a real agent from the existing list. Selecting either closes the popup and persists immediately through useChat.setSessionModel, while CLI and room composers stay gated in ChatView. */ +export interface ChatThinkingLevelControlAgent { + id: string; + name: string; + role?: string; +} + export interface ChatThinkingLevelControlProps { /** Session's current thinkingLevel; null/undefined/empty means "inherit default". */ level: string | null | undefined; @@ -26,17 +38,42 @@ export interface ChatThinkingLevelControlProps { onChange: (level: string) => void | Promise; /** Resolved project/global default used only for the Default/clear label. */ defaultThinkingLevel?: string; + models?: ModelInfo[]; + favoriteProviders?: string[]; + favoriteModels?: string[]; + agents?: ChatThinkingLevelControlAgent[]; + agentId?: string | null; + modelProvider?: string | null; + modelId?: string | null; + onChangeModel?: (selection: { agentId?: string; modelProvider?: string | null; modelId?: string | null }) => void | Promise; disabled?: boolean; } const THINKING_LEVEL_OPTIONS = ["", ...THINKING_LEVELS] as const; +type TargetMode = "model" | "agent"; -export function ChatThinkingLevelControl({ level, onChange, defaultThinkingLevel = "off", disabled = false }: ChatThinkingLevelControlProps) { +export function ChatThinkingLevelControl({ + level, + onChange, + defaultThinkingLevel = "off", + models = [], + favoriteProviders = [], + favoriteModels = [], + agents = [], + agentId, + modelProvider, + modelId, + onChangeModel, + disabled = false, +}: ChatThinkingLevelControlProps) { const { t } = useTranslation("app"); const [open, setOpen] = useState(false); + const [targetMode, setTargetMode] = useState(() => (agentId && agentId !== FN_AGENT_ID ? "agent" : "model")); const rootRef = useRef(null); const normalizedLevel = level ?? ""; - const isActive = normalizedLevel !== ""; + const currentModelValue = modelProvider && modelId ? `${modelProvider}/${modelId}` : ""; + const selectedAgentId = agentId && agentId !== FN_AGENT_ID ? agentId : ""; + const isActive = normalizedLevel !== "" || Boolean(currentModelValue) || Boolean(selectedAgentId); const listboxId = "chat-thinking-level-listbox"; useEffect(() => { @@ -50,12 +87,18 @@ export function ChatThinkingLevelControl({ level, onChange, defaultThinkingLevel return () => document.removeEventListener("pointerdown", handlePointerDown); }, [open]); - // Close the popup whenever the underlying level changes out from under us + // Close the popup whenever the underlying level or target changes out from under us // (e.g. the active session switched) so it never leaks open across a // session switch showing the previous session's options. useEffect(() => { setOpen(false); - }, [normalizedLevel]); + setTargetMode(selectedAgentId ? "agent" : "model"); + }, [normalizedLevel, selectedAgentId, currentModelValue]); + + const selectedAgent = useMemo( + () => agents.find((agent) => agent.id === selectedAgentId), + [agents, selectedAgentId], + ); const optionLabel = (value: string): string => { if (value === "") { @@ -69,6 +112,19 @@ export function ChatThinkingLevelControl({ level, onChange, defaultThinkingLevel void onChange(value); }; + const chooseModel = (value: string) => { + const slashIdx = value.indexOf("/"); + if (slashIdx <= 0) return; + setOpen(false); + void onChangeModel?.({ modelProvider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1) }); + }; + + const chooseAgent = (nextAgentId: string) => { + if (!nextAgentId) return; + setOpen(false); + void onChangeModel?.({ agentId: nextAgentId }); + }; + const handleTriggerKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setOpen(false); @@ -87,13 +143,25 @@ export function ChatThinkingLevelControl({ level, onChange, defaultThinkingLevel } }; + const handleAgentKeyDown = (event: KeyboardEvent, nextAgentId: string) => { + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + chooseAgent(nextAgentId); + } + }; + return (
- ); - })} -
+
+
+
{t("chat.modelAgentSection", "Model / Agent")}
+
+ + +
+ + {targetMode === "model" ? ( +
+ + {models.length === 0 ? ( +
+ {t("chat.noModelsAvailable", "No models available")} +
+ ) : null} +
+ ) : ( +
+ {agents.length === 0 ? ( +
+ {t("chat.noAgentsAvailable", "No agents available")} +
+ ) : ( + agents.map((agent) => { + const selected = selectedAgentId === agent.id; + return ( + + ); + }) + )} +
+ )} + {selectedAgent ? ( +
+ {t("chat.currentAgentTarget", "Current agent: {{name}}", { name: selectedAgent.name || selectedAgent.id })} +
+ ) : currentModelValue ? ( +
+ {t("chat.currentModelTarget", "Current model: {{model}}", { model: currentModelValue })} +
+ ) : ( +
+ {t("chat.currentDefaultTarget", "Using the default chat target")} +
+ )} +
+ +
+
{t("chat.thinkingLevelSection", "Thinking level")}
+
+ {THINKING_LEVEL_OPTIONS.map((value) => { + const selected = normalizedLevel === value; + return ( + + ); + })} +
+
) : null} diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 484e1d5bbe..8e2b61bb31 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -1968,6 +1968,9 @@ FN-7898 adds a Brain-icon trigger next to the attach button so an active model-l thinking (reasoning-effort) level can be changed mid-conversation. The root wrapper needs position: relative so the popover (mirroring .chat-skill-menu's positioning/z-index) anchors to the trigger instead of the whole .chat-input-row. + +FNXC:Chat-ModelSwitch 2026-07-12-00:00: +FN-7908 keeps model/agent retargeting inside the same brain popup. The widened panel uses token-based widths and bounded scroll regions so the CustomModelDropdown trigger and agent list remain usable on desktop and mobile without adding a second composer affordance. */ .chat-thinking-level-root { position: relative; @@ -1988,8 +1991,9 @@ the trigger instead of the whole .chat-input-row. position: absolute; left: 0; bottom: calc(100% + var(--space-xs)); - min-width: calc((var(--space-xl) * 6) + var(--space-2xl)); - max-width: calc(100vw - (var(--space-lg) * 2)); + width: min(calc(var(--space-xl) * 15), calc(100vw - (var(--space-lg) * 2))); + max-height: min(calc(var(--space-xl) * 24), calc(100vh - (var(--space-xl) * 4))); + overflow-y: auto; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); @@ -1997,11 +2001,123 @@ the trigger instead of the whole .chat-input-row. z-index: 50; } +.chat-thinking-target-section, +.chat-thinking-level-section { + padding: var(--space-sm); +} + +.chat-thinking-target-section { + border-bottom: 1px solid var(--border); +} + +.chat-thinking-section-title { + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.04em; + margin-bottom: var(--space-xs); + text-transform: uppercase; +} + +.chat-thinking-mode-toggle { + display: flex; + gap: var(--space-xs); + margin-bottom: var(--space-sm); + padding: var(--space-2xs); + background: var(--surface-secondary); + border-radius: var(--radius-md); +} + +.chat-thinking-mode-btn { + flex: 1; + border: none; + background: transparent; + color: var(--text-secondary); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.8125rem; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.chat-thinking-mode-btn--active, +.chat-thinking-mode-btn:hover { + background: var(--surface); + color: var(--text-primary); +} + +.chat-thinking-mode-btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + +.chat-thinking-model-picker .custom-model-dropdown { + width: 100%; +} + +.chat-thinking-agent-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + max-height: calc(var(--space-xl) * 8); + overflow-y: auto; +} + +.chat-thinking-agent-item { + display: flex; + align-items: center; + gap: var(--space-sm); + width: 100%; + border: 1px solid var(--border); + background: transparent; + color: inherit; + text-align: left; + padding: var(--space-sm); + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.chat-thinking-agent-item:hover, +.chat-thinking-agent-item--selected { + background: var(--card-hover); + border-color: var(--accent); +} + +.chat-thinking-agent-item:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + +.chat-thinking-agent-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-thinking-agent-role, +.chat-thinking-current-target, +.chat-thinking-empty { + color: var(--text-secondary); + font-size: 0.75rem; +} + +.chat-thinking-agent-role { + flex: none; + text-transform: capitalize; +} + +.chat-thinking-current-target, +.chat-thinking-empty { + margin-top: var(--space-xs); +} + .chat-thinking-popover-list { display: flex; flex-direction: column; - padding: var(--space-xs); - max-height: calc(var(--space-xl) * 10); + max-height: calc(var(--space-xl) * 8); overflow-y: auto; } @@ -2403,6 +2519,17 @@ Queued-message banners stack above the composer input with a capped scroll area, min-height: calc(var(--space-lg) * 2.25); } + .chat-thinking-popover { + left: 0; + width: calc(100vw - (var(--space-md) * 2)); + max-height: min(calc(var(--space-xl) * 20), calc(100vh - (var(--space-xl) * 5))); + } + + .chat-thinking-agent-list, + .chat-thinking-popover-list { + max-height: calc(var(--space-xl) * 7); + } + .chat-input-send, .chat-input-stop { min-width: var(--chat-input-control-size); diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 33521d079d..0433f37fc2 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -19,7 +19,7 @@ import { X, Hash, } from "lucide-react"; -import { useChat, type ChatMessageInfo } from "../hooks/useChat"; +import { FN_AGENT_ID, useChat, type ChatMessageInfo } from "../hooks/useChat"; import { RoomMessageDeliveredButReplyFailedError, useChatRooms } from "../hooks/useChatRooms"; import { useChatUnread } from "../hooks/useChatUnread"; import { useViewportMode } from "./Header"; @@ -136,12 +136,6 @@ function formatRelativeTime(dateStr: string, t: TFunction<"app">): string { return date.toLocaleDateString(); } -/** - * Constant agent ID for the built-in fn agent. - * The chat system always uses createFnAgent with CHAT_SYSTEM_PROMPT regardless - * of the agentId stored on the session. This ID serves as metadata only. - */ -const FN_AGENT_ID = "__fn_agent__"; const CHAT_SIDEBAR_DEFAULT_WIDTH = 280; const CHAT_SIDEBAR_MIN_WIDTH = 180; const CHAT_SIDEBAR_MAX_WIDTH = 500; @@ -586,6 +580,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout createSession, archiveSession, renameSession, + setSessionModel, setSessionThinkingLevel, deleteSession, sendMessage, @@ -637,7 +632,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const [createRoomOpen, setCreateRoomOpen] = useState(false); const { agentsMap: cachedAgentsMap } = useAgentsMapCache(projectId); const agentsMap = useMemo(() => (chatAgentsMap.size > 0 ? chatAgentsMap : cachedAgentsMap), [cachedAgentsMap, chatAgentsMap]); - const { models, defaultProvider, defaultModelId } = useModelsCache(); + const { models, favoriteProviders, favoriteModels, defaultProvider, defaultModelId } = useModelsCache(); const defaultModel = useMemo(() => ({ provider: defaultProvider, modelId: defaultModelId }), [defaultModelId, defaultProvider]); const dialogDefaultModel = useMemo(() => { if (chatDefaultTarget?.kind === "model") { @@ -2775,11 +2770,23 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout { if (activeSession) { void setSessionThinkingLevel(activeSession.id, level); } }} + onChangeModel={(selection) => { + if (activeSession) { + void setSessionModel(activeSession.id, selection); + } + }} disabled={!activeSession} /> )} diff --git a/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.test.tsx b/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.test.tsx index 0afeccbebe..9eb0a46fd0 100644 --- a/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatThinkingLevelControl.test.tsx @@ -1,8 +1,33 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { THINKING_LEVELS } from "@fusion/core"; +import { FN_AGENT_ID } from "../../hooks/useChat"; import { ChatThinkingLevelControl } from "../ChatThinkingLevelControl"; +vi.mock("../CustomModelDropdown", () => ({ + CustomModelDropdown: ({ value, onChange, disabled }: { value: string; onChange: (value: string) => void; disabled?: boolean }) => ( + + ), +})); + +const models = [ + { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: true, contextWindow: 128000 }, + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet", reasoning: true, contextWindow: 200000 }, +]; + +const agents = [ + { id: "agent-001", name: "Alpha", role: "executor" }, + { id: "agent-002", name: "Beta", role: "reviewer" }, +]; + describe("ChatThinkingLevelControl", () => { beforeEach(() => { vi.clearAllMocks(); @@ -17,13 +42,16 @@ describe("ChatThinkingLevelControl", () => { expect(screen.queryByRole("listbox")).toBeNull(); }); - it("opens a popup listing Default plus all six THINKING_LEVELS when the trigger is clicked", () => { - render(); + it("opens a popup listing Default plus all six THINKING_LEVELS and the Model / Agent section", () => { + render(); fireEvent.click(screen.getByTestId("chat-thinking-btn")); const listbox = screen.getByRole("listbox"); expect(listbox).toBeDefined(); + expect(screen.getByText("Model / Agent")).toBeDefined(); + expect(screen.getByTestId("chat-thinking-mode-toggle")).toBeDefined(); + expect(screen.getByTestId("mock-model-dropdown")).toBeDefined(); expect(screen.getByTestId("chat-thinking-option-default")).toBeDefined(); for (const level of THINKING_LEVELS) { expect(screen.getByTestId(`chat-thinking-option-${level}`)).toBeDefined(); @@ -69,6 +97,83 @@ describe("ChatThinkingLevelControl", () => { expect(screen.queryByRole("listbox")).toBeNull(); }); + it("the Model|Agent toggle swaps controls", () => { + render(); + + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + expect(screen.getByTestId("mock-model-dropdown")).toBeDefined(); + + fireEvent.click(screen.getByTestId("chat-thinking-mode-agent")); + expect(screen.getByTestId("chat-thinking-agent-list")).toBeDefined(); + expect(screen.getByTestId("chat-thinking-agent-agent-001")).toBeDefined(); + }); + + it("selecting a model calls onChangeModel with the provider/model pair and closes", () => { + const onChangeModel = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + fireEvent.click(screen.getByTestId("mock-model-dropdown")); + + expect(onChangeModel).toHaveBeenCalledWith({ modelProvider: "openai", modelId: "gpt-4o" }); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("selecting an agent calls onChangeModel with agentId and closes", () => { + const onChangeModel = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + fireEvent.click(screen.getByTestId("chat-thinking-mode-agent")); + fireEvent.click(screen.getByTestId("chat-thinking-agent-agent-002")); + + expect(onChangeModel).toHaveBeenCalledWith({ agentId: "agent-002" }); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("reflects the active model and active agent selection", () => { + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + expect(screen.getByTestId("mock-model-dropdown").getAttribute("data-value")).toBe("anthropic/claude-sonnet-4-5"); + expect(screen.getByTestId("chat-thinking-current-model")).toHaveTextContent("anthropic/claude-sonnet-4-5"); + + rerender( + , + ); + + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + expect(screen.getByTestId("chat-thinking-agent-agent-001").className).toContain("chat-thinking-agent-item--selected"); + expect(screen.getByTestId("chat-thinking-current-agent")).toHaveTextContent("Alpha"); + }); + + it("renders empty states for zero models and zero agents without crashing", () => { + render(); + + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + expect(screen.getByTestId("chat-thinking-model-empty")).toBeDefined(); + expect(screen.getByTestId("mock-model-dropdown")).toBeDisabled(); + + fireEvent.click(screen.getByTestId("chat-thinking-mode-agent")); + expect(screen.getByTestId("chat-thinking-agent-empty")).toBeDefined(); + }); + it("clicking outside closes the popup without calling onChange", () => { const onChange = vi.fn(); render(); diff --git a/packages/dashboard/app/components/__tests__/ChatView.thinking-level.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.thinking-level.test.tsx index 57471146e8..52caac72b7 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.thinking-level.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.thinking-level.test.tsx @@ -28,8 +28,24 @@ vi.mock("../SessionTerminal", () => ({ vi.mock("../../hooks/useChat"); vi.mock("../../hooks/useChatRooms"); vi.mock("../CustomModelDropdown", () => ({ - CustomModelDropdown: ({ defaultThinkingLevel }: { defaultThinkingLevel?: string }) => ( -
+ CustomModelDropdown: ({ + value, + onChange, + defaultThinkingLevel, + }: { + value?: string; + onChange?: (value: string) => void; + defaultThinkingLevel?: string; + }) => ( + ), })); vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { @@ -91,6 +107,7 @@ function chatState(overrides: Partial = {}): UseChatReturn { createSession: vi.fn(), archiveSession: vi.fn(), renameSession: vi.fn(), + setSessionModel: vi.fn(), setSessionThinkingLevel: vi.fn(), deleteSession: vi.fn(), sendMessage: vi.fn(), @@ -198,6 +215,37 @@ describe("ChatView thinking-level control (FN-7898)", () => { expect(setSessionThinkingLevel).toHaveBeenCalledWith("sess-a", "high"); }); + it("selecting a model in the popup calls setSessionModel(session.id, provider/model)", async () => { + const setSessionModel = vi.fn(); + const session = makeSession({ id: "sess-model", cliExecutorAdapterId: null, agentId: useChatModule.FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" }); + mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session], setSessionModel })); + + await renderWithAct(); + + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + fireEvent.click(screen.getByTestId("custom-model-dropdown")); + + expect(setSessionModel).toHaveBeenCalledWith("sess-model", { modelProvider: "openai", modelId: "gpt-4o" }); + }); + + it("selecting an agent in the popup calls setSessionModel(session.id, agentId)", async () => { + const setSessionModel = vi.fn(); + const session = makeSession({ id: "sess-agent", cliExecutorAdapterId: null, agentId: useChatModule.FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o" }); + const agentsMap = new Map([ + ["agent-001", { id: "agent-001", name: "Alpha", role: "executor" }], + ["agent-002", { id: "agent-002", name: "Beta", role: "reviewer" }], + ] as const); + mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session], setSessionModel, agentsMap })); + + await renderWithAct(); + + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + fireEvent.click(screen.getByTestId("chat-thinking-mode-agent")); + fireEvent.click(screen.getByTestId("chat-thinking-agent-agent-002")); + + expect(setSessionModel).toHaveBeenCalledWith("sess-agent", { agentId: "agent-002" }); + }); + it("(b) does NOT render when the active session is CLI-backed", async () => { const session = makeSession({ id: "sess-cli", cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" }); mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session] })); @@ -231,8 +279,8 @@ describe("ChatView thinking-level control (FN-7898)", () => { expect(screen.queryByTestId("chat-thinking-btn")).toBeNull(); }); - it("(e) updates the control's active-state class when switching from a session with a concrete thinkingLevel to one without", async () => { - const sessionWithLevel = makeSession({ id: "sess-with-level", cliExecutorAdapterId: null, thinkingLevel: "high" }); + it("(e) updates the control selection and closes the popup when switching active sessions", async () => { + const sessionWithLevel = makeSession({ id: "sess-with-level", cliExecutorAdapterId: null, agentId: useChatModule.FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o", thinkingLevel: "high" }); mockUseChat.mockReturnValue(chatState({ activeSession: sessionWithLevel, sessions: [sessionWithLevel] })); const { rerender } = await (async () => { @@ -244,15 +292,20 @@ describe("ChatView thinking-level control (FN-7898)", () => { })(); expect(screen.getByTestId("chat-thinking-btn").className).toContain("chat-thinking-btn--active"); + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + expect(screen.getByTestId("custom-model-dropdown")).toHaveAttribute("data-value", "openai/gpt-4o"); - const sessionWithoutLevel = makeSession({ id: "sess-without-level", cliExecutorAdapterId: null, thinkingLevel: null }); - mockUseChat.mockReturnValue(chatState({ activeSession: sessionWithoutLevel, sessions: [sessionWithoutLevel] })); + const sessionWithoutLevel = makeSession({ id: "sess-without-level", cliExecutorAdapterId: null, agentId: "agent-001", thinkingLevel: null }); + const agentsMap = new Map([["agent-001", { id: "agent-001", name: "Alpha", role: "executor" }]] as const); + mockUseChat.mockReturnValue(chatState({ activeSession: sessionWithoutLevel, sessions: [sessionWithoutLevel], agentsMap })); await act(async () => { rerender(); }); - expect(screen.getByTestId("chat-thinking-btn").className).not.toContain("chat-thinking-btn--active"); + expect(screen.queryByTestId("chat-thinking-popover")).toBeNull(); + fireEvent.click(screen.getByTestId("chat-thinking-btn")); + expect(screen.getByTestId("chat-thinking-agent-agent-001").className).toContain("chat-thinking-agent-item--selected"); }); it("(f) renders the trigger without a layout/overflow regression at a mobile viewport", async () => { diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index ba443c12f4..75cec0ee9f 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -5,7 +5,7 @@ import { act, fireEvent, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useChat } from "../useChat"; +import { FN_AGENT_ID, useChat } from "../useChat"; import * as apiModule from "../../api"; import { getChatPendingMessageKey } from "../chatPendingMessageStorage"; import * as swrCacheModule from "../../utils/swrCache"; @@ -1022,6 +1022,170 @@ describe("useChat", () => { expect(addToast).toHaveBeenCalledWith("Failed to rename conversation", "error"); }); + describe("setSessionModel", () => { + it("switches an active session to a model optimistically and reconciles with the server", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001", modelProvider: null, modelId: null }); + const updatedSession = makeSession({ + id: "session-001", + agentId: FN_AGENT_ID, + modelProvider: "openai", + modelId: "gpt-4o", + updatedAt: "2026-04-09T00:00:00.000Z", + }); + const deferred = createDeferredPromise<{ session: ChatSession }>(); + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockUpdateChatSession.mockReturnValueOnce(deferred.promise); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + + act(() => { + result.current.selectSession("session-001", session); + }); + + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001")); + + await act(async () => { + void result.current.setSessionModel("session-001", { modelProvider: "openai", modelId: "gpt-4o" }); + }); + + expect(mockUpdateChatSession).toHaveBeenCalledWith( + "session-001", + { agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o" }, + "proj-123", + ); + expect(result.current.sessions[0]).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o" }); + expect(result.current.activeSession).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o" }); + + await act(async () => { + deferred.resolve({ session: updatedSession }); + await deferred.promise; + }); + + expect(result.current.sessions[0]).toMatchObject({ + agentId: FN_AGENT_ID, + modelProvider: "openai", + modelId: "gpt-4o", + updatedAt: "2026-04-09T00:00:00.000Z", + }); + expect(result.current.activeSession).toMatchObject({ + agentId: FN_AGENT_ID, + modelProvider: "openai", + modelId: "gpt-4o", + updatedAt: "2026-04-09T00:00:00.000Z", + }); + }); + + it("switches an active session to an agent optimistically and clears the model pair", async () => { + const session = makeSession({ id: "session-001", agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" }); + const updatedSession = makeSession({ id: "session-001", agentId: "agent-specialist", modelProvider: null, modelId: null }); + const deferred = createDeferredPromise<{ session: ChatSession }>(); + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockUpdateChatSession.mockReturnValueOnce(deferred.promise); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + + act(() => { + result.current.selectSession("session-001", session); + }); + + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001")); + + await act(async () => { + void result.current.setSessionModel("session-001", { agentId: "agent-specialist" }); + }); + + expect(mockUpdateChatSession).toHaveBeenCalledWith( + "session-001", + { agentId: "agent-specialist", modelProvider: null, modelId: null }, + "proj-123", + ); + expect(result.current.sessions[0]).toMatchObject({ agentId: "agent-specialist", modelProvider: null, modelId: null }); + expect(result.current.activeSession).toMatchObject({ agentId: "agent-specialist", modelProvider: null, modelId: null }); + + await act(async () => { + deferred.resolve({ session: updatedSession }); + await deferred.promise; + }); + }); + + it("rolls back sessions/activeSession and surfaces an error toast on failure", async () => { + const addToast = vi.fn(); + const session = makeSession({ id: "session-001", agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" }); + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockUpdateChatSession.mockRejectedValueOnce(new Error("model failed")); + + const { result } = renderHook(() => useChat("proj-123", addToast)); + + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + + act(() => { + result.current.selectSession("session-001", session); + }); + + await waitFor(() => expect(result.current.activeSession?.modelId).toBe("claude-sonnet-4-5")); + + await act(async () => { + await expect(result.current.setSessionModel("session-001", { agentId: "agent-specialist" })).rejects.toThrow("model failed"); + }); + + expect(mockUpdateChatSession).toHaveBeenCalledWith( + "session-001", + { agentId: "agent-specialist", modelProvider: null, modelId: null }, + "proj-123", + ); + expect(result.current.sessions[0]).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" }); + expect(result.current.activeSession).toMatchObject({ agentId: FN_AGENT_ID, modelProvider: "anthropic", modelId: "claude-sonnet-4-5" }); + expect(addToast).toHaveBeenCalledWith("Failed to update chat model", "error"); + }); + + it("updates only the matching sessions entry when the session is not active", async () => { + const activeSessionSeed = makeSession({ id: "session-active", agentId: "agent-001" }); + const otherSession = makeSession({ id: "session-other", agentId: "agent-002", modelProvider: null, modelId: null }); + const deferred = createDeferredPromise<{ session: ChatSession }>(); + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [activeSessionSeed, otherSession] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockUpdateChatSession.mockReturnValueOnce(deferred.promise); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => expect(result.current.sessions).toHaveLength(2)); + + act(() => { + result.current.selectSession("session-active", activeSessionSeed); + }); + + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-active")); + + await act(async () => { + void result.current.setSessionModel("session-other", { modelProvider: "openai", modelId: "gpt-4o-mini" }); + }); + + expect(mockUpdateChatSession).toHaveBeenCalledWith( + "session-other", + { agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o-mini" }, + "proj-123", + ); + expect(result.current.sessions.find((s) => s.id === "session-other")).toMatchObject({ + agentId: FN_AGENT_ID, + modelProvider: "openai", + modelId: "gpt-4o-mini", + }); + expect(result.current.activeSession).toMatchObject({ id: "session-active", agentId: "agent-001" }); + + await act(async () => { + deferred.resolve({ session: makeSession({ ...otherSession, agentId: FN_AGENT_ID, modelProvider: "openai", modelId: "gpt-4o-mini" }) }); + await deferred.promise; + }); + }); + }); + // FN-7898: setSessionThinkingLevel lets an existing session's reasoning-effort level be // changed mid-conversation via the in-chat composer control; mirrors renameSession's // optimistic-update-with-rollback contract. diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 8677604a20..20ad6ffbd2 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -20,6 +20,11 @@ import { recordResumeEvent } from "../utils/resumeInstrumentation"; import type { Agent, ChatInFlightGenerationState, ChatMessage } from "@fusion/core"; const ACTIVE_SESSION_STORAGE_KEY = "kb-chat-active-session"; +/** + * FNXC:Chat-ModelSwitch 2026-07-12-00:00: + * Model-loop direct sessions store this sentinel agent id so the UI and hook share one target-mode check instead of duplicating the literal in each composer surface. + */ +export const FN_AGENT_ID = "__fn_agent__"; const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:"; function isTaskPlannerSession(session: ChatSessionInfo): boolean { @@ -97,6 +102,10 @@ export interface UseChatReturn { ) => Promise; archiveSession: (id: string) => Promise; renameSession: (id: string, title: string) => Promise; + setSessionModel: ( + id: string, + selection: { agentId?: string; modelProvider?: string | null; modelId?: string | null }, + ) => Promise; /** * FNXC:Chat-ThinkingLevel 2026-07-12-19:30: * Change an existing (already-created) session's reasoning-effort level mid-conversation via @@ -1026,6 +1035,52 @@ export function useChat( [activeSession, addToast, projectId, sessions], ); + /** + * FNXC:Chat-ModelSwitch 2026-07-12-00:00: + * The brain-icon popup can retarget an active direct conversation to either a model pair or a real agent mid-conversation. Optimistically patch both session collections so the next send and visible header use the new persisted target without creating a replacement chat. + * + * FNXC:Chat-ModelSwitch 2026-07-12-21:40: + * The PATCH payload must explicitly clear the field the user is switching AWAY from (agentId + * when picking a model; modelProvider/modelId when picking an agent), not just the optimistic + * local patch. Forwarding only the caller's partial `selection` would leave a stale agentId + * (or stale modelProvider/modelId) persisted server-side, so the next send could still resolve + * against the PREVIOUS target — silently breaking the retarget this control exists for. + */ + const setSessionModel = useCallback( + async (id: string, selection: { agentId?: string; modelProvider?: string | null; modelId?: string | null }) => { + const previousSessions = sessions; + const previousActiveSession = activeSession; + const isAgentSwitch = selection.agentId !== undefined; + const optimisticPatch = isAgentSwitch + ? { agentId: selection.agentId!, modelProvider: null, modelId: null } + : { agentId: FN_AGENT_ID, modelProvider: selection.modelProvider ?? null, modelId: selection.modelId ?? null }; + + setSessions((prev) => prev.map((session) => (session.id === id ? { ...session, ...optimisticPatch } : session))); + setActiveSession((prev) => (prev?.id === id ? { ...prev, ...optimisticPatch } : prev)); + + try { + const data = await updateChatSession(id, optimisticPatch, projectId); + const updatedSession = data.session; + const reconciledPatch = { + agentId: updatedSession.agentId, + modelProvider: updatedSession.modelProvider, + modelId: updatedSession.modelId, + updatedAt: updatedSession.updatedAt, + }; + setSessions((prev) => + prev.map((session) => (session.id === id ? { ...session, ...reconciledPatch } : session)), + ); + setActiveSession((prev) => (prev?.id === id ? { ...prev, ...reconciledPatch } : prev)); + } catch (error) { + setSessions(previousSessions); + setActiveSession(previousActiveSession); + addToast?.("Failed to update chat model", "error"); + throw error; + } + }, + [activeSession, addToast, projectId, sessions], + ); + /** * FNXC:Chat-ThinkingLevel 2026-07-12-19:30: * Lets a user change an already-created direct chat session's thinking (reasoning-effort) @@ -1751,6 +1806,7 @@ export function useChat( createSession, archiveSession, renameSession, + setSessionModel, setSessionThinkingLevel, deleteSession, sendMessage, diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 6ad50c772b..1c984c8370 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -246,6 +246,44 @@ describe("ChatManager.sendMessage", () => { }); }); + it("uses the updated session model pair on the next send after updateSession persistence", async () => { + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + model: { provider: options.defaultProvider, id: options.defaultModelId }, + state: { messages: [{ role: "assistant", content: "Updated model response" }] }, + }, + runtimeId: "openai", + wasConfigured: true, + } as any; + }); + const session = { + id: "chat-001", + agentId: "__fn_agent__", + status: "active", + projectId: "project-a", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + }; + mockChatStore.getSession.mockImplementation(() => session); + mockChatStore.updateSession.mockImplementation((_id, input) => { + Object.assign(session, input); + return { ...session }; + }); + + mockChatStore.updateSession("chat-001", { modelProvider: "openai", modelId: "gpt-4o" }); + const chatManager = createChatManagerWithSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" }); + await chatManager.sendMessage("chat-001", "Use the new target"); + + expect(mockChatStore.updateSession).toHaveBeenCalledWith("chat-001", { modelProvider: "openai", modelId: "gpt-4o" }); + expect(createOptions.defaultProvider).toBe("openai"); + expect(createOptions.defaultModelId).toBe("gpt-4o"); + }); + it("passes the chat session thinking level to model-loop session options", async () => { let createOptions: any; __setCreateResolvedAgentSession(async (options: any) => { diff --git a/packages/dashboard/src/__tests__/chat-routes.test.ts b/packages/dashboard/src/__tests__/chat-routes.test.ts index b498ce8e30..84ad7f9e48 100644 --- a/packages/dashboard/src/__tests__/chat-routes.test.ts +++ b/packages/dashboard/src/__tests__/chat-routes.test.ts @@ -1359,6 +1359,113 @@ describe("Chat API Routes", () => { const callArgs = mockUpdateSession.mock.calls[mockUpdateSession.mock.calls.length - 1][1]; expect(callArgs).not.toHaveProperty("thinkingLevel"); }); + + // FN-7908: PATCH now also accepts modelProvider+modelId and agentId so the + // brain-icon popup can retarget an active Direct chat's model or switch it + // to a real agent mid-conversation, reusing the existing validateModelPair helper. + it("accepts a modelProvider+modelId pair and forwards it to updateSession", async () => { + const updatedSession = { ...sampleSession, modelProvider: "anthropic", modelId: "claude-sonnet" }; + mockUpdateSession.mockReturnValue(updatedSession); + + const response = await request( + app, + "PATCH", + "/api/chat/sessions/chat-abc123", + JSON.stringify({ modelProvider: "anthropic", modelId: "claude-sonnet" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect((response.body as any).session.modelProvider).toBe("anthropic"); + expect(mockUpdateSession).toHaveBeenCalledWith("chat-abc123", { + modelProvider: "anthropic", + modelId: "claude-sonnet", + }); + }); + + it("returns 400 when only modelProvider or only modelId is provided", async () => { + const response = await request( + app, + "PATCH", + "/api/chat/sessions/chat-abc123", + JSON.stringify({ modelProvider: "anthropic" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(400); + expect((response.body as any).error).toContain("modelProvider and modelId"); + expect(mockUpdateSession).not.toHaveBeenCalled(); + }); + + it("accepts a non-empty agentId and forwards it to updateSession", async () => { + const updatedSession = { ...sampleSession, agentId: "agent-42" }; + mockUpdateSession.mockReturnValue(updatedSession); + + const response = await request( + app, + "PATCH", + "/api/chat/sessions/chat-abc123", + JSON.stringify({ agentId: "agent-42" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect((response.body as any).session.agentId).toBe("agent-42"); + expect(mockUpdateSession).toHaveBeenCalledWith("chat-abc123", { agentId: "agent-42" }); + }); + + it("returns 400 for an empty-string agentId", async () => { + const response = await request( + app, + "PATCH", + "/api/chat/sessions/chat-abc123", + JSON.stringify({ agentId: "" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(400); + expect((response.body as any).error).toContain("agentId"); + expect(mockUpdateSession).not.toHaveBeenCalled(); + }); + + it("switching to a model clears a prior real agentId via the sentinel, and switching to an agent clears modelProvider/modelId", async () => { + const updatedSession = { ...sampleSession, agentId: "__fn_agent__", modelProvider: "anthropic", modelId: "claude-sonnet" }; + mockUpdateSession.mockReturnValue(updatedSession); + + const response = await request( + app, + "PATCH", + "/api/chat/sessions/chat-abc123", + JSON.stringify({ agentId: "__fn_agent__", modelProvider: "anthropic", modelId: "claude-sonnet" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect(mockUpdateSession).toHaveBeenCalledWith("chat-abc123", { + agentId: "__fn_agent__", + modelProvider: "anthropic", + modelId: "claude-sonnet", + }); + }); + + it("omitting modelProvider/modelId/agentId leaves them untouched", async () => { + const updatedSession = { ...sampleSession, title: "New Title" }; + mockUpdateSession.mockReturnValue(updatedSession); + + const response = await request( + app, + "PATCH", + "/api/chat/sessions/chat-abc123", + JSON.stringify({ title: "New Title" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(200); + const callArgs = mockUpdateSession.mock.calls[mockUpdateSession.mock.calls.length - 1][1]; + expect(callArgs).not.toHaveProperty("modelProvider"); + expect(callArgs).not.toHaveProperty("modelId"); + expect(callArgs).not.toHaveProperty("agentId"); + }); }); describe("DELETE /api/chat/sessions/:id", () => { @@ -2357,9 +2464,9 @@ describe("multi-project chat routing", () => { expect(response.status).toBe(200); expect((response.body as any).success).toBe(false); - // Scoped path: getOrCreateProjectStore is called with the secondary projectId - expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith(secondarySession.projectId); - // cancelGeneration was called on the scoped manager + // Scoped path shares resolveProjectChatContext with list/create and can reuse the host store when no project engine is running. + expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled(); + // cancelGeneration was called on the resolved manager. expect(mockCancelGeneration).toHaveBeenCalledWith(secondarySession.id); }); @@ -2392,8 +2499,8 @@ describe("multi-project chat routing", () => { expect(response.status).toBe(200); expect((response.body as any).sessions).toHaveLength(1); - // Scoped path: getOrCreateProjectStore is called for isGenerating resolution - expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith(secondarySession.projectId); + // Scoped path shares resolveProjectChatContext with list/create and can reuse the host store when no project engine is running. + expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled(); // isGenerating defaults to false (MockChatManager has no getGeneratingSessionIds) expect((response.body as any).sessions[0].isGenerating).toBe(false); }); diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 9586551525..517216770d 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -475,8 +475,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): /** * PATCH /api/chat/sessions/:id - * Update a chat session (title, status, thinkingLevel). - * Body: { title?: string, status?: "active" | "archived", thinkingLevel?: string | null } + * Update a chat session (title, status, thinkingLevel, model, or agent target). + * Body: { title?: string, status?: "active" | "archived", thinkingLevel?: string | null, + * modelProvider?: string | null, modelId?: string | null, agentId?: string } * * FNXC:Chat-ThinkingLevel 2026-07-12-19:30: * FN-7775 only let a user pick a session's thinking level at creation time @@ -487,16 +488,37 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): * create-time semantics where an absent/empty thinkingLevel means * "inherit"); omitting the key entirely leaves the session's stored * value untouched, matching the existing title/status behavior below. + * + * FNXC:Chat-ModelSwitch 2026-07-12-20:15: + * FN-7908 extends this SAME route (rather than adding a new one) so the + * brain-icon popup introduced by FN-7898 can also retarget an active + * Direct chat's model or switch it to a real agent mid-conversation. + * modelProvider/modelId are validated as a pair via the existing + * validateModelPair helper (used elsewhere in this file for task-planner + * session creation); agentId is validated as a non-empty string. Both are + * forwarded to chatStore.updateSession only when present in the body so + * omitted keys stay untouched, matching the thinkingLevel/title/status + * pattern above. */ router.patch("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => { try { const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined); const sessionId = String(req.params.id); - const { title, status, thinkingLevel: rawThinkingLevel } = req.body as { + const { + title, + status, + thinkingLevel: rawThinkingLevel, + modelProvider: rawModelProvider, + modelId: rawModelId, + agentId: rawAgentId, + } = req.body as { title?: string; status?: string; thinkingLevel?: string | null; + modelProvider?: string | null; + modelId?: string | null; + agentId?: string; }; // Validate status if provided @@ -518,10 +540,30 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): } } + // FNXC:Chat-ModelSwitch — modelProvider/modelId are only validated (and + // therefore only forwarded) when at least one of them is present in the + // body, so a PATCH that omits both keys entirely leaves the session's + // stored model target untouched instead of tripping the pair-mismatch + // check below. + const modelPairProvided = rawModelProvider !== undefined || rawModelId !== undefined; + const { modelProvider: normalizedModelProvider, modelId: normalizedModelId } = modelPairProvided + ? validateModelPair(rawModelProvider, rawModelId) + : {}; + + let normalizedAgentId: string | undefined; + if (rawAgentId !== undefined) { + if (typeof rawAgentId !== "string" || rawAgentId.trim() === "") { + throw badRequest("agentId must be a non-empty string"); + } + normalizedAgentId = rawAgentId.trim(); + } + const session = chatStore.updateSession(sessionId, { ...(title !== undefined && { title: title?.trim() || null }), ...(status !== undefined && { status }), ...(normalizedThinkingLevel !== undefined && { thinkingLevel: normalizedThinkingLevel }), + ...(modelPairProvided && { modelProvider: normalizedModelProvider ?? null, modelId: normalizedModelId ?? null }), + ...(normalizedAgentId !== undefined && { agentId: normalizedAgentId }), }); if (!session) { diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index dbd6c5ea8e..4a7bed8f85 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -1398,7 +1398,13 @@ "unreadMessages": "Unread messages", "untitledSession": "Untitled", "workingStatus": "Working…", - "you": "You" + "you": "You", + "modelAgentSection": "Model / Agent", + "thinkingLevelSection": "Thinking level", + "noModelsAvailable": "No models available", + "currentAgentTarget": "Current agent: {{name}}", + "currentModelTarget": "Current model: {{model}}", + "currentDefaultTarget": "Using the default chat target" }, "chatRooms": { "error": { diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 2050ca0d37..038abbfaf9 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -1388,7 +1388,13 @@ "workingStatus": "Trabajando…", "you": "Tú", "contextWindowAria": "Estimated {{used}} of {{total}} context tokens", - "noMessage": "" + "noMessage": "", + "modelAgentSection": "Model / Agent", + "thinkingLevelSection": "Thinking level", + "noModelsAvailable": "No models available", + "currentAgentTarget": "Current agent: {{name}}", + "currentModelTarget": "Current model: {{model}}", + "currentDefaultTarget": "Using the default chat target" }, "chatRooms": { "error": { diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index c05becb1bc..39c817757d 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -1388,7 +1388,13 @@ "workingStatus": "Traitement en cours…", "you": "Vous", "contextWindowAria": "Estimated {{used}} of {{total}} context tokens", - "noMessage": "" + "noMessage": "", + "modelAgentSection": "Model / Agent", + "thinkingLevelSection": "Thinking level", + "noModelsAvailable": "No models available", + "currentAgentTarget": "Current agent: {{name}}", + "currentModelTarget": "Current model: {{model}}", + "currentDefaultTarget": "Using the default chat target" }, "chatRooms": { "error": { diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 20d2891190..bd3f30fccb 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -1388,7 +1388,13 @@ "workingStatus": "작업 중…", "you": "나", "contextWindowAria": "Estimated {{used}} of {{total}} context tokens", - "noMessage": "" + "noMessage": "", + "modelAgentSection": "Model / Agent", + "thinkingLevelSection": "Thinking level", + "noModelsAvailable": "No models available", + "currentAgentTarget": "Current agent: {{name}}", + "currentModelTarget": "Current model: {{model}}", + "currentDefaultTarget": "Using the default chat target" }, "chatRooms": { "error": { diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index b8abe1eb28..c6d0e2f8fa 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -1388,7 +1388,13 @@ "workingStatus": "处理中……", "you": "你", "contextWindowAria": "Estimated {{used}} of {{total}} context tokens", - "noMessage": "" + "noMessage": "", + "modelAgentSection": "Model / Agent", + "thinkingLevelSection": "Thinking level", + "noModelsAvailable": "No models available", + "currentAgentTarget": "Current agent: {{name}}", + "currentModelTarget": "Current model: {{model}}", + "currentDefaultTarget": "Using the default chat target" }, "chatRooms": { "error": { diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 1e7478c018..da823c4252 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -1388,7 +1388,13 @@ "workingStatus": "處理中……", "you": "你", "contextWindowAria": "Estimated {{used}} of {{total}} context tokens", - "noMessage": "" + "noMessage": "", + "modelAgentSection": "Model / Agent", + "thinkingLevelSection": "Thinking level", + "noModelsAvailable": "No models available", + "currentAgentTarget": "Current agent: {{name}}", + "currentModelTarget": "Current model: {{model}}", + "currentDefaultTarget": "Using the default chat target" }, "chatRooms": { "error": {