fix(web): disable resume and show agent selector when profile is deleted (#578)
This commit is contained in:
@@ -114,7 +114,7 @@ func (r *sqliteRepository) initSchema() error {
|
||||
}
|
||||
|
||||
// migrateDropModelCheckConstraint recreates agent_profiles without the legacy
|
||||
// CHECK(model != '') constraint. Existing databases created before the ACP-first
|
||||
// CHECK(model != ”) constraint. Existing databases created before the ACP-first
|
||||
// migration carry this constraint, which prevents empty model values. New
|
||||
// databases (created by the CREATE TABLE IF NOT EXISTS above) never have it.
|
||||
//
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { forwardRef, useCallback, useState } from "react";
|
||||
import { IconAlertTriangle, IconPlus, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { NewSessionDialog } from "@/components/task/new-session-dialog";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
@@ -93,6 +95,15 @@ function FailedSessionBanner({
|
||||
}) {
|
||||
const [isResuming, setIsResuming] = useState(false);
|
||||
|
||||
const agentProfileId = useAppStore((s) =>
|
||||
sessionId ? (s.taskSessions.items[sessionId]?.agent_profile_id ?? "") : "",
|
||||
);
|
||||
const profileExists = useAppStore(
|
||||
(s) =>
|
||||
agentProfileId !== "" &&
|
||||
s.agentProfiles.items.some((p: { id: string }) => p.id === agentProfileId),
|
||||
);
|
||||
|
||||
const handleResume = useCallback(async () => {
|
||||
if (!sessionId || !taskId) return;
|
||||
const client = getWebSocketClient();
|
||||
@@ -119,16 +130,24 @@ function FailedSessionBanner({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{sessionId && taskId && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5 cursor-pointer"
|
||||
onClick={handleResume}
|
||||
disabled={isResuming}
|
||||
>
|
||||
<IconPlayerPlay className="h-3.5 w-3.5" />
|
||||
{isResuming ? "Resuming..." : "Resume"}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex" data-testid="failed-session-resume-wrapper">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
data-testid="failed-session-resume-button"
|
||||
className="shrink-0 gap-1.5 cursor-pointer"
|
||||
onClick={handleResume}
|
||||
disabled={isResuming || !profileExists}
|
||||
>
|
||||
<IconPlayerPlay className="h-3.5 w-3.5" />
|
||||
{isResuming ? "Resuming..." : "Resume"}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!profileExists && <TooltipContent>Agent profile no longer exists</TooltipContent>}
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -71,7 +71,22 @@ function useNewSessionDialogState(taskId: string) {
|
||||
return executor?.name ?? null;
|
||||
});
|
||||
|
||||
return { taskTitle, agentProfiles, currentSession, worktreeBranch, initialPrompt, executorLabel };
|
||||
const sessionProfileId = currentSession?.agent_profile_id ?? "";
|
||||
const profileIsValid = agentProfiles.some((p: { id: string }) => p.id === sessionProfileId);
|
||||
const effectiveDefaultProfileId: string = profileIsValid
|
||||
? sessionProfileId
|
||||
: (agentProfiles[0]?.id ?? "");
|
||||
|
||||
return {
|
||||
taskTitle,
|
||||
agentProfiles,
|
||||
currentSession,
|
||||
worktreeBranch,
|
||||
initialPrompt,
|
||||
executorLabel,
|
||||
sessionProfileId,
|
||||
effectiveDefaultProfileId,
|
||||
};
|
||||
}
|
||||
|
||||
function activateNewSession(
|
||||
@@ -111,6 +126,7 @@ function useSessionOptions(taskId: string) {
|
||||
function NewSessionForm({
|
||||
taskId,
|
||||
defaultProfileId,
|
||||
initialProfileId,
|
||||
executorId,
|
||||
executorLabel,
|
||||
worktreeBranch,
|
||||
@@ -121,6 +137,7 @@ function NewSessionForm({
|
||||
}: {
|
||||
taskId: string;
|
||||
defaultProfileId: string;
|
||||
initialProfileId?: string;
|
||||
executorId: string;
|
||||
executorLabel: string | null;
|
||||
worktreeBranch: string | null;
|
||||
@@ -134,7 +151,7 @@ function NewSessionForm({
|
||||
const { summarize, isSummarizing } = useSummarizeSession();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [contextValue, setContextValue] = useState("blank");
|
||||
const [selectedProfileId, setSelectedProfileId] = useState(defaultProfileId);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState(initialProfileId ?? defaultProfileId);
|
||||
const [hasPrompt, setHasPrompt] = useState(false);
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
const {
|
||||
@@ -167,6 +184,12 @@ function NewSessionForm({
|
||||
}
|
||||
});
|
||||
}, [enhancePrompt]);
|
||||
const hasProfiles = profileOptions.length > 0;
|
||||
const showAgentSelector =
|
||||
hasProfiles &&
|
||||
(profileOptions.length > 1 ||
|
||||
(!!defaultProfileId && !profileOptions.find((o) => o.value === defaultProfileId)));
|
||||
const isBusy = isCreating || isSummarizing;
|
||||
|
||||
const handleContextChange = useCallback(
|
||||
async (value: string) => {
|
||||
@@ -207,7 +230,7 @@ function NewSessionForm({
|
||||
if (!prompt) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const { request } = buildStartRequest(taskId, selectedProfileId || defaultProfileId, {
|
||||
const { request } = buildStartRequest(taskId, selectedProfileId, {
|
||||
executorId,
|
||||
prompt,
|
||||
attachments: toMessageAttachments(attachments),
|
||||
@@ -216,9 +239,7 @@ function NewSessionForm({
|
||||
if (!response.session_id) {
|
||||
throw new Error("Session created but no session ID returned");
|
||||
}
|
||||
const profile = agentProfiles.find(
|
||||
(p: AgentProfileOption) => p.id === (selectedProfileId || defaultProfileId),
|
||||
);
|
||||
const profile = agentProfiles.find((p: AgentProfileOption) => p.id === selectedProfileId);
|
||||
activateNewSession(
|
||||
response.session_id,
|
||||
taskId,
|
||||
@@ -240,7 +261,6 @@ function NewSessionForm({
|
||||
[
|
||||
taskId,
|
||||
selectedProfileId,
|
||||
defaultProfileId,
|
||||
executorId,
|
||||
contextValue,
|
||||
initialPrompt,
|
||||
@@ -253,17 +273,20 @@ function NewSessionForm({
|
||||
],
|
||||
);
|
||||
|
||||
const showSessions = sessionOptions;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<EnvironmentBadges executorLabel={executorLabel} worktreeBranch={worktreeBranch} />
|
||||
{profileOptions.length > 1 && (
|
||||
{!hasProfiles && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
No agent profiles configured. Add one in Settings → Agents first.
|
||||
</p>
|
||||
)}
|
||||
{showAgentSelector && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Agent Profile</label>
|
||||
<AgentSelector
|
||||
options={profileOptions}
|
||||
value={selectedProfileId || defaultProfileId}
|
||||
value={selectedProfileId}
|
||||
onValueChange={setSelectedProfileId}
|
||||
disabled={isCreating}
|
||||
placeholder="Select agent profile"
|
||||
@@ -274,7 +297,7 @@ function NewSessionForm({
|
||||
value={contextValue}
|
||||
onValueChange={handleContextChange}
|
||||
hasInitialPrompt={!!initialPrompt}
|
||||
sessionOptions={showSessions}
|
||||
sessionOptions={sessionOptions}
|
||||
isSummarizing={isSummarizing}
|
||||
/>
|
||||
<div
|
||||
@@ -290,18 +313,24 @@ function NewSessionForm({
|
||||
placeholder="What should the agent work on?"
|
||||
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]"
|
||||
autoFocus
|
||||
disabled={isCreating || isSummarizing}
|
||||
disabled={isBusy}
|
||||
onInput={(e) => setHasPrompt(!!e.currentTarget.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
!isBusy &&
|
||||
hasPrompt &&
|
||||
hasProfiles
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center px-1 pb-1">
|
||||
<AttachButton onClick={handleAttachClick} disabled={isCreating || isSummarizing} />
|
||||
<AttachButton onClick={handleAttachClick} disabled={isBusy} />
|
||||
<EnhancePromptButton
|
||||
onClick={handleEnhancePrompt}
|
||||
isLoading={isEnhancingPrompt}
|
||||
@@ -343,7 +372,7 @@ function NewSessionForm({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreating || isSummarizing || !hasPrompt}
|
||||
disabled={isBusy || !hasPrompt || !hasProfiles}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{isCreating ? "Creating..." : "Start Agent"}
|
||||
@@ -354,8 +383,16 @@ function NewSessionForm({
|
||||
}
|
||||
|
||||
export function NewSessionDialog({ open, onOpenChange, taskId, groupId }: NewSessionDialogProps) {
|
||||
const { taskTitle, agentProfiles, currentSession, worktreeBranch, initialPrompt, executorLabel } =
|
||||
useNewSessionDialogState(taskId);
|
||||
const {
|
||||
taskTitle,
|
||||
agentProfiles,
|
||||
currentSession,
|
||||
worktreeBranch,
|
||||
initialPrompt,
|
||||
executorLabel,
|
||||
sessionProfileId,
|
||||
effectiveDefaultProfileId,
|
||||
} = useNewSessionDialogState(taskId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -368,7 +405,8 @@ export function NewSessionDialog({ open, onOpenChange, taskId, groupId }: NewSes
|
||||
<NewSessionForm
|
||||
key={`${open}`}
|
||||
taskId={taskId}
|
||||
defaultProfileId={currentSession?.agent_profile_id ?? ""}
|
||||
defaultProfileId={sessionProfileId}
|
||||
initialProfileId={effectiveDefaultProfileId}
|
||||
executorId={currentSession?.executor_id ?? ""}
|
||||
executorLabel={executorLabel}
|
||||
worktreeBranch={worktreeBranch}
|
||||
|
||||
@@ -61,6 +61,18 @@ export class SessionPage {
|
||||
todoIndicator() {
|
||||
return this.page.getByTestId("todo-indicator");
|
||||
}
|
||||
/** Resume button shown in the FailedSessionBanner. */
|
||||
failedSessionResumeButton(): Locator {
|
||||
return this.page.getByTestId("failed-session-resume-button");
|
||||
}
|
||||
/** Span wrapper around the resume button — used to trigger tooltip on disabled state. */
|
||||
failedSessionResumeWrapper(): Locator {
|
||||
return this.page.getByTestId("failed-session-resume-wrapper");
|
||||
}
|
||||
/** Cancel button shown in the chat toolbar while an agent turn is running. */
|
||||
cancelAgentButton(): Locator {
|
||||
return this.page.getByTestId("cancel-agent-button");
|
||||
}
|
||||
|
||||
async waitForLoad(timeout = 15_000) {
|
||||
// When multiple session tabs are open, multiple session-chat panels exist in
|
||||
@@ -550,6 +562,12 @@ export class SessionPage {
|
||||
await tab.click({ button: "right" });
|
||||
}
|
||||
|
||||
/** Right-click the first session tab (useful when there is only one session). */
|
||||
async rightClickFirstSessionTab(): Promise<void> {
|
||||
const tab = this.page.locator("[data-testid^='session-tab-']").first();
|
||||
await tab.click({ button: "right" });
|
||||
}
|
||||
|
||||
/** Context menu item by visible label. */
|
||||
contextMenuItem(label: string): Locator {
|
||||
return this.page.getByRole("menuitem", { name: label });
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
/** Navigate to a kanban card by title and open its session page. */
|
||||
async function openTaskSession(page: Parameters<typeof KanbanPage>[0], title: string) {
|
||||
const kanban = new KanbanPage(page);
|
||||
await kanban.goto();
|
||||
|
||||
const card = kanban.taskCardByTitle(title);
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
await expect(page).toHaveURL(/\/t\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(page);
|
||||
await session.waitForLoad();
|
||||
|
||||
// Reload to get fresh SSR state — the session may have started via API before
|
||||
// the WS connection was established, causing the UI to miss "session is RUNNING"
|
||||
// events. A reload guarantees hydrated state from the server.
|
||||
await page.reload();
|
||||
await session.waitForLoad();
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the running session via the tab context menu (session.stop → CANCELLED state),
|
||||
* then wait for the FailedSessionBanner to appear.
|
||||
*
|
||||
* Note: clicking the cancel-agent-button sends `agent.cancel` which only cancels
|
||||
* the current turn and transitions to WAITING_FOR_INPUT (not CANCELLED/FAILED).
|
||||
* To reach CANCELLED we need `session.stop` which is triggered via right-click → Stop.
|
||||
*/
|
||||
async function stopRunningSession(session: SessionPage) {
|
||||
// Wait for the agent to be actively running (cancel button becomes visible)
|
||||
await expect(session.cancelAgentButton()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Right-click the session tab to open context menu, then click "Stop"
|
||||
// This sends session.stop → CANCELLED state
|
||||
await session.rightClickFirstSessionTab();
|
||||
await session.contextMenuItem("Stop").click();
|
||||
|
||||
// Wait for the FailedSessionBanner's resume button to appear (isFailed = FAILED | CANCELLED)
|
||||
await expect(session.failedSessionResumeButton()).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("New session with deleted agent profile", () => {
|
||||
test("resume button is disabled when agent profile was deleted", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
// 1. Get agent info and create a temporary custom profile
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const agent = agents[0];
|
||||
if (!agent) throw new Error("No agents available");
|
||||
const profile = await apiClient.createAgentProfile(agent.id, "Temp Profile for Delete Test", {
|
||||
model: agent.profiles[0]?.model ?? "mock",
|
||||
auto_approve: true,
|
||||
});
|
||||
|
||||
// 2. Create task using the custom profile with a slow response so we can cancel it
|
||||
await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Deleted Profile Resume Test",
|
||||
profile.id,
|
||||
{
|
||||
description: "/slow 60s",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Navigate to the task and stop the agent to get CANCELLED state
|
||||
const session = await openTaskSession(testPage, "Deleted Profile Resume Test");
|
||||
await stopRunningSession(session);
|
||||
|
||||
// 4. While on the page, force-delete the custom agent profile.
|
||||
// The frontend receives an "agent.profile.deleted" WS event and removes
|
||||
// the profile from the store, making profileExists=false reactively.
|
||||
await apiClient.deleteAgentProfile(profile.id, true);
|
||||
|
||||
// 5. Wait for the resume button to become disabled (store has updated)
|
||||
await expect(session.failedSessionResumeButton()).toBeDisabled({ timeout: 10_000 });
|
||||
|
||||
// 6. Hover the span wrapper (tooltip trigger) and verify the tooltip text
|
||||
await testPage.getByTestId("failed-session-resume-wrapper").hover();
|
||||
await expect(testPage.getByRole("tooltip")).toContainText("Agent profile no longer exists", {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("new agent dialog falls back to available profile when original was deleted", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
// 1. Get agent info and create a temporary custom profile
|
||||
const { agents } = await apiClient.listAgents();
|
||||
const agent = agents[0];
|
||||
if (!agent) throw new Error("No agents available");
|
||||
const profile = await apiClient.createAgentProfile(agent.id, "Temp Profile for Dialog Test", {
|
||||
model: agent.profiles[0]?.model ?? "mock",
|
||||
auto_approve: true,
|
||||
});
|
||||
|
||||
// 2. Create task using the custom profile with a slow response so we can cancel it
|
||||
await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Deleted Profile Dialog Test",
|
||||
profile.id,
|
||||
{
|
||||
description: "/slow 60s",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Navigate to the task and stop the agent to get CANCELLED state
|
||||
const session = await openTaskSession(testPage, "Deleted Profile Dialog Test");
|
||||
await stopRunningSession(session);
|
||||
|
||||
// 4. While on the page, force-delete the custom agent profile.
|
||||
// The frontend receives an "agent.profile.deleted" WS event and removes
|
||||
// the profile from the store.
|
||||
await apiClient.deleteAgentProfile(profile.id, true);
|
||||
|
||||
// 5. Wait for the resume button to become disabled (confirms store updated)
|
||||
await expect(session.failedSessionResumeButton()).toBeDisabled({ timeout: 10_000 });
|
||||
|
||||
// 6. Click the "New Agent" button in the FailedSessionBanner to open the dialog
|
||||
await testPage.getByRole("button", { name: "New Agent" }).click();
|
||||
|
||||
// 7. Dialog should be visible
|
||||
await expect(session.newSessionDialog()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// 8. Agent selector should be visible because the original profile was deleted
|
||||
// (isDefaultProfileMissing=true forces the selector to show even with 1 profile)
|
||||
const agentSelector = session.newSessionDialog().getByTestId("agent-profile-selector");
|
||||
await expect(agentSelector).toBeVisible();
|
||||
|
||||
// 9. Fill in a prompt and submit to create a new session with the fallback profile
|
||||
await session.newSessionPromptInput().fill("/e2e:simple-message");
|
||||
await session.newSessionStartButton().click();
|
||||
|
||||
// 10. Dialog should close after submit
|
||||
await expect(session.newSessionDialog()).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 11. Verify the new session tab is visible
|
||||
await expect(session.sessionTabByText("2")).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user