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:
7
.changeset/fn-7775-chat-thinking-level.md
Normal file
7
.changeset/fn-7775-chat-thinking-level.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -834,6 +834,7 @@ export {
|
||||
createResolvedAgentSession,
|
||||
promptWithAutoRetry,
|
||||
describeAgentModel,
|
||||
resolveExecutorThinkingLevel,
|
||||
extractRuntimeHint,
|
||||
extractRuntimeModel,
|
||||
type ResolvedSessionOptions,
|
||||
|
||||
Reference in New Issue
Block a user