fix: honor manual New Agent profile selection (#2730) (#2749)

This commit is contained in:
Carlos Florêncio
2026-08-18 06:47:31 +00:00
committed by GitHub
parent fb38e87fd3
commit 7f299fe46b
11 changed files with 343 additions and 23 deletions
@@ -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)
}
})
}
}
@@ -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
@@ -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
@@ -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(<NewSessionDialog open={true} onOpenChange={vi.fn()} taskId="task-1" />);
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(<NewSessionDialog open={true} onOpenChange={vi.fn()} taskId="task-1" />);
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(<NewSessionDialog open={true} onOpenChange={vi.fn()} taskId="task-1" />);
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 },
@@ -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);
}}
/>
<ContextSelect
value={contextValue}
@@ -37,6 +37,13 @@ const AGENT_PROFILE_A: AgentProfileOption = {
agent_id: "agent-id",
cli_passthrough: false,
};
const AGENT_PROFILE_B: AgentProfileOption = {
id: "profile-b",
label: "Profile B",
agent_name: "agent-b",
agent_id: "agent-id-b",
cli_passthrough: false,
};
const ATTACHMENT: FileAttachment = {
id: "attachment-1",
@@ -260,6 +267,7 @@ describe("useSessionLaunchSubmit", () => {
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,
@@ -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<TaskFormInputsHandle | null>;
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,
@@ -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<typeof import("../../helpers/api-client").ApiClient>,
@@ -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
@@ -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",
@@ -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",
};
@@ -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;