feat(canvas): expose effort + task_budget in ConfigTab (#608)

Adds two new Claude API primitives (Opus 4.7+) as configurable workspace
fields in the Config tab form:

  effort: 'low' | 'medium' | 'high' | 'xhigh'
    Maps to output_config.effort in the Anthropic Messages API.
    Controls thinking depth — xhigh enables extended thinking mode.

  task_budget: integer (token count, 0 = unset)
    Maps to output_config.task_budget.total; requires beta header
    task-budgets-2026-03-13. Lets operators cap token spend per task.

Both fields are stored as top-level keys in config.yaml and read by
claude_sdk_executor.py (workspace-template side, tracked in #608).

Canvas changes:
- form-inputs.tsx: effort?: string, task_budget?: number added to
  ConfigData; DEFAULT_CONFIG initialises them to "" / 0
- yaml-utils.ts: toYaml() emits effort + task_budget (omits when
  empty/zero); parseYaml() already handles plain string/integer keys
- ConfigTab.tsx: new collapsible "Claude Settings" section (defaultOpen=false)
  shown when runtime === "claude-code" OR model name contains "claude"
  or "anthropic". Dropdown for effort (4 options + unset), number input
  for task_budget (step 1000, 0 = unset).

Tests (25 cases in ClaudeSettings.test.tsx):
  - toYaml serialises all four effort values + omits empty/undefined
  - toYaml serialises task_budget + omits 0/undefined
  - effort appears before task_budget in YAML output
  - parseYaml round-trips both fields correctly
  - DEFAULT_CONFIG shape assertions
  - Source assertions for section guards + option values
  - React rendering: section visible for claude-code/claude model,
    hidden for non-Claude runtime (crewai + gpt-4o)

640/640 tests pass. Build clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Molecule AI Frontend Engineer 2026-04-17 06:24:36 +00:00
parent 0adf5d5a5f
commit 99d5ef6866
4 changed files with 282 additions and 0 deletions

View 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();
});
});

View File

@ -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" />

View File

@ -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: [],

View File

@ -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);