diff --git a/.changeset/fn-7775-chat-thinking-level.md b/.changeset/fn-7775-chat-thinking-level.md new file mode 100644 index 0000000000..2bc74c9d02 --- /dev/null +++ b/.changeset/fn-7775-chat-thinking-level.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Choose a thinking level when starting a new model chat. +category: feature +dev: Adds chat_sessions.thinkingLevel and passes it as the engine defaultThinkingLevel session option. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 31c19dd17e..903ad11669 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -493,6 +493,7 @@ Chat view provides project-scoped conversations with agents. - Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model - On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome. +- 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 it to model-loop replies. - 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/docs/settings-reference.md b/docs/settings-reference.md index 9ef20ddb73..4a171900e9 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -971,7 +971,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. -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)`; empty thinking values inherit through the lane/global/default chain and explicit values are cleared by the lane reset action. Runtime thinking precedence is node/step `config.thinkingLevel` > task `thinkingLevel` > workflow lane thinking override > global lane thinking override > project default thinking override > global `defaultThinkingLevel`, and the value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters). +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)`; 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). When the planning lane has neither `planningFallback*` nor a global `fallback*` pair configured, triage now derives an **implicit fallback** from the resolved project/global default (execution) model (FN-7719). This lets a retryable primary planner-model failure (e.g. a provider 404/429) recover via one distinct swap instead of permanently failing triage with "no fallback configured" — the operator's chosen primary planner lane is unchanged, and the implicit fallback is skipped when it would equal the primary model or when test mode is active. diff --git a/packages/core/src/__tests__/chat-store.test.ts b/packages/core/src/__tests__/chat-store.test.ts index ac76ba72bb..e9fa6f0d23 100644 --- a/packages/core/src/__tests__/chat-store.test.ts +++ b/packages/core/src/__tests__/chat-store.test.ts @@ -74,6 +74,7 @@ describe("ChatStore", () => { projectId: string | null; modelProvider: string | null; modelId: string | null; + thinkingLevel: string | null; }>, ) { return store.createSession({ @@ -82,6 +83,7 @@ describe("ChatStore", () => { projectId: overrides?.projectId ?? null, modelProvider: overrides?.modelProvider ?? null, modelId: overrides?.modelId ?? null, + thinkingLevel: overrides?.thinkingLevel ?? null, }); } @@ -99,6 +101,7 @@ describe("ChatStore", () => { expect(session.projectId).toBeNull(); expect(session.modelProvider).toBeNull(); expect(session.modelId).toBeNull(); + expect(session.thinkingLevel).toBeNull(); expect(session.createdAt).toBeTruthy(); expect(session.updatedAt).toBeTruthy(); expect(session.inFlightGeneration).toBeNull(); @@ -111,6 +114,7 @@ describe("ChatStore", () => { projectId: "proj-123", modelProvider: "anthropic", modelId: "claude-3", + thinkingLevel: "high", }); expect(session.agentId).toBe("agent-test"); @@ -118,6 +122,9 @@ describe("ChatStore", () => { expect(session.projectId).toBe("proj-123"); expect(session.modelProvider).toBe("anthropic"); expect(session.modelId).toBe("claude-3"); + expect(session.thinkingLevel).toBe("high"); + expect(store.getSession(session.id)?.thinkingLevel).toBe("high"); + expect(store.listSessions().find((listed) => listed.id === session.id)?.thinkingLevel).toBe("high"); }); it("generates unique IDs", () => { @@ -390,15 +397,18 @@ describe("ChatStore", () => { expect(updated!.status).toBe("archived"); }); - it("updates model fields", () => { + it("updates model and thinking-level fields", () => { const session = createTestSession(store); const updated = store.updateSession(session.id, { modelProvider: "openai", modelId: "gpt-4o", + thinkingLevel: "off", }); expect(updated!.modelProvider).toBe("openai"); expect(updated!.modelId).toBe("gpt-4o"); + expect(updated!.thinkingLevel).toBe("off"); + expect(store.getSession(session.id)?.thinkingLevel).toBe("off"); }); it("returns undefined for non-existent session", () => { @@ -411,17 +421,20 @@ describe("ChatStore", () => { title: "Has title", modelProvider: "anthropic", modelId: "claude", + thinkingLevel: "high", }); const updated = store.updateSession(session.id, { title: null, modelProvider: null, modelId: null, + thinkingLevel: null, }); expect(updated!.title).toBeNull(); expect(updated!.modelProvider).toBeNull(); expect(updated!.modelId).toBeNull(); + expect(updated!.thinkingLevel).toBeNull(); }); }); diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index 6eb4fb6554..217afe9e9e 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -1315,6 +1315,39 @@ describe("schema migration", () => { db.close(); }); + it("adds thinkingLevel to chat_sessions when migrating from schema version 139", () => { + 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', '139')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec(` + CREATE TABLE IF NOT EXISTS chat_sessions ( + id TEXT PRIMARY KEY, + agentId TEXT NOT NULL, + title TEXT, + status TEXT NOT NULL DEFAULT 'active', + projectId TEXT, + modelProvider TEXT, + modelId TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + cliSessionFile TEXT, + inFlightGeneration TEXT, + cliExecutorAdapterId TEXT + ) + `); + + db.init(); + + const columns = db + .prepare("PRAGMA table_info(chat_sessions)") + .all() as Array<{ name: string }>; + expect(columns.map((column) => column.name)).toContain("thinkingLevel"); + + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + db.close(); + }); + it("creates cli_sessions on a fresh database (fresh-create path)", () => { const db = new Database(fusionDir); db.init(); diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index 8543777283..18a21ae77e 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -85,6 +85,7 @@ interface ChatSessionRow { projectId: string | null; modelProvider: string | null; modelId: string | null; + thinkingLevel: string | null; createdAt: string; updatedAt: string; cliSessionFile: string | null; @@ -179,6 +180,7 @@ export class ChatStore extends EventEmitter { projectId: row.projectId ?? null, modelProvider: row.modelProvider ?? null, modelId: row.modelId ?? null, + thinkingLevel: row.thinkingLevel ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, cliSessionFile: row.cliSessionFile ?? null, @@ -293,6 +295,7 @@ export class ChatStore extends EventEmitter { projectId: input.projectId ?? null, modelProvider: input.modelProvider ?? null, modelId: input.modelId ?? null, + thinkingLevel: input.thinkingLevel ?? null, createdAt: now, updatedAt: now, cliSessionFile: null, @@ -301,8 +304,8 @@ export class ChatStore extends EventEmitter { }; this.db.prepare(` - INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration, cliExecutorAdapterId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, thinkingLevel, createdAt, updatedAt, inFlightGeneration, cliExecutorAdapterId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( session.id, session.agentId, @@ -311,6 +314,7 @@ export class ChatStore extends EventEmitter { session.projectId, session.modelProvider, session.modelId, + session.thinkingLevel, session.createdAt, session.updatedAt, null, @@ -469,6 +473,10 @@ export class ChatStore extends EventEmitter { setClauses.push("modelId = ?"); params.push(input.modelId); } + if (input.thinkingLevel !== undefined) { + setClauses.push("thinkingLevel = ?"); + params.push(input.thinkingLevel); + } params.push(id); diff --git a/packages/core/src/chat-types.ts b/packages/core/src/chat-types.ts index 494604a597..a55295d881 100644 --- a/packages/core/src/chat-types.ts +++ b/packages/core/src/chat-types.ts @@ -56,6 +56,8 @@ export interface ChatSession { modelProvider: string | null; /** AI model ID for this session (optional, overrides defaults) */ modelId: string | null; + /** Optional thinking/reasoning-effort override for this session (optional, overrides defaults) */ + thinkingLevel: string | null; /** When the session was created */ createdAt: string; /** When the session was last updated */ @@ -212,6 +214,8 @@ export interface ChatSessionCreateInput { modelProvider?: string | null; /** Optional model ID override */ modelId?: string | null; + /** Optional thinking/reasoning-effort override */ + thinkingLevel?: string | null; /** Optional cli-agent adapter id; when set the chat is CLI-backed (U12) */ cliExecutorAdapterId?: string | null; } @@ -229,6 +233,8 @@ export interface ChatSessionUpdateInput { modelProvider?: string | null; /** Model ID override */ modelId?: string | null; + /** Thinking/reasoning-effort override */ + thinkingLevel?: string | null; } /** diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 34cab37bd0..e484f009f6 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -184,7 +184,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 139; +const SCHEMA_VERSION = 140; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -5644,6 +5644,18 @@ export class Database { }); } + if (version < 140) { + /* + * FNXC:Chat-ThinkingLevel 2026-07-10-00:00: + * Chat sessions store an optional per-session reasoning-effort level so model-loop chats can pass it as the engine `defaultThinkingLevel`; NULL means inherit the resolved project/global default. + */ + this.applyMigration(140, () => { + if (this.hasTable("chat_sessions")) { + this.addColumnIfMissing("chat_sessions", "thinkingLevel", "TEXT"); + } + }); + } + } /** diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index ab4dad28c0..5f6e27dbd7 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -10200,7 +10200,7 @@ export async function fetchResumeChatSession( /** Create a new chat session */ export function createChatSession( - input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }, + input: { agentId: string; title?: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }, projectId?: string, ): Promise { return api(withProjectId("/chat/sessions", projectId), { diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 10870b53b4..a06cc6e78d 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -259,7 +259,7 @@ interface NewChatDialogProps { projectId?: string; defaultModel: DefaultModelSelection; onClose: () => void; - onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void; + onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }) => void; } function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDialogProps) { @@ -272,6 +272,11 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi ? `${defaultModel.provider}/${defaultModel.modelId}` : ""; const [selectedModel, setSelectedModel] = useState(defaultModelValue); + /* + * FNXC:Chat-ThinkingLevel 2026-07-10-00:00: + * New model-mode chats expose the shared inline thinking selector; an empty value means Default and is omitted from the create-session payload so the backend resolves project/global reasoning effort. + */ + const [thinkingLevel, setThinkingLevel] = useState(""); const [favoriteProviders, setFavoriteProviders] = useState(cachedFavoriteProviders); const [favoriteModels, setFavoriteModels] = useState(cachedFavoriteModels); @@ -341,7 +346,7 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi if (slashIdx <= 0) return; const modelProvider = resolvedModel.slice(0, slashIdx); const modelId = resolvedModel.slice(slashIdx + 1); - onCreate({ agentId: FN_AGENT_ID, modelProvider, modelId }); + onCreate({ agentId: FN_AGENT_ID, modelProvider, modelId, thinkingLevel: thinkingLevel || undefined }); }; const isSubmitDisabled = @@ -417,6 +422,10 @@ function NewChatDialog({ projectId, defaultModel, onClose, onCreate }: NewChatDi onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} + showThinkingLevel + thinkingLevel={thinkingLevel} + onThinkingLevelChange={setThinkingLevel} + defaultThinkingLevel="off" /> )} @@ -1400,7 +1409,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout // Handle create session const handleCreateSession = useCallback( - async (input: { agentId: string; modelProvider?: string; modelId?: string }) => { + async (input: { agentId: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }) => { try { await createSession(input); setShowNewDialog(false); @@ -1477,6 +1486,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout agentId: activeSession.agentId, modelProvider: activeSession.modelProvider ?? undefined, modelId: activeSession.modelId ?? undefined, + thinkingLevel: activeSession.thinkingLevel ?? undefined, }).catch(() => { addToast(t("chat.failedToClearConversation", "Failed to clear conversation"), "error"); }); diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx index 96a7419eaa..753dce194d 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx @@ -73,21 +73,43 @@ vi.mock("../CustomModelDropdown", () => ({ value, onChange, label, + showThinkingLevel, + thinkingLevel, + onThinkingLevelChange, + defaultThinkingLevel, }: { value: string; onChange: (value: string) => void; label: string; + showThinkingLevel?: boolean; + thinkingLevel?: string; + onThinkingLevelChange?: (value: string) => void; + defaultThinkingLevel?: string; }) => ( - +
+ + {showThinkingLevel && ( + + )} +
), })); @@ -1034,6 +1056,39 @@ describe("ChatView core interactions", () => { expect(emptyState?.querySelector("select")).toBeNull(); }); + it("creates a model-mode new chat with the selected thinking level", async () => { + const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "__fn_agent__", status: "active", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }); + setupMockChat({ sessions: [], filteredSessions: [], createSession }); + + await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-new-btn")); + await userEvent.click(screen.getByTestId("chat-new-dialog-mode-model")); + expect(await screen.findByTestId("mock-thinking-level")).toBeInTheDocument(); + + await userEvent.selectOptions(screen.getByTestId("mock-thinking-level"), "high"); + await userEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => { + expect(createSession).toHaveBeenCalledWith({ + agentId: "__fn_agent__", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + thinkingLevel: "high", + }); + }); + }); + + it("does not render a thinking-level control in agent-mode new chat", async () => { + setupMockChat({ sessions: [], filteredSessions: [], createSession: vi.fn() }); + + await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-new-btn")); + + expect(screen.queryByTestId("mock-thinking-level")).not.toBeInTheDocument(); + }); + it("shows context menu on right-click", async () => { setupMockChat({ sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index ea3c672a73..b9515756c1 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -70,6 +70,7 @@ function makeSession(overrides: Partial & Pick { })); }); - it("creates a new session and selects it", async () => { - const newSession = makeSession({ id: "session-new", agentId: "agent-001", title: "Test Chat" }); + it("creates a new session with thinking level and selects it", async () => { + const newSession = makeSession({ id: "session-new", agentId: "agent-001", title: "Test Chat", thinkingLevel: "medium" }); mockCreateChatSession.mockResolvedValueOnce({ session: newSession }); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [] }); @@ -865,18 +866,28 @@ describe("useChat", () => { createdSession = await result.current.createSession({ agentId: "agent-001", title: "Test Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + thinkingLevel: "medium", }); }); await waitFor(() => { expect(mockCreateChatSession).toHaveBeenCalledWith( - { agentId: "agent-001", title: "Test Chat" }, + { + agentId: "agent-001", + title: "Test Chat", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + thinkingLevel: "medium", + }, undefined, ); }); await waitFor(() => { expect(result.current.activeSession?.id).toBe("session-new"); + expect(result.current.activeSession?.thinkingLevel).toBe("medium"); expect(result.current.sessions).toHaveLength(1); }); }); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 05e513cfb7..25909532fa 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -37,6 +37,7 @@ export interface ChatSessionInfo { status: string; modelProvider?: string | null; modelId?: string | null; + thinkingLevel?: string | null; createdAt: string; updatedAt: string; lastMessagePreview?: string; @@ -92,7 +93,7 @@ export interface UseChatReturn { // Session operations selectSession: (id: string, sessionOverride?: ChatSessionInfo) => void; createSession: ( - input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }, + input: { agentId: string; title?: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }, ) => Promise; archiveSession: (id: string) => Promise; renameSession: (id: string, title: string) => Promise; @@ -888,7 +889,7 @@ export function useChat( // Create a new session const createSession = useCallback( - async (input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }) => { + async (input: { agentId: string; title?: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }) => { const previousSessionId = activeSessionRef.current?.id; const data = await apiCreateChatSession(input, projectId); @@ -904,6 +905,7 @@ export function useChat( status: data.session.status, modelProvider: data.session.modelProvider, modelId: data.session.modelId, + thinkingLevel: data.session.thinkingLevel, createdAt: data.session.createdAt, updatedAt: data.session.updatedAt, }; diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index d209d7d5a0..1384dd980f 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -113,6 +113,10 @@ function createChatManagerWithSettings(settings: { fallbackModelId?: string; defaultProvider?: string; defaultModelId?: string; + defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + defaultThinkingLevelOverride?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + executionThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + executionGlobalThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; }): ChatManager { return new ChatManager( mockChatStore as any, @@ -242,6 +246,92 @@ describe("ChatManager.sendMessage", () => { }); }); + it("passes the chat session thinking level to model-loop session options", async () => { + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + model: { provider: "anthropic", id: "claude-sonnet-4-5" }, + state: { messages: [{ role: "assistant", content: "Thoughtful response" }] }, + }, + runtimeId: "anthropic", + wasConfigured: true, + } as any; + }); + mockChatStore.getSession.mockReturnValue({ + id: "chat-001", + agentId: "agent-001", + status: "active", + projectId: "project-a", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + thinkingLevel: "high", + }); + + const chatManager = createChatManagerWithSettings({ defaultThinkingLevel: "low" }); + await chatManager.sendMessage("chat-001", "Hello"); + + expect(createOptions.defaultThinkingLevel).toBe("high"); + }); + + it("falls back to settings when chat session thinking level is empty", async () => { + let createOptions: any; + __setCreateResolvedAgentSession(async (options: any) => { + createOptions = options; + return { + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + model: { provider: "anthropic", id: "claude-sonnet-4-5" }, + state: { messages: [{ role: "assistant", content: "Default-thinking response" }] }, + }, + runtimeId: "anthropic", + wasConfigured: true, + } as any; + }); + mockChatStore.getSession.mockReturnValue({ + id: "chat-001", + agentId: "agent-001", + status: "active", + projectId: "project-a", + thinkingLevel: null, + }); + + const chatManager = createChatManagerWithSettings({ executionThinkingLevel: "medium", defaultThinkingLevel: "low" }); + await chatManager.sendMessage("chat-001", "Hello"); + + expect(createOptions.defaultThinkingLevel).toBe("medium"); + }); + + it("does not thread thinking level into CLI-agent-backed chat", async () => { + const createResolvedSpy = vi.fn(); + __setCreateResolvedAgentSession(createResolvedSpy as any); + mockChatStore.getSession.mockReturnValue({ + id: "chat-001", + agentId: "agent-001", + status: "active", + projectId: "project-a", + cliExecutorAdapterId: "adapter-1", + thinkingLevel: "high", + }); + const runner = { + ensureSession: vi.fn().mockResolvedValue("cli-session-1"), + send: vi.fn().mockResolvedValue("sent"), + getTokenUsageSnapshot: vi.fn().mockResolvedValue(undefined), + getSessionStats: vi.fn().mockResolvedValue(undefined), + }; + + const chatManager = createChatManagerWithSettings({ defaultThinkingLevel: "low" }); + chatManager.setCliChatRunner(runner as any, "project-a"); + await chatManager.sendMessage("chat-001", "Hello CLI"); + + expect(runner.send).toHaveBeenCalledWith("chat-001", "Hello CLI"); + expect(createResolvedSpy).not.toHaveBeenCalled(); + }); + it("records successful chat session token usage from provider stats", async () => { __setCreateResolvedAgentSession(async () => ({ session: { diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 259b53ff71..1c01ae60dc 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -54,6 +54,7 @@ import { createChatTaskDocumentTools, createWorkflowAuthoringTools, resolveMcpServersForStore, + resolveExecutorThinkingLevel, } from "@fusion/engine"; import * as engineModule from "@fusion/engine"; @@ -1057,6 +1058,10 @@ export class ChatManager { | "fallbackModelId" | "defaultProvider" | "defaultModelId" + | "defaultThinkingLevel" + | "defaultThinkingLevelOverride" + | "executionThinkingLevel" + | "executionGlobalThinkingLevel" | "chatRoomRecentVerbatimMessages" | "chatRoomCompactionFetchLimit" | "chatRoomSummaryMaxChars" @@ -1066,6 +1071,10 @@ export class ChatManager { | "fallbackModelId" | "defaultProvider" | "defaultModelId" + | "defaultThinkingLevel" + | "defaultThinkingLevelOverride" + | "executionThinkingLevel" + | "executionGlobalThinkingLevel" | "chatRoomRecentVerbatimMessages" | "chatRoomCompactionFetchLimit" | "chatRoomSummaryMaxChars" @@ -1156,6 +1165,10 @@ export class ChatManager { fallbackModelId?: string; defaultProvider?: string; defaultModelId?: string; + defaultThinkingLevel?: Settings["defaultThinkingLevel"]; + defaultThinkingLevelOverride?: Settings["defaultThinkingLevelOverride"]; + executionThinkingLevel?: Settings["executionThinkingLevel"]; + executionGlobalThinkingLevel?: Settings["executionGlobalThinkingLevel"]; }> { if (!this.getSettings) { return {}; @@ -1168,6 +1181,10 @@ export class ChatManager { fallbackModelId: settings?.fallbackModelId ?? undefined, defaultProvider: settings?.defaultProvider ?? undefined, defaultModelId: settings?.defaultModelId ?? undefined, + defaultThinkingLevel: settings?.defaultThinkingLevel ?? undefined, + defaultThinkingLevelOverride: settings?.defaultThinkingLevelOverride ?? undefined, + executionThinkingLevel: settings?.executionThinkingLevel ?? undefined, + executionGlobalThinkingLevel: settings?.executionGlobalThinkingLevel ?? undefined, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -2208,6 +2225,11 @@ export class ChatManager { !hasExplicitAgentRuntimeModel || usesConfiguredDefaultModel || !!(requestedModelProvider && requestedModelId); + /* + * FNXC:Chat-ThinkingLevel 2026-07-10-00:00: + * Model-loop chat sessions apply the per-session thinking level through the engine `defaultThinkingLevel` session option; an empty session value inherits the project/global execution default resolved by resolveExecutorThinkingLevel. + */ + const effectiveThinkingLevel = resolveExecutorThinkingLevel(session.thinkingLevel ?? undefined, chatModelSettings); const messagingTools = agent?.id && this.messageStore ? [ @@ -2260,6 +2282,7 @@ export class ChatManager { defaultModelId: effectiveModelId, } : {}), + ...(effectiveThinkingLevel ? { defaultThinkingLevel: effectiveThinkingLevel } : {}), ...(allowFallback && chatModelSettings.fallbackProvider && chatModelSettings.fallbackModelId ? { fallbackProvider: chatModelSettings.fallbackProvider, diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 27a3dd2d32..50f6defbc6 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { createReadStream } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; -import type { EnrichedChatSession, ChatAttachment } from "@fusion/core"; +import { THINKING_LEVELS, type EnrichedChatSession, type ChatAttachment } from "@fusion/core"; import { ApiError, badRequest, notFound } from "../api-error.js"; import { resolveProjectChatContext } from "../chat-project-services.js"; import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_ATTACHMENT_SIZE } from "./chat-attachment-config.js"; @@ -120,6 +120,21 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): const pluginRunner = projectPluginRunner ?? options?.pluginRunner; return getOrCreateScopedChatManager(projectStore, chatStore, pluginRunner, Boolean(projectPluginRunner)); } + const THINKING_LEVEL_SET = new Set(THINKING_LEVELS); + + function validateThinkingLevel(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw badRequest("thinkingLevel must be a string"); + } + const normalized = value.trim(); + if (!normalized) return undefined; + if (!THINKING_LEVEL_SET.has(normalized)) { + throw badRequest(`thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}`); + } + return normalized; + } + function validateModelPair(modelProvider: unknown, modelId: unknown): { modelProvider?: string; modelId?: string } { let normalizedProvider: string | undefined; let normalizedModelId: string | undefined; @@ -340,7 +355,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): /** * POST /api/chat/sessions * Create a new chat session. - * Body: { agentId: string, title?: string, modelProvider?: string, modelId?: string } + * Body: { agentId: string, title?: string, modelProvider?: string, modelId?: string, thinkingLevel?: string } * If modelProvider and modelId are provided, those are used. Otherwise the model is * resolved from the agent's runtimeConfig.model setting. * The session is scoped to the project identified by projectId query param or header. @@ -354,17 +369,20 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); - const { agentId, title, modelProvider, modelId } = req.body as { + const { agentId, title, modelProvider, modelId, thinkingLevel: rawThinkingLevel } = req.body as { agentId?: string; title?: string; modelProvider?: string; modelId?: string; + thinkingLevel?: string; }; if (!agentId || typeof agentId !== "string" || !agentId.trim()) { throw badRequest("agentId is required"); } + const thinkingLevel = validateThinkingLevel(rawThinkingLevel); + // Validate that if one model field is provided, the other must also be provided const hasClientModelProvider = typeof modelProvider === "string" && modelProvider.trim() !== ""; const hasClientModelId = typeof modelId === "string" && modelId.trim() !== ""; @@ -402,6 +420,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): projectId: projectId ?? null, modelProvider: resolvedProvider, modelId: resolvedModelId, + ...(thinkingLevel ? { thinkingLevel } : {}), }); res.status(201).json({ session }); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e0204278b4..97b8339461 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -834,6 +834,7 @@ export { createResolvedAgentSession, promptWithAutoRetry, describeAgentModel, + resolveExecutorThinkingLevel, extractRuntimeHint, extractRuntimeModel, type ResolvedSessionOptions,