feat(KB-119): add configurable thinking effort level

- Add defaultThinkingLevel to core Settings types and exports
- Thread thinking level through engine executor, merger, reviewer, triage, and pi
- Add effort dropdown to Model settings UI in SettingsModal
- Add tests for thinking effort dropdown in SettingsModal
This commit is contained in:
Dustin Byrne
2026-03-26 23:13:40 -04:00
parent 001217b5fb
commit dc821973fa
9 changed files with 101 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } from "./types.js";
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel } from "./types.js";
export { TaskStore } from "./store.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";

View File

@@ -1,3 +1,7 @@
/** Valid thinking effort levels for AI agent sessions, controlling the cost/quality tradeoff of reasoning. */
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const;
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"] as const;
export type Column = (typeof COLUMNS)[number];
@@ -110,6 +114,11 @@ export interface Settings {
* Must be set together with `defaultProvider`. When both are undefined,
* the engine uses pi's automatic model resolution. */
defaultModelId?: string;
/** Default thinking effort level for AI agent sessions.
* Controls how much reasoning effort the model uses — higher levels
* produce better results but cost more. When undefined, the engine
* uses the model's default thinking level. */
defaultThinkingLevel?: ThinkingLevel;
}
export const DEFAULT_SETTINGS: Settings = {
@@ -124,6 +133,7 @@ export const DEFAULT_SETTINGS: Settings = {
includeTaskIdInCommit: true,
defaultProvider: undefined,
defaultModelId: undefined,
defaultThinkingLevel: undefined,
};
export interface BoardConfig {

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { THINKING_LEVELS } from "@kb/core";
import type { Settings } from "@kb/core";
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../api";
import type { AuthProvider, ModelInfo } from "../api";
@@ -254,6 +255,33 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
<small>Select the AI model used for agent sessions. "Use default" lets the engine choose automatically.</small>
</div>
)}
{(() => {
const selectedModel = availableModels.find(
(m) => m.provider === form.defaultProvider && m.id === form.defaultModelId,
);
if (selectedModel && !selectedModel.reasoning) return null;
return (
<div className="form-group">
<label htmlFor="defaultThinkingLevel">Thinking Effort</label>
<select
id="defaultThinkingLevel"
value={form.defaultThinkingLevel || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, defaultThinkingLevel: val || undefined } as any));
}}
>
<option value="">Default</option>
{THINKING_LEVELS.map((level) => (
<option key={level} value={level}>
{level.charAt(0).toUpperCase() + level.slice(1)}
</option>
))}
</select>
<small>Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.</small>
</div>
);
})()}
</>
);
}

View File

@@ -436,6 +436,53 @@ describe("SettingsModal", () => {
expect(elementsWithStyle.length).toBe(0);
});
it("shows Thinking Effort dropdown with correct options in Model section", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const select = screen.getByLabelText("Thinking Effort") as HTMLSelectElement;
expect(select.tagName).toBe("SELECT");
const options = Array.from(select.options).map((o) => o.textContent);
expect(options).toEqual(["Default", "Off", "Minimal", "Low", "Medium", "High"]);
});
it("changing Thinking Effort dropdown updates form and is included in save payload", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const select = screen.getByLabelText("Thinking Effort") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "high" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.defaultThinkingLevel).toBe("high");
});
it("Thinking Effort dropdown is hidden when selected model does not support reasoning", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
expect(screen.queryByLabelText("Thinking Effort")).toBeNull();
});
it("shows loading state during login", async () => {
// Make loginProvider hang
(loginProvider as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));

View File

@@ -352,6 +352,7 @@ export class TaskExecutor {
onToolStart: agentLogger.onToolStart,
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
// Register session so the pause listener can terminate it
@@ -542,6 +543,7 @@ export class TaskExecutor {
onText: (delta) => options.onAgentText?.(taskId, delta),
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
},
);

View File

@@ -239,6 +239,7 @@ export async function aiMergeTask(
onToolStart: agentLogger.onToolStart,
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
try {

View File

@@ -34,6 +34,8 @@ export interface AgentOptions {
defaultProvider?: string;
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). Used with `defaultProvider`. */
defaultModelId?: string;
/** Default thinking effort level (e.g. "medium", "high"). When provided, sets the session's thinking level after creation. */
defaultThinkingLevel?: string;
}
/**
@@ -79,6 +81,11 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
...(selectedModel ? { model: selectedModel } : {}),
});
// Apply thinking level if specified
if (options.defaultThinkingLevel) {
session.setThinkingLevel(options.defaultThinkingLevel as any);
}
// Wire up event listeners
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {

View File

@@ -115,6 +115,8 @@ export interface ReviewOptions {
defaultProvider?: string;
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). When set with `defaultProvider`, overrides the reviewer's model selection. */
defaultModelId?: string;
/** Default thinking effort level for the reviewer agent session. */
defaultThinkingLevel?: string;
}
/**
@@ -143,6 +145,7 @@ export async function reviewStep(
onText: (delta) => options.onText?.(delta),
defaultProvider: options.defaultProvider,
defaultModelId: options.defaultModelId,
defaultThinkingLevel: options.defaultThinkingLevel,
});
let reviewText = "";

View File

@@ -273,6 +273,7 @@ export class TriageProcessor {
onToolStart: agentLogger.onToolStart,
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
try {