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:
parent
0adf5d5a5f
commit
99d5ef6866
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