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:
@@ -1,4 +1,4 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS } 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 } 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 { TaskStore } from "./store.js";
|
||||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||||
|
|||||||
@@ -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 const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"] as const;
|
||||||
export type Column = (typeof COLUMNS)[number];
|
export type Column = (typeof COLUMNS)[number];
|
||||||
|
|
||||||
@@ -110,6 +114,11 @@ export interface Settings {
|
|||||||
* Must be set together with `defaultProvider`. When both are undefined,
|
* Must be set together with `defaultProvider`. When both are undefined,
|
||||||
* the engine uses pi's automatic model resolution. */
|
* the engine uses pi's automatic model resolution. */
|
||||||
defaultModelId?: string;
|
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 = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -124,6 +133,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
includeTaskIdInCommit: true,
|
includeTaskIdInCommit: true,
|
||||||
defaultProvider: undefined,
|
defaultProvider: undefined,
|
||||||
defaultModelId: undefined,
|
defaultModelId: undefined,
|
||||||
|
defaultThinkingLevel: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BoardConfig {
|
export interface BoardConfig {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { THINKING_LEVELS } from "@kb/core";
|
||||||
import type { Settings } from "@kb/core";
|
import type { Settings } from "@kb/core";
|
||||||
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../api";
|
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../api";
|
||||||
import type { AuthProvider, ModelInfo } 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>
|
<small>Select the AI model used for agent sessions. "Use default" lets the engine choose automatically.</small>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -436,6 +436,53 @@ describe("SettingsModal", () => {
|
|||||||
expect(elementsWithStyle.length).toBe(0);
|
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 () => {
|
it("shows loading state during login", async () => {
|
||||||
// Make loginProvider hang
|
// Make loginProvider hang
|
||||||
(loginProvider as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
|
(loginProvider as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ export class TaskExecutor {
|
|||||||
onToolStart: agentLogger.onToolStart,
|
onToolStart: agentLogger.onToolStart,
|
||||||
defaultProvider: settings.defaultProvider,
|
defaultProvider: settings.defaultProvider,
|
||||||
defaultModelId: settings.defaultModelId,
|
defaultModelId: settings.defaultModelId,
|
||||||
|
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register session so the pause listener can terminate it
|
// Register session so the pause listener can terminate it
|
||||||
@@ -542,6 +543,7 @@ export class TaskExecutor {
|
|||||||
onText: (delta) => options.onAgentText?.(taskId, delta),
|
onText: (delta) => options.onAgentText?.(taskId, delta),
|
||||||
defaultProvider: settings.defaultProvider,
|
defaultProvider: settings.defaultProvider,
|
||||||
defaultModelId: settings.defaultModelId,
|
defaultModelId: settings.defaultModelId,
|
||||||
|
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export async function aiMergeTask(
|
|||||||
onToolStart: agentLogger.onToolStart,
|
onToolStart: agentLogger.onToolStart,
|
||||||
defaultProvider: settings.defaultProvider,
|
defaultProvider: settings.defaultProvider,
|
||||||
defaultModelId: settings.defaultModelId,
|
defaultModelId: settings.defaultModelId,
|
||||||
|
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface AgentOptions {
|
|||||||
defaultProvider?: string;
|
defaultProvider?: string;
|
||||||
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). Used with `defaultProvider`. */
|
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). Used with `defaultProvider`. */
|
||||||
defaultModelId?: string;
|
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 } : {}),
|
...(selectedModel ? { model: selectedModel } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply thinking level if specified
|
||||||
|
if (options.defaultThinkingLevel) {
|
||||||
|
session.setThinkingLevel(options.defaultThinkingLevel as any);
|
||||||
|
}
|
||||||
|
|
||||||
// Wire up event listeners
|
// Wire up event listeners
|
||||||
session.subscribe((event) => {
|
session.subscribe((event) => {
|
||||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ export interface ReviewOptions {
|
|||||||
defaultProvider?: string;
|
defaultProvider?: string;
|
||||||
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). When set with `defaultProvider`, overrides the reviewer's model selection. */
|
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). When set with `defaultProvider`, overrides the reviewer's model selection. */
|
||||||
defaultModelId?: string;
|
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),
|
onText: (delta) => options.onText?.(delta),
|
||||||
defaultProvider: options.defaultProvider,
|
defaultProvider: options.defaultProvider,
|
||||||
defaultModelId: options.defaultModelId,
|
defaultModelId: options.defaultModelId,
|
||||||
|
defaultThinkingLevel: options.defaultThinkingLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
let reviewText = "";
|
let reviewText = "";
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export class TriageProcessor {
|
|||||||
onToolStart: agentLogger.onToolStart,
|
onToolStart: agentLogger.onToolStart,
|
||||||
defaultProvider: settings.defaultProvider,
|
defaultProvider: settings.defaultProvider,
|
||||||
defaultModelId: settings.defaultModelId,
|
defaultModelId: settings.defaultModelId,
|
||||||
|
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user