From a9fc2e6fc499f3b54871201dcac92d643a72d3aa Mon Sep 17 00:00:00 2001 From: Jeremy Eder Date: Wed, 1 Apr 2026 17:34:32 -0400 Subject: [PATCH 1/5] feat: expose all Claude Agent SDK options in session creation UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a collapsible "Advanced SDK Options" section to the new session page, gated behind the `advanced-sdk-options` workspace feature flag (disabled by default). Allows users to configure temperature, max tokens, thinking tokens, turn limits, cost budgets, permission mode, allowed tools, system prompt, beta flags, and more. Data flow: frontend sends sdkOptions → backend serializes into SDK_OPTIONS env var (with server-side allowlist) → runner parses and merges into adapter options (with defense-in-depth denylist). Also adds: - GHA workflow for weekly SDK options drift detection (auto-PR with amber:auto-fix label) - SDK options manifest for tracking ClaudeAgentOptions fields - Removal of unused create-session-dialog.tsx (dead code) - Removal of Type column from feature flags settings page - Tests for frontend component, runner SDK_OPTIONS parsing Co-Authored-By: Claude Opus 4.6 (1M context) --- .../workflows/claude-sdk-options-drift.yml | 134 ++++ components/backend/handlers/sessions.go | 66 ++ components/backend/types/session.go | 36 +- .../src/app/projects/[name]/new/page.tsx | 3 + .../__tests__/new-session-view.test.tsx | 26 +- .../components/new-session-view.tsx | 35 +- .../__tests__/advanced-sdk-options.test.tsx | 65 ++ .../src/components/advanced-sdk-options.tsx | 533 +++++++++++++++ .../src/components/create-session-dialog.tsx | 616 ------------------ .../feature-flags-section.tsx | 24 +- components/frontend/src/types/api/sessions.ts | 21 + components/manifests/base/core/flags.json | 10 + .../ambient_runner/bridges/claude/bridge.py | 45 ++ .../ambient-runner/sdk-options-manifest.json | 95 +++ .../ambient-runner/tests/test_sdk_options.py | 108 +++ 15 files changed, 1152 insertions(+), 665 deletions(-) create mode 100644 .github/workflows/claude-sdk-options-drift.yml create mode 100644 components/frontend/src/components/__tests__/advanced-sdk-options.test.tsx create mode 100644 components/frontend/src/components/advanced-sdk-options.tsx delete mode 100644 components/frontend/src/components/create-session-dialog.tsx create mode 100644 components/runners/ambient-runner/sdk-options-manifest.json create mode 100644 components/runners/ambient-runner/tests/test_sdk_options.py diff --git a/.github/workflows/claude-sdk-options-drift.yml b/.github/workflows/claude-sdk-options-drift.yml new file mode 100644 index 000000000..9ae56f40d --- /dev/null +++ b/.github/workflows/claude-sdk-options-drift.yml @@ -0,0 +1,134 @@ +name: Claude SDK Options Drift Check + +on: + schedule: + - cron: '0 6 * * 1' # Weekly Monday 6am UTC + workflow_dispatch: + +concurrency: + group: claude-sdk-options-drift + cancel-in-progress: true + +jobs: + check-drift: + name: Check SDK Options Drift + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + + - name: Install claude-agent-sdk + run: pip install claude-agent-sdk + + - name: Extract current SDK options + run: | + python3 -c " + import json + try: + from claude_agent_sdk import ClaudeAgentOptions + # Pydantic model — extract fields from model_fields + if hasattr(ClaudeAgentOptions, 'model_fields'): + fields = {} + for name, field_info in ClaudeAgentOptions.model_fields.items(): + annotation = str(field_info.annotation) if field_info.annotation else 'unknown' + fields[name] = { + 'type': annotation, + 'required': field_info.is_required(), + } + else: + # Fallback: inspect __init__ signature + import inspect + sig = inspect.signature(ClaudeAgentOptions.__init__) + fields = {} + for name, param in sig.parameters.items(): + if name == 'self': + continue + fields[name] = { + 'type': str(param.annotation) if param.annotation != inspect.Parameter.empty else 'unknown', + 'required': param.default == inspect.Parameter.empty, + } + print(json.dumps(fields, indent=2, sort_keys=True)) + except ImportError: + print('{}') + import sys + print('WARNING: claude-agent-sdk not found', file=sys.stderr) + sys.exit(1) + " > /tmp/current-sdk-options.json + + - name: Compare with manifest + id: check + run: | + python3 -c " + import json, sys + + manifest_path = 'components/runners/ambient-runner/sdk-options-manifest.json' + try: + with open(manifest_path) as f: + manifest = json.load(f) + except FileNotFoundError: + print('No manifest file found — creating initial manifest') + sys.exit(1) + + with open('/tmp/current-sdk-options.json') as f: + current = json.load(f) + + manifest_keys = set(manifest.get('options', {}).keys()) + current_keys = set(current.keys()) + + new_keys = sorted(current_keys - manifest_keys) + removed_keys = sorted(manifest_keys - current_keys) + + if new_keys or removed_keys: + if new_keys: + print(f'New options: {new_keys}') + if removed_keys: + print(f'Removed options: {removed_keys}') + sys.exit(1) + + print('No drift detected') + " || echo "drift=true" >> "$GITHUB_OUTPUT" + + - name: Update manifest and create PR + if: steps.check.outputs.drift == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Build updated manifest + python3 -c " + import json, datetime + + with open('/tmp/current-sdk-options.json') as f: + options = json.load(f) + + manifest = { + 'description': 'Canonical list of Claude Agent SDK ClaudeAgentOptions fields', + 'generatedFrom': 'claude-agent-sdk (PyPI)', + 'generatedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(), + 'options': options, + } + + with open('components/runners/ambient-runner/sdk-options-manifest.json', 'w') as f: + json.dump(manifest, f, indent=2, sort_keys=True) + f.write('\n') + " + + BRANCH="auto/sdk-options-update-$(date +%Y%m%d)" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "${BRANCH}" + git add components/runners/ambient-runner/sdk-options-manifest.json + git commit -m "chore: update Claude SDK options manifest" + git push origin "${BRANCH}" + + gh pr create \ + --title "chore: update Claude SDK options manifest" \ + --body "Auto-detected changes in ClaudeAgentOptions fields from claude-agent-sdk on PyPI. The advanced-sdk-options UI component may need updating to expose new options." \ + --label "amber:auto-fix" diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index aa45a58be..770a65c05 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -105,6 +105,41 @@ func isBinaryContentType(contentType string) bool { } // parseSpec parses AgenticSessionSpec with v1alpha1 fields +// allowedSdkOptionKeys defines the keys users may set via sdkOptions. +// Platform-managed keys (cwd, resume, mcp_servers, setting_sources, stderr, +// continue_conversation, add_dirs) are excluded to prevent users from +// overriding security-critical settings. +var allowedSdkOptionKeys = map[string]bool{ + "temperature": true, + "max_tokens": true, + "max_thinking_tokens": true, + "max_turns": true, + "max_budget_usd": true, + "fallback_model": true, + "model": true, + "timeout": true, + "inactivity_timeout": true, + "permission_mode": true, + "output_format": true, + "include_partial_messages": true, + "enable_file_checkpointing": true, + "strict_mcp_config": true, + "betas": true, + "allowed_tools": true, + "system_prompt": true, +} + +// filterSdkOptions returns only the allowed keys from the input map. +func filterSdkOptions(opts map[string]interface{}) map[string]interface{} { + filtered := make(map[string]interface{}, len(opts)) + for k, v := range opts { + if allowedSdkOptionKeys[k] { + filtered[k] = v + } + } + return filtered +} + func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec { result := types.AgenticSessionSpec{} @@ -762,6 +797,19 @@ func CreateSession(c *gin.Context) { envVars[k] = v } + // Serialize sdkOptions as JSON into SDK_OPTIONS env var (filtered to allowed keys only) + if len(req.SdkOptions) > 0 { + filtered := filterSdkOptions(req.SdkOptions) + if len(filtered) > 0 { + sdkOptsJSON, err := json.Marshal(filtered) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to serialize sdkOptions: %v", err)}) + return + } + envVars["SDK_OPTIONS"] = string(sdkOptsJSON) + } + } + // Handle session continuation if req.ParentSessionID != "" { envVars["PARENT_SESSION_ID"] = req.ParentSessionID @@ -1229,6 +1277,24 @@ func UpdateSession(c *gin.Context) { spec["timeout"] = *req.Timeout } + // Update SDK options in environmentVariables (filtered to allowed keys only) + if len(req.SdkOptions) > 0 { + filtered := filterSdkOptions(req.SdkOptions) + if len(filtered) > 0 { + sdkOptsJSON, err := json.Marshal(filtered) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to serialize sdkOptions: %v", err)}) + return + } + envVars, _ := spec["environmentVariables"].(map[string]interface{}) + if envVars == nil { + envVars = make(map[string]interface{}) + } + envVars["SDK_OPTIONS"] = string(sdkOptsJSON) + spec["environmentVariables"] = envVars + } + } + // Update the resource updated, err := k8sDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) if err != nil { diff --git a/components/backend/types/session.go b/components/backend/types/session.go index d0a79e255..e5340df64 100644 --- a/components/backend/types/session.go +++ b/components/backend/types/session.go @@ -52,19 +52,20 @@ type AgenticSessionStatus struct { } type CreateAgenticSessionRequest struct { - InitialPrompt string `json:"initialPrompt,omitempty"` - DisplayName string `json:"displayName,omitempty"` - RunnerType string `json:"runnerType,omitempty"` - LLMSettings *LLMSettings `json:"llmSettings,omitempty"` - Timeout *int `json:"timeout,omitempty"` - InactivityTimeout *int `json:"inactivityTimeout,omitempty"` - ParentSessionID string `json:"parent_session_id,omitempty"` - Repos []SimpleRepo `json:"repos,omitempty"` - ActiveWorkflow *WorkflowSelection `json:"activeWorkflow,omitempty"` - UserContext *UserContext `json:"userContext,omitempty"` - EnvironmentVariables map[string]string `json:"environmentVariables,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` + InitialPrompt string `json:"initialPrompt,omitempty"` + DisplayName string `json:"displayName,omitempty"` + RunnerType string `json:"runnerType,omitempty"` + LLMSettings *LLMSettings `json:"llmSettings,omitempty"` + Timeout *int `json:"timeout,omitempty"` + InactivityTimeout *int `json:"inactivityTimeout,omitempty"` + ParentSessionID string `json:"parent_session_id,omitempty"` + Repos []SimpleRepo `json:"repos,omitempty"` + ActiveWorkflow *WorkflowSelection `json:"activeWorkflow,omitempty"` + UserContext *UserContext `json:"userContext,omitempty"` + EnvironmentVariables map[string]string `json:"environmentVariables,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + SdkOptions map[string]interface{} `json:"sdkOptions,omitempty"` } type CloneSessionRequest struct { @@ -73,10 +74,11 @@ type CloneSessionRequest struct { } type UpdateAgenticSessionRequest struct { - InitialPrompt *string `json:"initialPrompt,omitempty"` - DisplayName *string `json:"displayName,omitempty"` - Timeout *int `json:"timeout,omitempty"` - LLMSettings *LLMSettings `json:"llmSettings,omitempty"` + InitialPrompt *string `json:"initialPrompt,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Timeout *int `json:"timeout,omitempty"` + LLMSettings *LLMSettings `json:"llmSettings,omitempty"` + SdkOptions map[string]interface{} `json:"sdkOptions,omitempty"` } type CloneAgenticSessionRequest struct { diff --git a/components/frontend/src/app/projects/[name]/new/page.tsx b/components/frontend/src/app/projects/[name]/new/page.tsx index 9c0ebc6a8..e0696116a 100644 --- a/components/frontend/src/app/projects/[name]/new/page.tsx +++ b/components/frontend/src/app/projects/[name]/new/page.tsx @@ -7,6 +7,7 @@ import { NewSessionView } from "../sessions/[sessionName]/components/new-session import { CustomWorkflowDialog } from "../sessions/[sessionName]/components/modals/custom-workflow-dialog"; import { useCreateSession } from "@/services/queries"; import { useOOTBWorkflows } from "@/services/queries/use-workflows"; +import type { SdkOptions } from "@/types/api/sessions"; export default function NewSessionPage() { const params = useParams(); @@ -25,6 +26,7 @@ export default function NewSessionPage() { model: string; workflow?: string; repos?: Array<{ url: string }>; + sdkOptions?: SdkOptions; }) => { const workflowConfig = config.workflow === "custom" && customWorkflow ? { gitUrl: customWorkflow.gitUrl, branch: customWorkflow.branch, path: customWorkflow.path } @@ -53,6 +55,7 @@ export default function NewSessionPage() { repos: config.repos.map((r) => ({ url: r.url })), } : {}), + ...(config.sdkOptions ? { sdkOptions: config.sdkOptions } : {}), }, }, { diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx index 4be14ee29..ef7e5feef 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx @@ -1,7 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { NewSessionView } from '../new-session-view'; +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + vi.mock('../runner-model-selector', () => ({ RunnerModelSelector: ({ onSelect }: { onSelect: (r: string, m: string) => void }) => ( , })); +vi.mock('@/services/api/feature-flags-admin', () => ({ + evaluateFeatureFlag: vi.fn().mockResolvedValue({ enabled: false }), +})); + describe('NewSessionView', () => { const defaultProps = { projectName: 'test-project', @@ -52,32 +64,32 @@ describe('NewSessionView', () => { }); it('renders heading and subtitle', () => { - render(); + render(, { wrapper }); expect(screen.getByText('What are you working on?')).toBeDefined(); expect(screen.getByText(/Start a new session/)).toBeDefined(); }); it('renders textarea with placeholder', () => { - render(); + render(, { wrapper }); const textarea = screen.getByPlaceholderText("Describe what you'd like to work on..."); expect(textarea).toBeDefined(); }); it('renders runner/model selector and workflow selector', () => { - render(); + render(, { wrapper }); expect(screen.getByTestId('runner-model-selector')).toBeDefined(); expect(screen.getByTestId('workflow-selector')).toBeDefined(); }); it('send button is disabled when textarea is empty', () => { - render(); + render(, { wrapper }); const allButtons = screen.getAllByRole('button'); const lastButton = allButtons[allButtons.length - 1]; expect(lastButton.hasAttribute('disabled')).toBe(true); }); it('calls onCreateSession with prompt when submitted', () => { - render(); + render(, { wrapper }); const textarea = screen.getByPlaceholderText("Describe what you'd like to work on..."); fireEvent.change(textarea, { target: { value: 'Build a REST API' } }); fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); @@ -91,14 +103,14 @@ describe('NewSessionView', () => { }); it('does not submit when prompt is empty', () => { - render(); + render(, { wrapper }); const textarea = screen.getByPlaceholderText("Describe what you'd like to work on..."); fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); expect(defaultProps.onCreateSession).not.toHaveBeenCalled(); }); it('Shift+Enter does not submit (allows newline)', () => { - render(); + render(, { wrapper }); const textarea = screen.getByPlaceholderText("Describe what you'd like to work on..."); fireEvent.change(textarea, { target: { value: 'some text' } }); fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true }); diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx index aa36dbe5f..1c01a9d03 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useCallback, useEffect } from "react"; +import { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { MessageSquarePlus, ArrowUp, Loader2, Plus, GitBranch, Upload, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -20,10 +20,14 @@ import { import { RunnerModelSelector, getDefaultModel } from "./runner-model-selector"; import { WorkflowSelector } from "./workflow-selector"; import { AddContextModal } from "./modals/add-context-modal"; +import { AdvancedSdkOptions } from "@/components/advanced-sdk-options"; import { useRunnerTypes } from "@/services/queries/use-runner-types"; import { useModels } from "@/services/queries/use-models"; +import { useQuery } from "@tanstack/react-query"; +import { evaluateFeatureFlag } from "@/services/api/feature-flags-admin"; import { DEFAULT_RUNNER_TYPE_ID } from "@/services/api/runner-types"; import type { WorkflowConfig } from "../lib/types"; +import type { SdkOptions } from "@/types/api/sessions"; type PendingRepo = { url: string; @@ -38,6 +42,7 @@ type NewSessionViewProps = { model: string; workflow?: string; repos?: Array<{ url: string }>; + sdkOptions?: SdkOptions; }) => void; ootbWorkflows: WorkflowConfig[]; onLoadCustomWorkflow?: () => void; @@ -52,10 +57,19 @@ export function NewSessionView({ isSubmitting = false, }: NewSessionViewProps) { const { data: runnerTypes } = useRunnerTypes(projectName); + const { data: advancedFlagData } = useQuery({ + queryKey: ["workspace-flag", projectName, "advanced-sdk-options"], + queryFn: () => evaluateFeatureFlag(projectName, "advanced-sdk-options"), + enabled: !!projectName, + staleTime: 0, + refetchOnMount: "always", + }); + const showAdvancedOptions = advancedFlagData?.enabled ?? false; const [prompt, setPrompt] = useState(""); const [selectedRunner, setSelectedRunner] = useState(DEFAULT_RUNNER_TYPE_ID); const [selectedModel, setSelectedModel] = useState(""); + const [sdkOptions, setSdkOptions] = useState({}); const currentRunner = runnerTypes?.find((r) => r.id === selectedRunner); const currentProvider = currentRunner?.provider; @@ -97,6 +111,10 @@ export function NewSessionView({ const [selectedWorkflow, setSelectedWorkflow] = useState("none"); const [pendingRepos, setPendingRepos] = useState([]); const [contextModalOpen, setContextModalOpen] = useState(false); + const modelOptions = useMemo( + () => modelsData?.models?.map((m) => ({ id: m.id, name: m.label })) ?? [], + [modelsData?.models], + ); const textareaRef = useRef(null); const addPendingRepo = (url: string) => { @@ -116,14 +134,18 @@ export function NewSessionView({ // Require either a prompt OR a workflow with startupPrompt if (!trimmed && !hasWorkflow) return; + // Only include sdkOptions if any values were set + const hasOptions = Object.values(sdkOptions).some((v) => v !== undefined); + onCreateSession({ prompt: trimmed, runner: selectedRunner, model: selectedModel, workflow: hasWorkflow ? selectedWorkflow : undefined, repos: pendingRepos.length > 0 ? pendingRepos.map((r) => ({ url: r.url })) : undefined, + sdkOptions: hasOptions ? sdkOptions : undefined, }); - }, [prompt, selectedRunner, selectedModel, selectedWorkflow, pendingRepos, onCreateSession]); + }, [prompt, selectedRunner, selectedModel, selectedWorkflow, pendingRepos, sdkOptions, onCreateSession]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -214,6 +236,15 @@ export function NewSessionView({ + {/* Advanced SDK Options (behind feature flag) */} + {showAdvancedOptions && ( + + )} + {/* Pending repo badges */} {pendingRepos.length > 0 && (
diff --git a/components/frontend/src/components/__tests__/advanced-sdk-options.test.tsx b/components/frontend/src/components/__tests__/advanced-sdk-options.test.tsx new file mode 100644 index 000000000..21d2f5615 --- /dev/null +++ b/components/frontend/src/components/__tests__/advanced-sdk-options.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AdvancedSdkOptions } from '../advanced-sdk-options'; +import type { SdkOptions } from '@/types/api/sessions'; + +describe('AdvancedSdkOptions', () => { + const defaultProps = { + value: {} as SdkOptions, + onChange: vi.fn(), + models: [ + { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' }, + { id: 'claude-opus-4-5', name: 'Claude Opus 4.5' }, + ], + }; + + it('renders the collapsible trigger button', () => { + render(); + expect(screen.getByText('Advanced SDK Options')).toBeDefined(); + }); + + it('is collapsed by default', () => { + render(); + expect(screen.queryByText('Model & Generation')).toBeNull(); + }); + + it('expands when trigger is clicked', () => { + render(); + fireEvent.click(screen.getByText('Advanced SDK Options')); + expect(screen.getByText('Model & Generation')).toBeDefined(); + expect(screen.getByText('Execution & Control')).toBeDefined(); + expect(screen.getByText('Allowed Tools')).toBeDefined(); + expect(screen.getByText('System Prompt')).toBeDefined(); + expect(screen.getByText('Beta Feature Flags')).toBeDefined(); + }); + + it('calls onChange when temperature is set', () => { + render(); + fireEvent.click(screen.getByText('Advanced SDK Options')); + const tempInput = screen.getByLabelText('Temperature'); + fireEvent.change(tempInput, { target: { value: '0.5' } }); + expect(defaultProps.onChange).toHaveBeenCalledWith( + expect.objectContaining({ temperature: 0.5 }) + ); + }); + + it('shows JSON preview when toggled', () => { + const props = { + ...defaultProps, + value: { temperature: 0.5, max_tokens: 8000 } as SdkOptions, + }; + render(); + fireEvent.click(screen.getByText('Advanced SDK Options')); + fireEvent.click(screen.getByText('Show JSON Preview')); + expect(screen.getByText(/"temperature": 0.5/)).toBeDefined(); + expect(screen.getByText(/"max_tokens": 8000/)).toBeDefined(); + }); + + it('renders tool toggles', () => { + render(); + fireEvent.click(screen.getByText('Advanced SDK Options')); + expect(screen.getByText('Read')).toBeDefined(); + expect(screen.getByText('Write')).toBeDefined(); + expect(screen.getByText('Bash')).toBeDefined(); + }); +}); diff --git a/components/frontend/src/components/advanced-sdk-options.tsx b/components/frontend/src/components/advanced-sdk-options.tsx new file mode 100644 index 000000000..df24eeca9 --- /dev/null +++ b/components/frontend/src/components/advanced-sdk-options.tsx @@ -0,0 +1,533 @@ +"use client"; + +import { useState } from "react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ChevronsUpDown, Settings, Code, X } from "lucide-react"; +import type { SdkOptions } from "@/types/api/sessions"; + +const DEFAULT_TOOLS = [ + "Read", + "Write", + "Edit", + "MultiEdit", + "Bash", + "Glob", + "Grep", + "WebSearch", + "WebFetch", + "NotebookEdit", + "TodoRead", + "TodoWrite", + "Agent", +]; + +type ModelOption = { + id: string; + name: string; +}; + +type AdvancedSdkOptionsProps = { + value: SdkOptions; + onChange: (opts: SdkOptions) => void; + models?: ModelOption[]; +}; + +export function AdvancedSdkOptions({ + value, + onChange, + models = [], +}: AdvancedSdkOptionsProps) { + const [isOpen, setIsOpen] = useState(false); + const [showJsonPreview, setShowJsonPreview] = useState(false); + const [betaInput, setBetaInput] = useState(""); + const [customToolInput, setCustomToolInput] = useState(""); + + const update = (partial: Partial) => { + onChange({ ...value, ...partial }); + }; + + const toggleTool = (tool: string) => { + const current = value.allowed_tools ?? []; + const next = current.includes(tool) + ? current.filter((t) => t !== tool) + : [...current, tool]; + update({ allowed_tools: next.length > 0 ? next : undefined }); + }; + + const addBeta = () => { + const trimmed = betaInput.trim(); + if (!trimmed) return; + const current = value.betas ?? []; + if (!current.includes(trimmed)) { + update({ betas: [...current, trimmed] }); + } + setBetaInput(""); + }; + + const removeBeta = (beta: string) => { + const next = (value.betas ?? []).filter((b) => b !== beta); + update({ betas: next.length > 0 ? next : undefined }); + }; + + const addCustomTool = () => { + const trimmed = customToolInput.trim(); + if (!trimmed) return; + const current = value.allowed_tools ?? []; + if (!current.includes(trimmed)) { + update({ allowed_tools: [...current, trimmed] }); + } + setCustomToolInput(""); + }; + + return ( + + + + + + {/* Model & Generation */} +
+ + Model & Generation + +
+
+ + + update({ + temperature: + e.target.value === "" + ? undefined + : parseFloat(e.target.value), + }) + } + /> +
+
+ + + update({ + max_tokens: + e.target.value === "" + ? undefined + : parseInt(e.target.value), + }) + } + /> +
+
+ + + update({ + max_thinking_tokens: + e.target.value === "" + ? undefined + : parseInt(e.target.value), + }) + } + /> +
+
+ + + update({ + max_turns: + e.target.value === "" + ? undefined + : parseInt(e.target.value), + }) + } + /> +
+
+ + + update({ + max_budget_usd: + e.target.value === "" + ? undefined + : parseFloat(e.target.value), + }) + } + /> +
+
+ + +
+
+
+ + {/* Execution & Control */} +
+ + Execution & Control + +
+
+ + + update({ + timeout: + e.target.value === "" + ? undefined + : parseInt(e.target.value), + }) + } + /> +
+
+ + + update({ + inactivity_timeout: + e.target.value === "" + ? undefined + : parseInt(e.target.value), + }) + } + /> +
+
+ + +
+
+
+
+ + + update({ include_partial_messages: checked }) + } + /> +
+
+ + + update({ enable_file_checkpointing: checked }) + } + /> +
+
+ + + update({ strict_mcp_config: checked }) + } + /> +
+
+
+ +