Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions components/backend/handlers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,84 @@ 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,
"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, validating value types.
// Returns the filtered map and an error if any value has an invalid type.
func filterSdkOptions(opts map[string]interface{}) (map[string]interface{}, error) {
filtered := make(map[string]interface{}, len(opts))
for k, v := range opts {
if !allowedSdkOptionKeys[k] {
continue
}
if err := validateSdkOptionValue(k, v); err != nil {
return nil, fmt.Errorf("invalid value for %q: %w", k, err)
}
filtered[k] = v
}
return filtered, nil
}

// validateSdkOptionValue checks that the value type is appropriate for the given SDK option key.
func validateSdkOptionValue(key string, value interface{}) error {
if value == nil {
return nil
}
switch key {
case "model", "permission_mode", "fallback_model", "system_prompt", "output_format":
if _, ok := value.(string); !ok {
return fmt.Errorf("expected string, got %T", value)
}
case "temperature", "max_budget_usd":
switch value.(type) {
case float64, float32, int, int64:
default:
return fmt.Errorf("expected number, got %T", value)
}
case "max_tokens", "max_thinking_tokens", "max_turns":
switch value.(type) {
case float64, int, int64:
default:
return fmt.Errorf("expected integer, got %T", value)
}
case "include_partial_messages", "enable_file_checkpointing", "strict_mcp_config":
if _, ok := value.(bool); !ok {
return fmt.Errorf("expected boolean, got %T", value)
}
case "betas", "allowed_tools":
arr, ok := value.([]interface{})
if !ok {
return fmt.Errorf("expected array, got %T", value)
}
for i, item := range arr {
if _, ok := item.(string); !ok {
return fmt.Errorf("expected string at index %d, got %T", i, item)
}
}
}
return nil
}

func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec {
result := types.AgenticSessionSpec{}

Expand Down Expand Up @@ -771,6 +849,23 @@ func CreateSession(c *gin.Context) {
envVars["JIRA_READ_ONLY_MODE"] = "false"
}

// Serialize sdkOptions as JSON into SDK_OPTIONS env var (filtered to allowed keys only)
if len(req.SdkOptions) > 0 {
filtered, filterErr := filterSdkOptions(req.SdkOptions)
if filterErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid sdkOptions: %v", filterErr)})
return
}
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
Expand Down Expand Up @@ -1264,6 +1359,34 @@ func UpdateSession(c *gin.Context) {
spec["timeout"] = *req.Timeout
}

// Update SDK options in environmentVariables (filtered to allowed keys only)
if req.ClearSdkOptions {
envVars, _ := spec["environmentVariables"].(map[string]interface{})
if envVars != nil {
delete(envVars, "SDK_OPTIONS")
spec["environmentVariables"] = envVars
}
} else if len(req.SdkOptions) > 0 {
filtered, filterErr := filterSdkOptions(req.SdkOptions)
if filterErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid sdkOptions: %v", filterErr)})
return
}
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 {
Expand Down
37 changes: 20 additions & 17 deletions components/backend/types/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -73,10 +74,12 @@ 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"`
ClearSdkOptions bool `json:"clearSdkOptions,omitempty"`
}

type CloneAgenticSessionRequest struct {
Expand Down
3 changes: 3 additions & 0 deletions components/frontend/src/app/projects/[name]/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -25,6 +26,7 @@ export default function NewSessionPage() {
model: string;
workflow?: string;
repos?: Array<{ url: string; branch?: string; autoPush?: boolean }>;
sdkOptions?: SdkOptions;
}) => {
const workflowConfig = config.workflow === "custom" && customWorkflow
? { gitUrl: customWorkflow.gitUrl, branch: customWorkflow.branch, path: customWorkflow.path }
Expand Down Expand Up @@ -57,6 +59,7 @@ export default function NewSessionPage() {
})),
}
: {}),
...(config.sdkOptions ? { sdkOptions: config.sdkOptions } : {}),
},
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
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';

function createWrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function TestQueryWrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
return TestQueryWrapper;
}

vi.mock('../runner-model-selector', () => ({
RunnerModelSelector: ({ onSelect }: { onSelect: (r: string, m: string) => void }) => (
<button data-testid="runner-model-selector" onClick={() => onSelect('claude-agent-sdk', 'claude-sonnet-4-5')}>
Expand Down Expand Up @@ -53,6 +62,10 @@ vi.mock('../modals/add-context-modal', () => ({
),
}));

vi.mock('@/services/api/feature-flags-admin', () => ({
evaluateFeatureFlag: vi.fn().mockResolvedValue({ enabled: false }),
}));

describe('NewSessionView', () => {
const defaultProps = {
projectName: 'test-project',
Expand All @@ -65,32 +78,32 @@ describe('NewSessionView', () => {
});

it('renders heading and subtitle', () => {
render(<NewSessionView {...defaultProps} />);
render(<NewSessionView {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('What are you working on?')).toBeDefined();
expect(screen.getByText(/Start a new session/)).toBeDefined();
});

it('renders textarea with placeholder', () => {
render(<NewSessionView {...defaultProps} />);
render(<NewSessionView {...defaultProps} />, { wrapper: createWrapper() });
const textarea = screen.getByPlaceholderText("Describe what you'd like to work on...");
expect(textarea).toBeDefined();
});

it('renders runner/model selector and workflow selector', () => {
render(<NewSessionView {...defaultProps} />);
render(<NewSessionView {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByTestId('runner-model-selector')).toBeDefined();
expect(screen.getByTestId('workflow-selector')).toBeDefined();
});

it('send button is disabled when textarea is empty', () => {
render(<NewSessionView {...defaultProps} />);
render(<NewSessionView {...defaultProps} />, { wrapper: createWrapper() });
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(<NewSessionView {...defaultProps} />);
render(<NewSessionView {...defaultProps} />, { wrapper: createWrapper() });
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 });
Expand All @@ -104,14 +117,14 @@ describe('NewSessionView', () => {
});

it('does not submit when prompt is empty', () => {
render(<NewSessionView {...defaultProps} />);
render(<NewSessionView {...defaultProps} />, { wrapper: createWrapper() });
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(<NewSessionView {...defaultProps} />);
render(<NewSessionView {...defaultProps} />, { wrapper: createWrapper() });
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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -20,10 +20,14 @@
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;
Expand All @@ -40,6 +44,7 @@
model: string;
workflow?: string;
repos?: Array<{ url: string; branch?: string; autoPush?: boolean }>;
sdkOptions?: SdkOptions;
}) => void;
ootbWorkflows: WorkflowConfig[];
onLoadCustomWorkflow?: () => void;
Expand All @@ -54,10 +59,19 @@
isSubmitting = false,
}: NewSessionViewProps) {
const { data: runnerTypes } = useRunnerTypes(projectName);
const { data: advancedFlagData } = useQuery({

Check failure on line 62 in components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx

View workflow job for this annotation

GitHub Actions / Frontend Unit Tests (Vitest)

src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx > NewSessionView > removes a pending repo badge when the X button is clicked

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:15:11 ❯ useBaseQuery node_modules/@tanstack/react-query/build/modern/useBaseQuery.js:30:18 ❯ useQuery node_modules/@tanstack/react-query/build/modern/useQuery.js:7:10 ❯ NewSessionView src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx:62:38 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22

Check failure on line 62 in components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx

View workflow job for this annotation

GitHub Actions / Frontend Unit Tests (Vitest)

src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx > NewSessionView > omits branch from repos when no branch is specified

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:15:11 ❯ useBaseQuery node_modules/@tanstack/react-query/build/modern/useBaseQuery.js:30:18 ❯ useQuery node_modules/@tanstack/react-query/build/modern/useQuery.js:7:10 ❯ NewSessionView src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx:62:38 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22

Check failure on line 62 in components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx

View workflow job for this annotation

GitHub Actions / Frontend Unit Tests (Vitest)

src/app/projects/[name]/sessions/[sessionName]/components/__tests__/new-session-view.test.tsx > NewSessionView > includes branch and autoPush in onCreateSession when repo is added with branch

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:15:11 ❯ useBaseQuery node_modules/@tanstack/react-query/build/modern/useBaseQuery.js:30:18 ❯ useQuery node_modules/@tanstack/react-query/build/modern/useQuery.js:7:10 ❯ NewSessionView src/app/projects/[name]/sessions/[sessionName]/components/new-session-view.tsx:62:38 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:17641:22
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<string>(DEFAULT_RUNNER_TYPE_ID);
const [selectedModel, setSelectedModel] = useState<string>("");
const [sdkOptions, setSdkOptions] = useState<SdkOptions>({});

const currentRunner = runnerTypes?.find((r) => r.id === selectedRunner);
const currentProvider = currentRunner?.provider;
Expand Down Expand Up @@ -99,6 +113,10 @@
const [selectedWorkflow, setSelectedWorkflow] = useState("none");
const [pendingRepos, setPendingRepos] = useState<PendingRepo[]>([]);
const [contextModalOpen, setContextModalOpen] = useState(false);
const modelOptions = useMemo(
() => modelsData?.models?.map((m) => ({ id: m.id, name: m.label })) ?? [],
[modelsData?.models],
);
const textareaRef = useRef<HTMLTextAreaElement>(null);

// Auto-resize the textarea as the user types.
Expand Down Expand Up @@ -128,14 +146,18 @@
// 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, branch: r.branch, autoPush: r.autoPush })) : undefined,
sdkOptions: hasOptions ? sdkOptions : undefined,
});
}, [prompt, selectedRunner, selectedModel, selectedWorkflow, pendingRepos, onCreateSession]);
}, [prompt, selectedRunner, selectedModel, selectedWorkflow, pendingRepos, sdkOptions, onCreateSession]);

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
Expand Down Expand Up @@ -226,6 +248,15 @@
</div>
</div>

{/* Advanced SDK Options (behind feature flag) */}
{showAdvancedOptions && (
<AdvancedSdkOptions
value={sdkOptions}
onChange={setSdkOptions}
models={modelOptions}
/>
)}

{/* Pending repo badges */}
{pendingRepos.length > 0 && (
<div className="flex gap-2 flex-wrap">
Expand Down
Loading
Loading