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;