FN-7775: Add thinking level selection to new chat model picker

Adds a per-session thinking-level selector to the new chat model dropdown, persisting the choice through the chat store and engine session options.

- Adds thinkingLevel column to chat_sessions with a db migration
- Extends chat-store, chat-types, and chat.ts to read/write thinkingLevel
- ChatView model selector now exposes a thinking-level control alongside model choice
- useChat and register-chat-routes plumb thinkingLevel through session creation/API
- engine/src/index.ts passes thinkingLevel as defaultThinkingLevel session option
- Adds a minor changeset and updates settings-reference/dashboard-guide docs
- Adds/updates unit tests across core and dashboard packages

Files changed:
 .changeset/fn-7775-chat-thinking-level.md          |  7 ++
 docs/dashboard-guide.md                            |  1 +
 docs/settings-reference.md                         |  2 +-
 packages/core/src/__tests__/chat-store.test.ts     | 15 +++-
 packages/core/src/__tests__/db-migrate.test.ts     | 33 ++++++++
 packages/core/src/chat-store.ts                    | 12 ++-
 packages/core/src/chat-types.ts                    |  6 ++
 packages/core/src/db.ts                            | 14 +++-
 packages/dashboard/app/api/legacy.ts               |  2 +-
 packages/dashboard/app/components/ChatView.tsx     | 16 +++-
 .../__tests__/ChatView.core-interactions.test.tsx  | 75 +++++++++++++++---
 .../dashboard/app/hooks/__tests__/useChat.test.ts  | 17 +++-
 packages/dashboard/app/hooks/useChat.ts            |  6 +-
 .../dashboard/src/__tests__/chat-manager.test.ts   | 90 ++++++++++++++++++++++
 packages/dashboard/src/chat.ts                     | 23 ++++++
 .../dashboard/src/routes/register-chat-routes.ts   | 25 +++++-
 packages/engine/src/index.ts                       |  1 +
 17 files changed, 318 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-7775

Fusion-Task-Lineage: e16c7d3b-361e-4908-87ad-10be17a47c47

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 08:00:34 -07:00
parent 57c3d7ceb8
commit 035caca6c8
17 changed files with 318 additions and 27 deletions

View File

@@ -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.

View File

@@ -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)
<!-- FNXC:ChatEmptyMessage 2026-07-10-00:00: Empty final assistant responses can be legitimate provider output (for example a Grok CLI run ending without text). Document the shared Chat/Planner Chat behavior so operators see "No message" instead of interpreting a blank bubble as a rendering failure. -->
- 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.

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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();

View File

@@ -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<ChatStoreEvents> {
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<ChatStoreEvents> {
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<ChatStoreEvents> {
};
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<ChatStoreEvents> {
session.projectId,
session.modelProvider,
session.modelId,
session.thinkingLevel,
session.createdAt,
session.updatedAt,
null,
@@ -469,6 +473,10 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
setClauses.push("modelId = ?");
params.push(input.modelId);
}
if (input.thinkingLevel !== undefined) {
setClauses.push("thinkingLevel = ?");
params.push(input.thinkingLevel);
}
params.push(id);

View File

@@ -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;
}
/**

View File

@@ -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");
}
});
}
}
/**

View File

@@ -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<ChatSessionResponse> {
return api<ChatSessionResponse>(withProjectId("/chat/sessions", projectId), {

View File

@@ -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<string>(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<string>("");
const [favoriteProviders, setFavoriteProviders] = useState<string[]>(cachedFavoriteProviders);
const [favoriteModels, setFavoriteModels] = useState<string[]>(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"
/>
)}
</div>
@@ -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");
});

View File

@@ -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;
}) => (
<select
data-testid="mock-model-dropdown"
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">Use default</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
<div>
<select
data-testid="mock-model-dropdown"
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">Use default</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
{showThinkingLevel && (
<select
data-testid="mock-thinking-level"
aria-label="Thinking Level"
value={thinkingLevel || ""}
onChange={(event) => onThinkingLevelChange?.(event.target.value)}
>
<option value="">Default ({defaultThinkingLevel ?? "off"})</option>
<option value="medium">medium</option>
<option value="high">high</option>
</select>
)}
</div>
),
}));
@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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" }],

View File

@@ -70,6 +70,7 @@ function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" |
projectId: overrides.projectId ?? null,
modelProvider: overrides.modelProvider ?? null,
modelId: overrides.modelId ?? null,
thinkingLevel: overrides.thinkingLevel ?? null,
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-04-08T00:00:00.000Z",
};
@@ -849,8 +850,8 @@ describe("useChat", () => {
}));
});
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);
});
});

View File

@@ -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<ChatSessionInfo>;
archiveSession: (id: string) => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>;
@@ -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,
};

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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<string>(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 });

View File

@@ -834,6 +834,7 @@ export {
createResolvedAgentSession,
promptWithAutoRetry,
describeAgentModel,
resolveExecutorThinkingLevel,
extractRuntimeHint,
extractRuntimeModel,
type ResolvedSessionOptions,