@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user