From 99d5ef6866f19c6aa7c873a21fd4033b4ab2d1fb Mon Sep 17 00:00:00 2001 From: Molecule AI Frontend Engineer Date: Fri, 17 Apr 2026 06:24:36 +0000 Subject: [PATCH] feat(canvas): expose effort + task_budget in ConfigTab (#608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/ClaudeSettings.test.tsx | 230 ++++++++++++++++++ canvas/src/components/tabs/ConfigTab.tsx | 42 ++++ .../components/tabs/config/form-inputs.tsx | 7 + .../src/components/tabs/config/yaml-utils.ts | 3 + 4 files changed, 282 insertions(+) create mode 100644 canvas/src/components/__tests__/ClaudeSettings.test.tsx diff --git a/canvas/src/components/__tests__/ClaudeSettings.test.tsx b/canvas/src/components/__tests__/ClaudeSettings.test.tsx new file mode 100644 index 00000000..b74ab085 --- /dev/null +++ b/canvas/src/components/__tests__/ClaudeSettings.test.tsx @@ -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: () =>
, +})); + +// ── 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(); + // 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(); + 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(); + // Wait for load (config.yaml fetch resolves) then check absence + await screen.findByText("General"); // loaded + expect(screen.queryByText("Claude Settings")).toBeNull(); + }); +}); diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 6e600cd4..699ee27a 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -267,6 +267,48 @@ export function ConfigTab({ workspaceId }: Props) { updateNested("runtime_config" as keyof ConfigData, "required_env", v)} placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN" /> + {/* 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")) && ( +
+
+ + +
+
+ + 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" + /> +
+
+ )} +
update("skills", v)} placeholder="e.g. code-review" /> update("tools", v)} placeholder="e.g. web_search, filesystem" /> diff --git a/canvas/src/components/tabs/config/form-inputs.tsx b/canvas/src/components/tabs/config/form-inputs.tsx index 63e983d6..58c75101 100644 --- a/canvas/src/components/tabs/config/form-inputs.tsx +++ b/canvas/src/components/tabs/config/form-inputs.tsx @@ -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: [], diff --git a/canvas/src/components/tabs/config/yaml-utils.ts b/canvas/src/components/tabs/config/yaml-utils.ts index 752bd7ab..77ffff2d 100644 --- a/canvas/src/components/tabs/config/yaml-utils.ts +++ b/canvas/src/components/tabs/config/yaml-utils.ts @@ -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);