mirror of
https://github.com/proxysoul/Empryo.git
synced 2026-11-07 06:25:57 -10:00
feat(hearth): expose provider settings as remote slash commands
- shared registry in src/hearth/provider-commands.ts (16 settings) - adds /thinking /effort /speed /reasoning /tier /budget - adds /codeexec /websearch /computeruse toggles - adds /compact /cleartools /clearthinking /pruning - adds /sendreasoning /toolstream /seqtools - adds /settings overview - wired into TuiHost + daemon command paths - writes ~/.soulforge/config.json via saveGlobalConfig Co-Authored-By: SoulForge <[email protected]>
This commit is contained in:
4 files changed
+612
No files matched your search
@@ -33,6 +33,11 @@ import { PairingRegistry } from "./pairing.js";
|
||||
import { checkPeer } from "./peer-auth.js";
|
||||
import { describeTool, evaluatePolicy } from "./policy.js";
|
||||
import { attachFrameReader, writeFrame } from "./protocol.js";
|
||||
import {
|
||||
handleSettingsCommand,
|
||||
SETTINGS_COMMAND_NAMES,
|
||||
settingsHelpLines,
|
||||
} from "./provider-commands.js";
|
||||
import { installGlobalRedaction, redact } from "./redact.js";
|
||||
import { ChatWorkspaceRegistry } from "./registry.js";
|
||||
import { SurfaceHost } from "./surface-host.js";
|
||||
@@ -751,6 +756,15 @@ export class HearthDaemon {
|
||||
const ws = this.workspaces.get(surfaceId, msg.externalId);
|
||||
const hasBridge = hearthBridge.getBinding(surfaceId, msg.externalId) !== null;
|
||||
|
||||
// Provider-settings commands — shared with TuiHost via provider-commands.ts.
|
||||
// These write the global config, so they work regardless of bridge binding.
|
||||
if (SETTINGS_COMMAND_NAMES.includes(cmd.name)) {
|
||||
const handled = await handleSettingsCommand(cmd.name, cmd.args, (text) =>
|
||||
surface.notify(msg.externalId, text),
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
// Bridge mode: a TUI tab is bound to this chat. /tab, /tabs, /stop,
|
||||
// /mute and /unmute route through the bridge. /pair, /new, /close still
|
||||
// manage daemon-side state if someone wants it.
|
||||
@@ -1208,6 +1222,9 @@ export class HearthDaemon {
|
||||
"\u2501\u2501 Pairing \u2501\u2501",
|
||||
"/pair [CODE] pair this chat",
|
||||
"/unpair revoke",
|
||||
"",
|
||||
...settingsHelpLines(),
|
||||
"",
|
||||
"/help this list",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Provider-settings slash commands for Hearth surfaces (Telegram/Discord).
|
||||
*
|
||||
* Mirrors a useful subset of the TUI's /provider-settings UI as one-shot
|
||||
* remote commands. Writes go to the *global* config (~/.soulforge/config.json)
|
||||
* via saveGlobalConfig — same path the settings UI uses — so changes apply
|
||||
* to every new turn after the patch is written.
|
||||
*
|
||||
* The implementation is intentionally provider-agnostic: each setting is a
|
||||
* SettingDef with a cycle/toggle/budget shape, identical to the React UI in
|
||||
* ProviderSettings.tsx. The handler is invoked from both TuiHost and the
|
||||
* daemon-side surface command switch with a small adapter (`notify`).
|
||||
*/
|
||||
|
||||
import { loadConfig, saveGlobalConfig } from "../config/index.js";
|
||||
import type {
|
||||
AppConfig,
|
||||
ContextManagementConfig,
|
||||
EffortLevel,
|
||||
PerformanceConfig,
|
||||
ThinkingMode,
|
||||
} from "../types/index.js";
|
||||
|
||||
type Notify = (text: string) => void | Promise<void>;
|
||||
|
||||
type SettingType = "cycle" | "toggle" | "budget";
|
||||
|
||||
interface SettingDef {
|
||||
/** Slash command (without leading "/"). */
|
||||
cmd: string;
|
||||
/** Human label for /help. */
|
||||
label: string;
|
||||
/** Allowed values for "cycle". Booleans use on/off. Budget uses ints. */
|
||||
type: SettingType;
|
||||
options?: readonly string[];
|
||||
/** Read current value out of an effective config. */
|
||||
read: (cfg: AppConfig) => string;
|
||||
/** Build a saveGlobalConfig patch for the parsed value. */
|
||||
patch: (raw: string) => Partial<AppConfig>;
|
||||
}
|
||||
|
||||
const THINKING_MODES = ["off", "disabled", "auto", "adaptive", "enabled"] as const;
|
||||
const EFFORT_LEVELS = ["off", "low", "medium", "high", "xhigh", "max"] as const;
|
||||
const SPEEDS = ["off", "standard", "fast"] as const;
|
||||
const OAI_EFFORTS = ["off", "none", "minimal", "low", "medium", "high", "xhigh"] as const;
|
||||
const SERVICE_TIERS = ["off", "auto", "default", "flex", "priority"] as const;
|
||||
const PRUNING = ["none", "main", "subagents", "both"] as const;
|
||||
|
||||
function showBool(v: boolean | undefined, fallback = false): string {
|
||||
return (v ?? fallback) ? "on" : "off";
|
||||
}
|
||||
|
||||
function parseBool(raw: string): boolean | null {
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (v === "on" || v === "true" || v === "1" || v === "yes") return true;
|
||||
if (v === "off" || v === "false" || v === "0" || v === "no") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** All supported settings. Keep cmd names short — they're typed on a phone. */
|
||||
export const SETTINGS: SettingDef[] = [
|
||||
{
|
||||
cmd: "thinking",
|
||||
label: "thinking mode",
|
||||
type: "cycle",
|
||||
options: THINKING_MODES,
|
||||
read: (c) => c.thinking?.mode ?? "off",
|
||||
patch: (raw) => ({ thinking: { mode: raw as ThinkingMode } }),
|
||||
},
|
||||
{
|
||||
cmd: "budget",
|
||||
label: "thinking budget tokens",
|
||||
type: "budget",
|
||||
read: (c) => String(c.thinking?.budgetTokens ?? 10000),
|
||||
patch: (raw) => ({
|
||||
thinking: { mode: "enabled", budgetTokens: Number.parseInt(raw, 10) },
|
||||
}),
|
||||
},
|
||||
{
|
||||
cmd: "effort",
|
||||
label: "reasoning effort",
|
||||
type: "cycle",
|
||||
options: EFFORT_LEVELS,
|
||||
read: (c) => c.performance?.effort ?? "off",
|
||||
patch: (raw) => ({
|
||||
performance: { effort: raw as EffortLevel | "off" } as PerformanceConfig,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cmd: "speed",
|
||||
label: "speed (Opus 4.6)",
|
||||
type: "cycle",
|
||||
options: SPEEDS,
|
||||
read: (c) => c.performance?.speed ?? "off",
|
||||
patch: (raw) => ({
|
||||
performance: { speed: raw as "off" | "standard" | "fast" } as PerformanceConfig,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cmd: "reasoning",
|
||||
label: "OpenAI reasoning effort",
|
||||
type: "cycle",
|
||||
options: OAI_EFFORTS,
|
||||
read: (c) => c.performance?.openaiReasoningEffort ?? "off",
|
||||
patch: (raw) => ({
|
||||
performance: { openaiReasoningEffort: raw } as PerformanceConfig,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cmd: "tier",
|
||||
label: "OpenAI service tier",
|
||||
type: "cycle",
|
||||
options: SERVICE_TIERS,
|
||||
read: (c) => c.performance?.serviceTier ?? "off",
|
||||
patch: (raw) => ({
|
||||
performance: { serviceTier: raw } as PerformanceConfig,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cmd: "sendreasoning",
|
||||
label: "send reasoning across turns",
|
||||
type: "toggle",
|
||||
read: (c) => showBool(c.performance?.sendReasoning),
|
||||
patch: (raw) => ({
|
||||
performance: { sendReasoning: parseBool(raw) ?? false } as PerformanceConfig,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cmd: "toolstream",
|
||||
label: "tool arg streaming",
|
||||
type: "toggle",
|
||||
read: (c) => showBool(c.performance?.toolStreaming, true),
|
||||
patch: (raw) => ({
|
||||
performance: { toolStreaming: parseBool(raw) ?? true } as PerformanceConfig,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cmd: "seqtools",
|
||||
label: "sequential tool use",
|
||||
type: "toggle",
|
||||
read: (c) => showBool(c.performance?.disableParallelToolUse),
|
||||
patch: (raw) => ({
|
||||
performance: { disableParallelToolUse: parseBool(raw) ?? false } as PerformanceConfig,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cmd: "codeexec",
|
||||
label: "code execution tool",
|
||||
type: "toggle",
|
||||
read: (c) => showBool(c.codeExecution, true),
|
||||
patch: (raw) => ({ codeExecution: parseBool(raw) ?? true }),
|
||||
},
|
||||
{
|
||||
cmd: "websearch",
|
||||
label: "web search tool",
|
||||
type: "toggle",
|
||||
read: (c) => showBool(c.webSearch, true),
|
||||
patch: (raw) => ({ webSearch: parseBool(raw) ?? true }),
|
||||
},
|
||||
{
|
||||
cmd: "computeruse",
|
||||
label: "computer use tool",
|
||||
type: "toggle",
|
||||
read: (c) => showBool(c.computerUse),
|
||||
patch: (raw) => ({ computerUse: parseBool(raw) ?? false }),
|
||||
},
|
||||
{
|
||||
cmd: "compact",
|
||||
label: "server-side compaction",
|
||||
type: "toggle",
|
||||
read: (c) => showBool(c.contextManagement?.compact),
|
||||
patch: (raw) => ({
|
||||
contextManagement: { compact: parseBool(raw) ?? false } as ContextManagementConfig,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cmd: "cleartools",
|
||||
label: "clear old tool results (server)",
|
||||
type: "toggle",
|
||||
read: (c) => showBool(c.contextManagement?.clearToolUses),
|
||||
patch: (raw) => ({
|
||||
contextManagement: { clearToolUses: parseBool(raw) ?? false } as ContextManagementConfig,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cmd: "clearthinking",
|
||||
label: "preserve thinking blocks",
|
||||
type: "toggle",
|
||||
read: (c) => showBool(c.contextManagement?.clearThinking, true),
|
||||
patch: (raw) => ({
|
||||
contextManagement: { clearThinking: parseBool(raw) ?? true } as ContextManagementConfig,
|
||||
}),
|
||||
},
|
||||
{
|
||||
cmd: "pruning",
|
||||
label: "tool-result pruning target",
|
||||
type: "cycle",
|
||||
options: PRUNING,
|
||||
read: (c) => c.contextManagement?.pruningTarget ?? "none",
|
||||
patch: (raw) => ({
|
||||
contextManagement: {
|
||||
pruningTarget: raw as "none" | "main" | "subagents" | "both",
|
||||
} as ContextManagementConfig,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/** Slash-command names this module handles, with the leading "/". */
|
||||
export const SETTINGS_COMMAND_NAMES: readonly string[] = [
|
||||
...SETTINGS.map((s) => `/${s.cmd}`),
|
||||
"/settings",
|
||||
];
|
||||
|
||||
function findSetting(cmdName: string): SettingDef | undefined {
|
||||
const bare = cmdName.startsWith("/") ? cmdName.slice(1) : cmdName;
|
||||
return SETTINGS.find((s) => s.cmd === bare);
|
||||
}
|
||||
|
||||
/** Validate an incoming raw value against a SettingDef. Returns the canonical
|
||||
* string to pass to .patch(), or an error message. */
|
||||
export function validateSettingValue(
|
||||
setting: SettingDef,
|
||||
raw: string,
|
||||
): { ok: true; value: string } | { ok: false; error: string } {
|
||||
const v = raw.trim();
|
||||
if (!v) return { ok: false, error: "missing value" };
|
||||
if (setting.type === "toggle") {
|
||||
const b = parseBool(v);
|
||||
if (b === null) return { ok: false, error: `expected on|off, got "${v}"` };
|
||||
return { ok: true, value: b ? "on" : "off" };
|
||||
}
|
||||
if (setting.type === "cycle") {
|
||||
const lower = v.toLowerCase();
|
||||
const match = setting.options?.find((o) => o.toLowerCase() === lower);
|
||||
if (!match) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `expected ${setting.options?.join("|") ?? "(none)"} — got "${v}"`,
|
||||
};
|
||||
}
|
||||
return { ok: true, value: match };
|
||||
}
|
||||
// budget — integer
|
||||
const n = Number.parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || n < 1024) {
|
||||
return { ok: false, error: "expected integer ≥ 1024" };
|
||||
}
|
||||
return { ok: true, value: String(n) };
|
||||
}
|
||||
|
||||
/** Render the /settings overview — current values for every setting. */
|
||||
export function renderSettingsOverview(cfg: AppConfig): string {
|
||||
const lines = ["━━ Provider settings ━━"];
|
||||
for (const s of SETTINGS) {
|
||||
lines.push(`/${s.cmd.padEnd(15)} ${s.read(cfg).padEnd(10)} ${s.label}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("set: /<cmd> <value>");
|
||||
lines.push("show: /<cmd>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** /<cmd> with no arg → show. With arg → validate, patch global config. */
|
||||
export interface HandleSettingsOptions {
|
||||
/** Override config readers — used by tests. */
|
||||
load?: () => AppConfig;
|
||||
save?: (patch: Partial<AppConfig>) => void;
|
||||
}
|
||||
|
||||
export async function handleSettingsCommand(
|
||||
cmdName: string,
|
||||
args: string[],
|
||||
notify: Notify,
|
||||
opts: HandleSettingsOptions = {},
|
||||
): Promise<boolean> {
|
||||
const load = opts.load ?? loadConfig;
|
||||
const save = opts.save ?? saveGlobalConfig;
|
||||
|
||||
if (cmdName === "/settings") {
|
||||
await notify(renderSettingsOverview(load()));
|
||||
return true;
|
||||
}
|
||||
|
||||
const setting = findSetting(cmdName);
|
||||
if (!setting) return false;
|
||||
|
||||
const raw = args.join(" ").trim();
|
||||
if (!raw) {
|
||||
const current = setting.read(load());
|
||||
const opts = setting.options?.join("|") ?? (setting.type === "toggle" ? "on|off" : "<int>");
|
||||
await notify(`/${setting.cmd}: ${current}\noptions: ${opts}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = validateSettingValue(setting, raw);
|
||||
if (!parsed.ok) {
|
||||
await notify(`✗ ${parsed.error}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
save(setting.patch(parsed.value));
|
||||
} catch (err) {
|
||||
await notify(`✗ save failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return true;
|
||||
}
|
||||
await notify(`✓ ${setting.cmd} → ${parsed.value}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Lines appended to the in-chat /help output. */
|
||||
export function settingsHelpLines(): string[] {
|
||||
return [
|
||||
"━━ Provider settings ━━",
|
||||
"/settings show all current values",
|
||||
"/thinking [mode] off | auto | enabled | adaptive | disabled",
|
||||
"/budget [N] thinking budget tokens (≥1024)",
|
||||
"/effort [level] off | low | medium | high | xhigh | max",
|
||||
"/speed [mode] off | standard | fast (Opus 4.6)",
|
||||
"/reasoning [level] OpenAI reasoning effort",
|
||||
"/tier [tier] OpenAI service tier (flex|priority|auto…)",
|
||||
"/sendreasoning on|off send reasoning across turns",
|
||||
"/toolstream on|off stream tool args incrementally",
|
||||
"/seqtools on|off sequential tool use",
|
||||
"/codeexec on|off code execution tool",
|
||||
"/websearch on|off web search tool",
|
||||
"/computeruse on|off computer use tool",
|
||||
"/compact on|off server-side context compaction",
|
||||
"/cleartools on|off clear old tool results (server)",
|
||||
"/clearthinking on|off preserve thinking blocks",
|
||||
"/pruning [target] none | main | subagents | both",
|
||||
];
|
||||
}
|
||||
@@ -39,6 +39,11 @@ import {
|
||||
} from "./config.js";
|
||||
import { generatePairingCode, PairingRegistry } from "./pairing.js";
|
||||
import { socketRequest } from "./protocol.js";
|
||||
import {
|
||||
handleSettingsCommand,
|
||||
SETTINGS_COMMAND_NAMES,
|
||||
settingsHelpLines,
|
||||
} from "./provider-commands.js";
|
||||
import { redact } from "./redact.js";
|
||||
import { SurfaceHost } from "./surface-host.js";
|
||||
import {
|
||||
@@ -236,6 +241,15 @@ export class TuiHost {
|
||||
const surface = this.host.getSurface(surfaceId);
|
||||
if (!surface) return;
|
||||
|
||||
// Provider-settings commands — handled uniformly out of the switch so
|
||||
// tui-host and daemon share the same logic. Returns true when consumed.
|
||||
if (SETTINGS_COMMAND_NAMES.includes(cmd.name)) {
|
||||
const handled = await handleSettingsCommand(cmd.name, cmd.args, (text) =>
|
||||
surface.notify(msg.externalId, text),
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
switch (cmd.name) {
|
||||
case "/tabs":
|
||||
case "/list": {
|
||||
@@ -856,6 +870,9 @@ export class TuiHost {
|
||||
"\u2501\u2501 Pairing \u2501\u2501",
|
||||
"/pair [CODE] pair this chat \u00b7 redeem a code",
|
||||
"/unpair revoke pairing",
|
||||
"",
|
||||
...settingsHelpLines(),
|
||||
"",
|
||||
"/help this list",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Tests for the /thinking, /effort, /websearch, etc. settings commands that
|
||||
* Hearth surfaces (Telegram/Discord) expose remotely. We mock loadConfig +
|
||||
* saveGlobalConfig so the tests never touch ~/.soulforge.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
handleSettingsCommand,
|
||||
renderSettingsOverview,
|
||||
SETTINGS,
|
||||
SETTINGS_COMMAND_NAMES,
|
||||
settingsHelpLines,
|
||||
validateSettingValue,
|
||||
} from "../src/hearth/provider-commands.js";
|
||||
import type { AppConfig } from "../src/types/index.js";
|
||||
|
||||
const EMPTY_CFG: AppConfig = {
|
||||
defaultModel: "none",
|
||||
routerRules: [],
|
||||
editor: { command: "nvim", args: [] },
|
||||
theme: { name: "dark", transparent: true },
|
||||
nvimConfig: "default",
|
||||
editorIntegration: {
|
||||
diagnostics: true,
|
||||
symbols: true,
|
||||
hover: true,
|
||||
references: true,
|
||||
definition: true,
|
||||
codeActions: true,
|
||||
editorContext: true,
|
||||
rename: true,
|
||||
lspStatus: true,
|
||||
format: true,
|
||||
syncEditorOnEdit: false,
|
||||
},
|
||||
codeExecution: true,
|
||||
webSearch: true,
|
||||
};
|
||||
|
||||
interface Captured {
|
||||
patches: Partial<AppConfig>[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
function makeHarness(initial: AppConfig = EMPTY_CFG): {
|
||||
notify: (text: string) => void;
|
||||
load: () => AppConfig;
|
||||
save: (patch: Partial<AppConfig>) => void;
|
||||
captured: Captured;
|
||||
} {
|
||||
const captured: Captured = { patches: [], notes: [] };
|
||||
return {
|
||||
notify: (text) => captured.notes.push(text),
|
||||
load: () => initial,
|
||||
save: (patch) => captured.patches.push(patch),
|
||||
captured,
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider-commands — registry", () => {
|
||||
test("every setting has a /<cmd> entry in SETTINGS_COMMAND_NAMES", () => {
|
||||
for (const s of SETTINGS) {
|
||||
expect(SETTINGS_COMMAND_NAMES).toContain(`/${s.cmd}`);
|
||||
}
|
||||
expect(SETTINGS_COMMAND_NAMES).toContain("/settings");
|
||||
});
|
||||
|
||||
test("settingsHelpLines has a line per setting", () => {
|
||||
const help = settingsHelpLines().join("\n");
|
||||
for (const s of SETTINGS) {
|
||||
expect(help).toContain(`/${s.cmd}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("no duplicate cmd names", () => {
|
||||
const names = SETTINGS.map((s) => s.cmd);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider-commands — validateSettingValue", () => {
|
||||
const thinking = SETTINGS.find((s) => s.cmd === "thinking");
|
||||
const budget = SETTINGS.find((s) => s.cmd === "budget");
|
||||
const codeexec = SETTINGS.find((s) => s.cmd === "codeexec");
|
||||
|
||||
test("cycle accepts known option (case-insensitive)", () => {
|
||||
expect(thinking).toBeDefined();
|
||||
if (!thinking) return;
|
||||
const r = validateSettingValue(thinking, "ENABLED");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe("enabled");
|
||||
});
|
||||
|
||||
test("cycle rejects unknown option", () => {
|
||||
if (!thinking) return;
|
||||
const r = validateSettingValue(thinking, "nope");
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("toggle accepts on/off/true/false/1/0", () => {
|
||||
if (!codeexec) return;
|
||||
for (const v of ["on", "true", "1", "yes"]) {
|
||||
const r = validateSettingValue(codeexec, v);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe("on");
|
||||
}
|
||||
for (const v of ["off", "false", "0", "no"]) {
|
||||
const r = validateSettingValue(codeexec, v);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe("off");
|
||||
}
|
||||
});
|
||||
|
||||
test("toggle rejects nonsense", () => {
|
||||
if (!codeexec) return;
|
||||
const r = validateSettingValue(codeexec, "maybe");
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("budget requires integer ≥ 1024", () => {
|
||||
if (!budget) return;
|
||||
expect(validateSettingValue(budget, "2048").ok).toBe(true);
|
||||
expect(validateSettingValue(budget, "500").ok).toBe(false);
|
||||
expect(validateSettingValue(budget, "abc").ok).toBe(false);
|
||||
});
|
||||
|
||||
test("empty raw fails", () => {
|
||||
if (!thinking) return;
|
||||
expect(validateSettingValue(thinking, " ").ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider-commands — handleSettingsCommand", () => {
|
||||
let h: ReturnType<typeof makeHarness>;
|
||||
beforeEach(() => {
|
||||
h = makeHarness();
|
||||
});
|
||||
|
||||
test("unknown command returns false (not handled)", async () => {
|
||||
const handled = await handleSettingsCommand("/bogus", [], h.notify, {
|
||||
load: h.load,
|
||||
save: h.save,
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
expect(h.captured.patches).toEqual([]);
|
||||
});
|
||||
|
||||
test("/settings prints overview", async () => {
|
||||
const handled = await handleSettingsCommand("/settings", [], h.notify, {
|
||||
load: h.load,
|
||||
save: h.save,
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(h.captured.notes[0]).toContain("Provider settings");
|
||||
expect(h.captured.patches).toEqual([]);
|
||||
});
|
||||
|
||||
test("/thinking with no args shows current + options", async () => {
|
||||
await handleSettingsCommand("/thinking", [], h.notify, { load: h.load, save: h.save });
|
||||
expect(h.captured.notes[0]).toContain("/thinking");
|
||||
expect(h.captured.notes[0]).toContain("off"); // default
|
||||
expect(h.captured.notes[0]).toContain("enabled"); // option in help
|
||||
expect(h.captured.patches).toEqual([]);
|
||||
});
|
||||
|
||||
test("/thinking enabled writes thinking.mode patch", async () => {
|
||||
await handleSettingsCommand("/thinking", ["enabled"], h.notify, {
|
||||
load: h.load,
|
||||
save: h.save,
|
||||
});
|
||||
expect(h.captured.patches.length).toBe(1);
|
||||
expect(h.captured.patches[0]).toEqual({ thinking: { mode: "enabled" } });
|
||||
expect(h.captured.notes[0]).toContain("✓");
|
||||
});
|
||||
|
||||
test("/effort high writes performance.effort patch", async () => {
|
||||
await handleSettingsCommand("/effort", ["high"], h.notify, { load: h.load, save: h.save });
|
||||
expect(h.captured.patches[0]).toEqual({ performance: { effort: "high" } });
|
||||
});
|
||||
|
||||
test("/budget 4096 writes thinking patch with mode=enabled", async () => {
|
||||
await handleSettingsCommand("/budget", ["4096"], h.notify, { load: h.load, save: h.save });
|
||||
expect(h.captured.patches[0]).toEqual({
|
||||
thinking: { mode: "enabled", budgetTokens: 4096 },
|
||||
});
|
||||
});
|
||||
|
||||
test("/codeexec off writes top-level codeExecution=false", async () => {
|
||||
await handleSettingsCommand("/codeexec", ["off"], h.notify, { load: h.load, save: h.save });
|
||||
expect(h.captured.patches[0]).toEqual({ codeExecution: false });
|
||||
});
|
||||
|
||||
test("/websearch on writes top-level webSearch=true", async () => {
|
||||
await handleSettingsCommand("/websearch", ["on"], h.notify, { load: h.load, save: h.save });
|
||||
expect(h.captured.patches[0]).toEqual({ webSearch: true });
|
||||
});
|
||||
|
||||
test("/compact on writes contextManagement.compact=true", async () => {
|
||||
await handleSettingsCommand("/compact", ["on"], h.notify, { load: h.load, save: h.save });
|
||||
expect(h.captured.patches[0]).toEqual({ contextManagement: { compact: true } });
|
||||
});
|
||||
|
||||
test("/pruning subagents writes contextManagement.pruningTarget", async () => {
|
||||
await handleSettingsCommand("/pruning", ["subagents"], h.notify, {
|
||||
load: h.load,
|
||||
save: h.save,
|
||||
});
|
||||
expect(h.captured.patches[0]).toEqual({ contextManagement: { pruningTarget: "subagents" } });
|
||||
});
|
||||
|
||||
test("invalid value reports error, no save", async () => {
|
||||
await handleSettingsCommand("/thinking", ["nope"], h.notify, { load: h.load, save: h.save });
|
||||
expect(h.captured.patches).toEqual([]);
|
||||
expect(h.captured.notes[0]).toContain("✗");
|
||||
});
|
||||
|
||||
test("save throwing is reported, not propagated", async () => {
|
||||
const throwSave = () => {
|
||||
throw new Error("disk full");
|
||||
};
|
||||
await handleSettingsCommand("/effort", ["low"], h.notify, {
|
||||
load: h.load,
|
||||
save: throwSave,
|
||||
});
|
||||
expect(h.captured.notes[0]).toContain("✗");
|
||||
expect(h.captured.notes[0]).toContain("disk full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider-commands — renderSettingsOverview", () => {
|
||||
test("includes every cmd and reads current values", () => {
|
||||
const cfg: AppConfig = {
|
||||
...EMPTY_CFG,
|
||||
thinking: { mode: "enabled", budgetTokens: 8192 },
|
||||
performance: { effort: "high", speed: "fast" },
|
||||
webSearch: false,
|
||||
};
|
||||
const out = renderSettingsOverview(cfg);
|
||||
for (const s of SETTINGS) expect(out).toContain(`/${s.cmd}`);
|
||||
expect(out).toContain("enabled"); // thinking.mode
|
||||
expect(out).toContain("8192"); // budgetTokens
|
||||
expect(out).toContain("high"); // effort
|
||||
expect(out).toContain("fast"); // speed
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user