From 7f299fe46b56ec124080dcca246ddba6221fd60f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 18 Aug 2026 06:47:31 +0000 Subject: [PATCH] fix: honor manual New Agent profile selection (#2730) (#2749) --- .../manual_profile_launch_test.go | 101 ++++++++++++++++++ .../internal/orchestrator/session_launch.go | 32 +++--- .../internal/orchestrator/task_operations.go | 11 +- .../task/new-session-dialog.test.tsx | 48 ++++++++- .../components/task/new-session-dialog.tsx | 7 +- .../task/new-session-form-actions.test.ts | 60 +++++++++++ .../task/new-session-form-actions.ts | 14 ++- .../workflow/workflow-agent-switch.spec.ts | 77 ++++++++++++- .../services/session-launch-helpers.test.ts | 12 +++ .../lib/services/session-launch-helpers.ts | 2 + .../lib/services/session-launch-service.ts | 2 + 11 files changed, 343 insertions(+), 23 deletions(-) create mode 100644 apps/backend/internal/orchestrator/manual_profile_launch_test.go diff --git a/apps/backend/internal/orchestrator/manual_profile_launch_test.go b/apps/backend/internal/orchestrator/manual_profile_launch_test.go new file mode 100644 index 0000000000..2cd5348bfb --- /dev/null +++ b/apps/backend/internal/orchestrator/manual_profile_launch_test.go @@ -0,0 +1,101 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/orchestrator/executor" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +func TestLaunchSessionExplicitProfilePrecedence(t *testing.T) { + tests := []struct { + name string + requestJSON string + wantProfileID string + }{ + { + name: "explicit profile bypasses workflow step", + requestJSON: `{"task_id":"task-1","intent":"start","agent_profile_id":"profile-b","profile_explicit":true}`, + wantProfileID: "profile-b", + }, + { + name: "omitted explicit flag keeps workflow step", + requestJSON: `{"task_id":"task-1","intent":"start","agent_profile_id":"profile-b"}`, + wantProfileID: "profile-a", + }, + { + name: "explicit empty profile keeps workflow step", + requestJSON: `{"task_id":"task-1","intent":"start","agent_profile_id":"","profile_explicit":true}`, + wantProfileID: "profile-a", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSessionWithStep(t, repo, "task-1", "existing-session", "step-a") + + stepGetter := newMockStepGetter() + stepGetter.steps["step-a"] = &wfmodels.WorkflowStep{ + ID: "step-a", + WorkflowID: "wf1", + AgentProfileID: "profile-a", + } + taskRepo := newMockTaskRepo() + taskRepo.tasks["task-1"] = &v1.Task{ + ID: "task-1", + WorkspaceID: "ws1", + Title: "Task", + Description: "Do the work", + State: v1.TaskStateInProgress, + } + + var launchedRequest *executor.LaunchAgentRequest + agentManager := &mockAgentManager{ + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + requestCopy := *req + launchedRequest = &requestCopy + return &executor.LaunchAgentResponse{ + AgentExecutionID: "execution-1", + Status: v1.AgentStatusStarting, + }, nil + }, + } + svc := createTestServiceWithScheduler(repo, stepGetter, taskRepo, agentManager) + + var request LaunchSessionRequest + if err := json.Unmarshal([]byte(tt.requestJSON), &request); err != nil { + t.Fatalf("decode launch request: %v", err) + } + + response, err := svc.LaunchSession(ctx, &request) + if err != nil { + t.Fatalf("LaunchSession returned error: %v", err) + } + if response.AgentProfileID != tt.wantProfileID { + t.Fatalf("response agent_profile_id = %q, want %q", response.AgentProfileID, tt.wantProfileID) + } + if response.AgentExecutionID != "execution-1" { + t.Fatalf("response agent_execution_id = %q, want execution-1", response.AgentExecutionID) + } + + storedSession, err := repo.GetTaskSession(ctx, response.SessionID) + if err != nil { + t.Fatalf("load launched session: %v", err) + } + if storedSession.AgentProfileID != tt.wantProfileID { + t.Fatalf("stored session agent_profile_id = %q, want %q", storedSession.AgentProfileID, tt.wantProfileID) + } + if launchedRequest == nil { + t.Fatal("agent launch request was not captured") + } + if launchedRequest.AgentProfileID != tt.wantProfileID { + t.Fatalf("agent launch agent_profile_id = %q, want %q", launchedRequest.AgentProfileID, tt.wantProfileID) + } + }) + } +} diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index ac6334733c..d053d4de58 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -36,19 +36,23 @@ const ( // LaunchSessionRequest is the unified request for session.launch. type LaunchSessionRequest struct { - TaskID string `json:"task_id"` - Intent SessionIntent `json:"intent,omitempty"` - SessionID string `json:"session_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - ExecutorProfileID string `json:"executor_profile_id,omitempty"` - Prompt string `json:"prompt,omitempty"` - PlanMode bool `json:"plan_mode,omitempty"` - WorkflowStepID string `json:"workflow_step_id,omitempty"` - Priority string `json:"priority,omitempty"` - LaunchWorkspace bool `json:"launch_workspace,omitempty"` - SkipMessageRecord bool `json:"skip_message_record,omitempty"` - AutoStart bool `json:"auto_start,omitempty"` + TaskID string `json:"task_id"` + Intent SessionIntent `json:"intent,omitempty"` + SessionID string `json:"session_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + // ProfileExplicit marks a non-empty profile selected by the manual New Agent + // picker. It bypasses workflow-step profile resolution for IntentStart only; + // IntentStartCreated keeps its existing profile resolution behavior. + ProfileExplicit bool `json:"profile_explicit,omitempty"` + ExecutorID string `json:"executor_id,omitempty"` + ExecutorProfileID string `json:"executor_profile_id,omitempty"` + Prompt string `json:"prompt,omitempty"` + PlanMode bool `json:"plan_mode,omitempty"` + WorkflowStepID string `json:"workflow_step_id,omitempty"` + Priority string `json:"priority,omitempty"` + LaunchWorkspace bool `json:"launch_workspace,omitempty"` + SkipMessageRecord bool `json:"skip_message_record,omitempty"` + AutoStart bool `json:"auto_start,omitempty"` // NoAgentLaunch marks a prepare request that must NEVER be upgraded into an // agent launch, even for passthrough profiles (whose prepare would normally // be eagerly upgraded so the PTY exists). It backs the session.ensure @@ -248,7 +252,7 @@ func (s *Service) launchStart(ctx context.Context, req *LaunchSessionRequest) (* ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.ExecutorProfileID, req.Priority, req.Prompt, req.WorkflowStepID, req.PlanMode, req.AutoStart, req.Attachments, - startTaskOptions{SpawnOrigin: req.SpawnOrigin}, + startTaskOptions{ProfileExplicit: req.ProfileExplicit, SpawnOrigin: req.SpawnOrigin}, ) if err != nil { return nil, err diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 9a65681589..91afaae381 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -794,6 +794,9 @@ func (s *Service) StartTaskWithEnv(ctx context.Context, taskID string, agentProf // some callers supply. Keeping them in one struct avoids growing startTask's // already long positional parameter list for every new orthogonal concern. type startTaskOptions struct { + // ProfileExplicit marks a non-empty profile selected by the manual New Agent + // picker. It bypasses workflow-step profile resolution for this new session. + ProfileExplicit bool // Env holds launch-scoped environment variables for the agent runtime. Env map[string]string // Route pins a concrete execution profile chosen by Office provider routing. @@ -903,7 +906,13 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s // The frontend may pass the workspace default profile, but the step may // require a different agent (e.g., Codex on "In Progress", Auggie on "Review"). callerProfileID := agentProfileID - agentProfileID = s.resolveEffectiveAgentProfile(ctx, taskID, workflowStepID, agentProfileID) + if opts.ProfileExplicit && agentProfileID != "" { + s.logger.Info("manual agent profile selection takes precedence over workflow step", + zap.String("task_id", taskID), + zap.String("agent_profile_id", agentProfileID)) + } else { + agentProfileID = s.resolveEffectiveAgentProfile(ctx, taskID, workflowStepID, agentProfileID) + } overrideApplied := agentProfileID != callerProfileID if route != nil && route.ExecutionProfileID != "" { agentProfileID = route.ExecutionProfileID diff --git a/apps/web/components/task/new-session-dialog.test.tsx b/apps/web/components/task/new-session-dialog.test.tsx index 33bdea8ad2..a3c628ca44 100644 --- a/apps/web/components/task/new-session-dialog.test.tsx +++ b/apps/web/components/task/new-session-dialog.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types"; @@ -8,6 +8,8 @@ const mockBuildStartRequest = vi.fn(); const mockLaunchSession = vi.fn(); const mockSetActiveSession = vi.fn(); let mockAgentSelectorValue: string | undefined; +let mockAgentSelectorOnChange: ((value: string) => void) | undefined; +const PLUGIN_COMPOSER_LABEL = "Plugin composer action"; const BASE_PROFILE = { id: "profile-1", @@ -139,7 +141,7 @@ vi.mock("@/components/task-create-dialog-selectors", async () => { type: "button", // Stands in for a plugin composer action: insert text, then ask the // dialog to submit the native way. - "aria-label": "Plugin composer action", + "aria-label": PLUGIN_COMPOSER_LABEL, onClick: () => { updateValue("dictated prompt"); onComposerSubmit?.(); @@ -150,8 +152,9 @@ vi.mock("@/components/task-create-dialog-selectors", async () => { ); } return { - AgentSelector: (props: { value?: string }) => { + AgentSelector: (props: { value?: string; onValueChange?: (value: string) => void }) => { mockAgentSelectorValue = props.value; + mockAgentSelectorOnChange = props.onValueChange; return null; }, TaskFormInputs, @@ -230,11 +233,13 @@ vi.mock("./session-dialog-shared", () => ({ import { NewSessionDialog } from "./new-session-dialog"; +// eslint-disable-next-line max-lines-per-function describe("NewSessionDialog", () => { afterEach(() => { cleanup(); mockState.agentProfiles.items = [BASE_PROFILE]; mockAgentSelectorValue = undefined; + mockAgentSelectorOnChange = undefined; }); beforeEach(() => { @@ -277,7 +282,7 @@ describe("NewSessionDialog", () => { it("submits text a plugin composer action inserted into a blank composer", async () => { render(); - fireEvent.click(screen.getByRole("button", { name: "Plugin composer action" })); + fireEvent.click(screen.getByRole("button", { name: PLUGIN_COMPOSER_LABEL })); await waitFor(() => expect(mockLaunchSession).toHaveBeenCalledTimes(1)); expect(mockBuildStartRequest).toHaveBeenCalledWith( @@ -287,6 +292,41 @@ describe("NewSessionDialog", () => { ); }); + it("does not mark the initialized profile explicit until the picker changes", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: PLUGIN_COMPOSER_LABEL })); + + await waitFor(() => + expect(mockBuildStartRequest).toHaveBeenCalledWith( + "task-1", + "profile-1", + expect.objectContaining({ profileExplicit: false }), + ), + ); + }); + + it("marks a changed picker profile explicit", async () => { + mockState.agentProfiles.items = [ + BASE_PROFILE, + { ...BASE_PROFILE, id: "profile-2", label: "Profile 2" }, + ]; + render(); + + await act(async () => { + mockAgentSelectorOnChange?.("profile-2"); + }); + fireEvent.click(screen.getByRole("button", { name: PLUGIN_COMPOSER_LABEL })); + + await waitFor(() => + expect(mockBuildStartRequest).toHaveBeenCalledWith( + "task-1", + "profile-2", + expect.objectContaining({ profileExplicit: true }), + ), + ); + }); + it("defaults to the first enabled profile when the session profile is disabled", () => { mockState.agentProfiles.items = [ { ...BASE_PROFILE, enabled: false }, diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx index bf63fdc7c7..26d3fcd855 100644 --- a/apps/web/components/task/new-session-dialog.tsx +++ b/apps/web/components/task/new-session-dialog.tsx @@ -376,6 +376,7 @@ function NewSessionForm({ const [selectedProfileId, setSelectedProfileId] = useState( handoffInitial?.selectedProfileId ?? initialProfileId ?? defaultProfileId, ); + const [profileExplicit, setProfileExplicit] = useState(Boolean(handoff)); const [hasPrompt, setHasPrompt] = useState(false); const [hasPendingAttachmentUploads, setHasPendingAttachmentUploads] = useState(false); const [isCreating, setIsCreating] = useState(false); @@ -407,6 +408,7 @@ function NewSessionForm({ promptRef, taskId, selectedProfileId, + profileExplicit, executorId, contextValue, initialPrompt, @@ -434,7 +436,10 @@ function NewSessionForm({ profileOptions={profileSelection.profileOptions} selectedProfileId={selectedProfileId} isCreating={isCreating} - onProfileChange={setSelectedProfileId} + onProfileChange={(value) => { + setProfileExplicit(true); + setSelectedProfileId(value); + }} /> { promptRef, taskId: TASK_ID, selectedProfileId: PROFILE_ID, + profileExplicit: true, executorId: EXECUTOR_ID, contextValue: "blank", initialPrompt: null, @@ -283,6 +291,7 @@ describe("useSessionLaunchSubmit", () => { expect(mockBuildStartRequest).toHaveBeenCalledWith(TASK_ID, PROFILE_ID, { executorId: EXECUTOR_ID, prompt: "hello", + profileExplicit: true, attachments: MESSAGE_ATTACHMENTS, }); expect(mockLaunchSession).toHaveBeenCalledWith({ @@ -302,6 +311,53 @@ describe("useSessionLaunchSubmit", () => { expect(mockSetIsCreating).toHaveBeenLastCalledWith(false); }); + it("marks the picker profile explicit and labels the tab from the effective response profile", async () => { + mockLaunchSession.mockResolvedValueOnce({ + session_id: SESSION_ID, + agent_profile_id: AGENT_PROFILE_B.id, + }); + const promptRef = createPromptRef("hello"); + const mockSetActiveSession = vi.fn(); + const mockActivateSession = vi.fn(); + const mockSetIsCreating = vi.fn(); + const mockOnClose = vi.fn(); + + const { result } = renderHook(() => + useSessionLaunchSubmit({ + promptRef, + taskId: TASK_ID, + selectedProfileId: AGENT_PROFILE_A.id, + profileExplicit: true, + executorId: EXECUTOR_ID, + contextValue: "blank", + initialPrompt: null, + agentProfiles: [AGENT_PROFILE_A, AGENT_PROFILE_B], + onClose: mockOnClose, + toast: mockToast, + setActiveSession: mockSetActiveSession, + activateSession: mockActivateSession, + setIsCreating: mockSetIsCreating, + }), + ); + + await act(async () => { + await result.current({ preventDefault: vi.fn() } as unknown as FormEvent); + }); + + expect(mockBuildStartRequest).toHaveBeenCalledWith( + TASK_ID, + AGENT_PROFILE_A.id, + expect.objectContaining({ profileExplicit: true }), + ); + expect(mockActivateSession).toHaveBeenCalledWith( + SESSION_ID, + TASK_ID, + AGENT_PROFILE_B.label, + undefined, + mockSetActiveSession, + ); + }); + it("uses initial prompt when context is copy_prompt and user did not type anything", async () => { const promptRef = createPromptRef(" ", [ATTACHMENT]); const mockSetActiveSession = vi.fn(); @@ -314,6 +370,7 @@ describe("useSessionLaunchSubmit", () => { promptRef, taskId: TASK_ID, selectedProfileId: PROFILE_ID, + profileExplicit: true, executorId: EXECUTOR_ID, contextValue: "copy_prompt", initialPrompt: SEED_PROMPT, @@ -360,6 +417,7 @@ describe("useSessionLaunchSubmit", () => { promptRef, taskId: TASK_ID, selectedProfileId: PROFILE_ID, + profileExplicit: true, executorId: EXECUTOR_ID, contextValue: "blank", initialPrompt: null, @@ -398,6 +456,7 @@ describe("useSessionLaunchSubmit", () => { promptRef, taskId: TASK_ID, selectedProfileId: PROFILE_ID, + profileExplicit: true, executorId: EXECUTOR_ID, contextValue: "blank", initialPrompt: null, @@ -434,6 +493,7 @@ describe("useSessionLaunchSubmit", () => { promptRef, taskId: TASK_ID, selectedProfileId: PROFILE_ID, + profileExplicit: true, executorId: EXECUTOR_ID, contextValue: "blank", initialPrompt: null, diff --git a/apps/web/components/task/new-session-form-actions.ts b/apps/web/components/task/new-session-form-actions.ts index 108d1306d6..7ffeae2e6b 100644 --- a/apps/web/components/task/new-session-form-actions.ts +++ b/apps/web/components/task/new-session-form-actions.ts @@ -20,6 +20,11 @@ type SessionContextChangeOpts = { setHasPrompt: (v: boolean) => void; }; +function launchErrorDescription(error: unknown): string { + if (error instanceof Error) return error.message; + return t("common:unknownError"); +} + export function useSessionContextChange(opts: SessionContextChangeOpts) { const { promptRef, initialPrompt, summarize, toast, setContextValue, setHasPrompt } = opts; return useCallback( @@ -46,6 +51,7 @@ export function useSessionLaunchSubmit({ promptRef, taskId, selectedProfileId, + profileExplicit, executorId, contextValue, initialPrompt, @@ -60,6 +66,7 @@ export function useSessionLaunchSubmit({ promptRef: RefObject; taskId: string; selectedProfileId: string; + profileExplicit: boolean; executorId: string; contextValue: string; initialPrompt: string | null; @@ -91,13 +98,15 @@ export function useSessionLaunchSubmit({ const { request } = buildStartRequest(taskId, selectedProfileId, { executorId, prompt, + profileExplicit, attachments: toMessageAttachments(selectedAttachments), }); const response = await launchSession(request); if (!response.session_id) { throw new Error("Session created but no session ID returned"); } - const profile = agentProfiles.find((p) => p.id === selectedProfileId); + const effectiveProfileId = response.agent_profile_id ?? selectedProfileId; + const profile = agentProfiles.find((p) => p.id === effectiveProfileId); activateSession( response.session_id, taskId, @@ -109,7 +118,7 @@ export function useSessionLaunchSubmit({ } catch (error) { toast({ title: t("task:failedToCreateSession"), - description: error instanceof Error ? error.message : t("common:unknownError"), + description: launchErrorDescription(error), variant: "error", }); } finally { @@ -120,6 +129,7 @@ export function useSessionLaunchSubmit({ promptRef, taskId, selectedProfileId, + profileExplicit, executorId, contextValue, initialPrompt, diff --git a/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts b/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts index 31057c28c1..1372a5e858 100644 --- a/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "../../fixtures/test-base"; import { SessionPage } from "../../pages/session-page"; import { WorkflowSettingsPage } from "../../pages/workflow-settings-page"; -import { dwell } from "../../helpers/causal-waits"; +import { dwell, watchWs } from "../../helpers/causal-waits"; async function createProfiles( apiClient: InstanceType, @@ -555,6 +555,81 @@ test.describe("Workflow agent profile switching", () => { }); }); + test("manual New Agent picker overrides the pinned workflow profile", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const { profileA, profileB } = await createProfiles(apiClient); + + const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Manual Profile Picker"); + const step1 = await apiClient.createWorkflowStep(workflow.id, "Step1", 0, { + is_start_step: true, + }); + await apiClient.createWorkflowStep(workflow.id, "Done", 1); + await apiClient.updateWorkflowStep(step1.id, { + agent_profile_id: profileA.id, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Manual Profile Picker Task", + profileA.id, + { + workflow_id: workflow.id, + workflow_step_id: step1.id, + repository_ids: [seedData.repositoryId], + }, + ); + + const initialSessions = await pollSessions(apiClient, task.id, 1, 60_000); + const profileASession = initialSessions.find((item) => item.agent_profile_id === profileA.id); + expect(profileASession).toBeDefined(); + + const ws = watchWs(testPage); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.openNewSessionDialog(); + + const dialog = session.newSessionDialog(); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + const selector = dialog.getByTestId("agent-profile-selector"); + await expect(selector).toBeVisible({ timeout: 10_000 }); + await selector.click(); + + const listbox = testPage.getByRole("listbox"); + await expect(listbox.getByRole("option", { name: profileB.name, exact: false })).toBeVisible({ + timeout: 10_000, + }); + await listbox.getByRole("option", { name: profileB.name, exact: false }).click(); + await expect(selector).toContainText(profileB.name); + + await session.newSessionPromptInput().fill("/e2e:simple-message"); + const sessionCreated = ws.waitForEvent("session.state_changed", { + where: (payload) => + payload.task_id === task.id && + payload.agent_profile_id === profileB.id && + payload.new_state === "CREATED", + }); + await session.newSessionStartButton().click(); + await expect(dialog).not.toBeVisible({ timeout: 15_000 }); + + const sessionCreatedFrame = await sessionCreated; + const profileBSessionId = String(sessionCreatedFrame.payload.session_id ?? ""); + expect(profileBSessionId).not.toBe(""); + + const finalSessions = await pollSessions(apiClient, task.id, 2, 60_000); + const profileBSession = finalSessions.find((item) => item.id === profileBSessionId); + expect(profileBSession?.agent_profile_id).toBe(profileB.id); + + const profileBTab = session.sessionTabBySessionId(profileBSessionId); + await expect(profileBTab).toBeVisible({ timeout: 30_000 }); + await expect(profileBTab).toContainText(profileB.name); + }); + /** * Verifies the switchSessionForStep WS event fix: when a task is manually moved * to a step with a different agent profile, the new session is created and diff --git a/apps/web/lib/services/session-launch-helpers.test.ts b/apps/web/lib/services/session-launch-helpers.test.ts index a94b52459f..e8cf03774e 100644 --- a/apps/web/lib/services/session-launch-helpers.test.ts +++ b/apps/web/lib/services/session-launch-helpers.test.ts @@ -2,6 +2,18 @@ import { describe, expect, it } from "vitest"; import { buildStartRequest } from "./session-launch-helpers"; describe("buildStartRequest", () => { + it("omits profile_explicit unless the caller opts in", () => { + const { request } = buildStartRequest("task-1", "profile-a"); + + expect(request).not.toHaveProperty("profile_explicit"); + }); + + it("serializes an explicit profile selection", () => { + const { request } = buildStartRequest("task-1", "profile-b", { profileExplicit: true }); + + expect(request).toHaveProperty("profile_explicit", true); + }); + it("serializes a canonical task priority string", () => { const { request } = buildStartRequest("task-1", "agent-1", { priority: "critical", diff --git a/apps/web/lib/services/session-launch-helpers.ts b/apps/web/lib/services/session-launch-helpers.ts index aa7d338449..85820562b4 100644 --- a/apps/web/lib/services/session-launch-helpers.ts +++ b/apps/web/lib/services/session-launch-helpers.ts @@ -18,6 +18,7 @@ export function buildStartRequest( planMode?: boolean; priority?: TaskPriority; autoStart?: boolean; + profileExplicit?: boolean; attachments?: MessageAttachment[]; }, ): BuildResult { @@ -33,6 +34,7 @@ export function buildStartRequest( priority: opts?.priority, auto_start: opts?.autoStart, attachments: opts?.attachments, + ...(opts?.profileExplicit !== undefined ? { profile_explicit: opts.profileExplicit } : {}), }, layout: opts?.planMode ? "plan" : "default", }; diff --git a/apps/web/lib/services/session-launch-service.ts b/apps/web/lib/services/session-launch-service.ts index 22e1212603..d9cbb87d38 100644 --- a/apps/web/lib/services/session-launch-service.ts +++ b/apps/web/lib/services/session-launch-service.ts @@ -24,6 +24,7 @@ export type LaunchSessionRequest = { intent?: SessionIntent; session_id?: string; agent_profile_id?: string; + profile_explicit?: boolean; executor_id?: string; executor_profile_id?: string; prompt?: string; @@ -41,6 +42,7 @@ export type LaunchSessionResponse = { task_id: string; session_id?: string; agent_execution_id?: string; + agent_profile_id?: string; state: string; worktree_path?: string; worktree_branch?: string;