Merge pull request #639 from Molecule-AI/feat/issue-608-effort-task-budget-ui
Merge gate passed (all 7 gates). Adds effort + task_budget to ConfigTab Claude Settings section. Dark zinc palette, conditionally shown for claude/anthropic runtimes, yaml serialization omits zero/empty values. UNSTABLE = known App token scope gap.
This commit is contained in:
commit
f127d4c0a6
@ -44,6 +44,15 @@ vi.mock("../tabs/BudgetSection", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock WorkspaceUsage — it has its own test suite (WorkspaceUsage.test.tsx).
|
||||
// Without this mock its internal api.get call races against the shared mock
|
||||
// and crashes when the return value is not a valid WorkspaceMetrics object.
|
||||
vi.mock("../WorkspaceUsage", () => ({
|
||||
WorkspaceUsage: ({ workspaceId }: { workspaceId: string }) => (
|
||||
<div data-testid="workspace-usage-stub" data-ws={workspaceId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { DetailsTab } from "../tabs/DetailsTab";
|
||||
|
||||
|
||||
230
canvas/src/components/__tests__/ClaudeSettings.test.tsx
Normal file
230
canvas/src/components/__tests__/ClaudeSettings.test.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for issue #608 — effort + task_budget fields in workspace config.
|
||||
*
|
||||
* Covers:
|
||||
* 1. toYaml serialization (effort + task_budget → YAML keys)
|
||||
* 2. parseYaml round-trip (YAML → ConfigData)
|
||||
* 3. DEFAULT_CONFIG shape (new fields present with zero/empty defaults)
|
||||
* 4. ConfigTab source assertions (section rendered conditionally)
|
||||
* 5. React rendering of the section for claude-code and claude model configs
|
||||
*/
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
|
||||
// ── Module-level mocks ───────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: vi.fn(), put: vi.fn(), patch: vi.fn(), post: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
restartWorkspace: vi.fn(),
|
||||
updateNodeData: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../tabs/config/secrets-section", () => ({
|
||||
SecretsSection: () => <div data-testid="secrets-stub" />,
|
||||
}));
|
||||
|
||||
// ── Imports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
import { toYaml, parseYaml } from "../tabs/config/yaml-utils";
|
||||
import { DEFAULT_CONFIG, type ConfigData } from "../tabs/config/form-inputs";
|
||||
import { ConfigTab } from "../tabs/ConfigTab";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── 1. toYaml serialization ──────────────────────────────────────────────────
|
||||
|
||||
describe("toYaml — effort field", () => {
|
||||
it("omits effort when empty string", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "" };
|
||||
expect(toYaml(cfg)).not.toContain("effort:");
|
||||
});
|
||||
|
||||
it("omits effort when undefined", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: undefined };
|
||||
expect(toYaml(cfg)).not.toContain("effort:");
|
||||
});
|
||||
|
||||
it("serializes effort: low", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "low" };
|
||||
const yaml = toYaml(cfg);
|
||||
expect(yaml).toContain("effort: low");
|
||||
});
|
||||
|
||||
it("serializes effort: medium", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "medium" };
|
||||
expect(toYaml(cfg)).toContain("effort: medium");
|
||||
});
|
||||
|
||||
it("serializes effort: high", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "high" };
|
||||
expect(toYaml(cfg)).toContain("effort: high");
|
||||
});
|
||||
|
||||
it("serializes effort: xhigh", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "xhigh" };
|
||||
expect(toYaml(cfg)).toContain("effort: xhigh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toYaml — task_budget field", () => {
|
||||
it("omits task_budget when 0", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, task_budget: 0 };
|
||||
expect(toYaml(cfg)).not.toContain("task_budget:");
|
||||
});
|
||||
|
||||
it("omits task_budget when undefined", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, task_budget: undefined };
|
||||
expect(toYaml(cfg)).not.toContain("task_budget:");
|
||||
});
|
||||
|
||||
it("serializes task_budget: 10000", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, task_budget: 10000 };
|
||||
expect(toYaml(cfg)).toContain("task_budget: 10000");
|
||||
});
|
||||
|
||||
it("serializes task_budget: 50000", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, task_budget: 50000 };
|
||||
expect(toYaml(cfg)).toContain("task_budget: 50000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toYaml — effort and task_budget together", () => {
|
||||
it("serializes both when set", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "xhigh", task_budget: 32000 };
|
||||
const yaml = toYaml(cfg);
|
||||
expect(yaml).toContain("effort: xhigh");
|
||||
expect(yaml).toContain("task_budget: 32000");
|
||||
});
|
||||
|
||||
it("effort appears before task_budget in output", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "high", task_budget: 8000 };
|
||||
const yaml = toYaml(cfg);
|
||||
const effortIdx = yaml.indexOf("effort:");
|
||||
const budgetIdx = yaml.indexOf("task_budget:");
|
||||
expect(effortIdx).toBeGreaterThan(-1);
|
||||
expect(budgetIdx).toBeGreaterThan(-1);
|
||||
expect(effortIdx).toBeLessThan(budgetIdx);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. parseYaml round-trip ──────────────────────────────────────────────────
|
||||
|
||||
describe("parseYaml — effort + task_budget round-trip", () => {
|
||||
it("parses effort from YAML", () => {
|
||||
const yaml = "name: Test\neffort: high\n";
|
||||
const parsed = parseYaml(yaml);
|
||||
expect(parsed.effort).toBe("high");
|
||||
});
|
||||
|
||||
it("parses task_budget from YAML as integer", () => {
|
||||
const yaml = "name: Test\ntask_budget: 16000\n";
|
||||
const parsed = parseYaml(yaml);
|
||||
expect(parsed.task_budget).toBe(16000);
|
||||
});
|
||||
|
||||
it("round-trips effort: xhigh through toYaml → parseYaml", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "xhigh" };
|
||||
const yaml = toYaml(cfg);
|
||||
const parsed = parseYaml(yaml);
|
||||
expect(parsed.effort).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("round-trips task_budget: 50000 through toYaml → parseYaml", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, task_budget: 50000 };
|
||||
const yaml = toYaml(cfg);
|
||||
const parsed = parseYaml(yaml);
|
||||
expect(parsed.task_budget).toBe(50000);
|
||||
});
|
||||
|
||||
it("round-trips both fields together", () => {
|
||||
const cfg: ConfigData = { ...DEFAULT_CONFIG, effort: "low", task_budget: 1000 };
|
||||
const yaml = toYaml(cfg);
|
||||
const parsed = parseYaml(yaml);
|
||||
expect(parsed.effort).toBe("low");
|
||||
expect(parsed.task_budget).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. DEFAULT_CONFIG shape ──────────────────────────────────────────────────
|
||||
|
||||
describe("DEFAULT_CONFIG", () => {
|
||||
it("has effort defaulting to empty string", () => {
|
||||
expect(DEFAULT_CONFIG.effort).toBe("");
|
||||
});
|
||||
|
||||
it("has task_budget defaulting to 0", () => {
|
||||
expect(DEFAULT_CONFIG.task_budget).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. ConfigTab source assertions ──────────────────────────────────────────
|
||||
|
||||
describe("ConfigTab source — Claude Settings section", () => {
|
||||
it("ConfigTab.tsx contains the effort-select data-testid", async () => {
|
||||
const { readFileSync } = await import("fs");
|
||||
const { join } = await import("path");
|
||||
const src = readFileSync(join(__dirname, "../../components/tabs/ConfigTab.tsx"), "utf8");
|
||||
expect(src).toContain('data-testid="effort-select"');
|
||||
expect(src).toContain('data-testid="task-budget-input"');
|
||||
});
|
||||
|
||||
it("ConfigTab.tsx effort dropdown has all four Claude values", async () => {
|
||||
const { readFileSync } = await import("fs");
|
||||
const { join } = await import("path");
|
||||
const src = readFileSync(join(__dirname, "../../components/tabs/ConfigTab.tsx"), "utf8");
|
||||
expect(src).toContain('"low"');
|
||||
expect(src).toContain('"medium"');
|
||||
expect(src).toContain('"high"');
|
||||
expect(src).toContain('"xhigh"');
|
||||
});
|
||||
|
||||
it("ConfigTab.tsx section is guarded by claude-code runtime check", async () => {
|
||||
const { readFileSync } = await import("fs");
|
||||
const { join } = await import("path");
|
||||
const src = readFileSync(join(__dirname, "../../components/tabs/ConfigTab.tsx"), "utf8");
|
||||
expect(src).toContain('config.runtime === "claude-code"');
|
||||
expect(src).toContain('"claude"');
|
||||
});
|
||||
});
|
||||
|
||||
// ── 5. React rendering ───────────────────────────────────────────────────────
|
||||
|
||||
describe("ConfigTab — Claude Settings section rendering", () => {
|
||||
function setupMock(configYaml: string) {
|
||||
vi.mocked(api.get).mockResolvedValue({ content: configYaml } as never);
|
||||
}
|
||||
|
||||
it("shows Claude Settings section for claude-code runtime", async () => {
|
||||
setupMock("name: Bot\nruntime: claude-code\n");
|
||||
render(<ConfigTab workspaceId="ws-1" />);
|
||||
// Section title appears once loading resolves
|
||||
const section = await screen.findByText("Claude Settings");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Claude Settings section when model contains claude", async () => {
|
||||
setupMock("name: Bot\nmodel: anthropic:claude-opus-4-7\n");
|
||||
render(<ConfigTab workspaceId="ws-1" />);
|
||||
const section = await screen.findByText("Claude Settings");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show Claude Settings section for non-claude runtime/model", async () => {
|
||||
setupMock("name: Bot\nruntime: crewai\nmodel: openai:gpt-4o\n");
|
||||
render(<ConfigTab workspaceId="ws-1" />);
|
||||
// Wait for load (config.yaml fetch resolves) then check absence
|
||||
await screen.findByText("General"); // loaded
|
||||
expect(screen.queryByText("Claude Settings")).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -267,6 +267,48 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
<TagList label="Required Env Vars" values={config.runtime_config?.required_env || []} onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)} placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN" />
|
||||
</Section>
|
||||
|
||||
{/* Claude Settings — shown for claude-code runtime or claude/anthropic model names */}
|
||||
{(config.runtime === "claude-code" ||
|
||||
(config.runtime_config?.model || config.model || "").toLowerCase().includes("claude") ||
|
||||
(config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && (
|
||||
<Section title="Claude Settings" defaultOpen={false}>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
Effort
|
||||
<span className="ml-1 text-zinc-600">(output_config.effort — Opus 4.7+)</span>
|
||||
</label>
|
||||
<select
|
||||
value={config.effort || ""}
|
||||
onChange={(e) => update("effort", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
data-testid="effort-select"
|
||||
>
|
||||
<option value="">— unset (model default) —</option>
|
||||
<option value="low">low</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="high">high</option>
|
||||
<option value="xhigh">xhigh (extended thinking)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
Task Budget (tokens)
|
||||
<span className="ml-1 text-zinc-600">(output_config.task_budget.total — 0 = unset)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
value={config.task_budget ?? 0}
|
||||
onChange={(e) => update("task_budget", parseInt(e.target.value, 10) || 0)}
|
||||
placeholder="0"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 font-mono"
|
||||
data-testid="task-budget-input"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Skills & Tools" defaultOpen={false}>
|
||||
<TagList label="Skills" values={config.skills || []} onChange={(v) => update("skills", v)} placeholder="e.g. code-review" />
|
||||
<TagList label="Tools" values={config.tools || []} onChange={(v) => update("tools", v)} placeholder="e.g. web_search, filesystem" />
|
||||
|
||||
@ -16,6 +16,11 @@ export interface ConfigData {
|
||||
// Deprecated
|
||||
auth_token_file?: string;
|
||||
};
|
||||
// Claude API primitives (Opus 4.7+) — issue #608
|
||||
// effort maps to output_config.effort in Messages API: 'low' | 'medium' | 'high' | 'xhigh'
|
||||
effort?: string;
|
||||
// task_budget maps to output_config.task_budget.total (requires beta header task-budgets-2026-03-13)
|
||||
task_budget?: number;
|
||||
prompt_files: string[];
|
||||
shared_context: string[];
|
||||
skills: string[];
|
||||
@ -32,6 +37,8 @@ export const DEFAULT_CONFIG: ConfigData = {
|
||||
tier: 1,
|
||||
model: "",
|
||||
runtime: "",
|
||||
effort: "",
|
||||
task_budget: 0,
|
||||
prompt_files: [],
|
||||
shared_context: [],
|
||||
skills: [],
|
||||
|
||||
@ -116,6 +116,9 @@ export function toYaml(config: ConfigData): string {
|
||||
}
|
||||
}
|
||||
if (config.model) { lines.push(""); simple("model", config.model); }
|
||||
// Claude API primitives (issue #608)
|
||||
if (config.effort) { lines.push(""); simple("effort", config.effort); }
|
||||
if (config.task_budget && config.task_budget > 0) { simple("task_budget", config.task_budget); }
|
||||
if (config.prompt_files?.length) { lines.push(""); list("prompt_files", config.prompt_files); }
|
||||
if (config.shared_context?.length) { lines.push(""); list("shared_context", config.shared_context); }
|
||||
lines.push(""); list("skills", config.skills);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user