fix(web): hide agent profiles that aren't ready from session handoff (#2874)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { seedDefaultCLIFlags } from "@/lib/cli-flags";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { agentProfileId as toAgentProfileId } from "@/lib/types/ids";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
|
||||
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
|
||||
import { deleteAgentAction } from "@/app/actions/agents";
|
||||
import { SettingsRedirect } from "@/src/settings-route-helpers";
|
||||
@@ -157,13 +158,7 @@ function useAgentStoreSync() {
|
||||
setSettingsAgents(nextAgents);
|
||||
setAgentProfiles(
|
||||
nextAgents.flatMap((agent) =>
|
||||
agent.profiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
label: `${profile.agentDisplayName ?? ""} • ${profile.name}`,
|
||||
agent_id: agent.id,
|
||||
agent_name: agent.name,
|
||||
cli_passthrough: profile.cliPassthrough ?? false,
|
||||
})),
|
||||
agent.profiles.map((profile) => toAgentProfileOption(agent, profile)),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,17 @@ const PROFILE_B: AgentProfileOption = {
|
||||
};
|
||||
|
||||
let mockProfiles: AgentProfileOption[] = [PROFILE_A, PROFILE_B];
|
||||
let mockExecutorProfile: ExecutorProfile | null = null;
|
||||
const LOCAL_EXECUTOR_PROFILE: ExecutorProfile = {
|
||||
id: "exec-profile-1",
|
||||
name: "Default",
|
||||
executor_id: "executor-1",
|
||||
executor_type: "local_pc",
|
||||
prepare_script: "",
|
||||
cleanup_script: "",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
let mockExecutorProfile: ExecutorProfile | null = LOCAL_EXECUTOR_PROFILE;
|
||||
let mockAuthLoaded = true;
|
||||
let mockAuthSpecs: Record<string, unknown> = {};
|
||||
const mockUseTaskExecutorProfile = vi.fn(
|
||||
@@ -44,6 +54,8 @@ vi.mock("@/hooks/domains/settings/use-remote-auth-specs", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/agent-executor-compat", () => ({
|
||||
shouldFilterHandoffByHostHealth: (executor: ExecutorProfile | null) =>
|
||||
Boolean(executor && ["local", "local_pc", "worktree"].includes(executor.executor_type ?? "")),
|
||||
isAgentConfiguredOnExecutor: (
|
||||
profile: AgentProfileOption,
|
||||
_executor: ExecutorProfile,
|
||||
@@ -51,12 +63,12 @@ vi.mock("@/lib/agent-executor-compat", () => ({
|
||||
) => profile.id === "profile-a",
|
||||
}));
|
||||
|
||||
import { useHandoffProfiles } from "./handoff-profile-menu-items";
|
||||
import { useHandoffProfiles, useHasSelectableAgentProfiles } from "./handoff-profile-menu-items";
|
||||
|
||||
describe("useHandoffProfiles", () => {
|
||||
afterEach(() => {
|
||||
mockProfiles = [PROFILE_A, PROFILE_B];
|
||||
mockExecutorProfile = null;
|
||||
mockExecutorProfile = LOCAL_EXECUTOR_PROFILE;
|
||||
mockAuthLoaded = true;
|
||||
mockAuthSpecs = {};
|
||||
mockUseTaskExecutorProfile.mockClear();
|
||||
@@ -77,21 +89,21 @@ describe("useHandoffProfiles", () => {
|
||||
});
|
||||
|
||||
it("marks incompatible profiles disabled when executor profile is known", () => {
|
||||
mockExecutorProfile = {
|
||||
id: "exec-profile-1",
|
||||
name: "Default",
|
||||
executor_id: "executor-1",
|
||||
executor_type: "local_pc",
|
||||
prepare_script: "",
|
||||
cleanup_script: "",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
mockExecutorProfile = LOCAL_EXECUTOR_PROFILE;
|
||||
const { result } = renderHook(() => useHandoffProfiles("task-1"));
|
||||
expect(result.current.find((p) => p.id === "profile-a")?.disabled).toBe(false);
|
||||
expect(result.current.find((p) => p.id === "profile-b")?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps unhealthy profiles visible for an executor that runs agents off-host", () => {
|
||||
mockExecutorProfile = { ...LOCAL_EXECUTOR_PROFILE, executor_type: "local_docker" };
|
||||
mockProfiles = [PROFILE_A, { ...PROFILE_B, capability_status: "not_installed" }];
|
||||
|
||||
const { result } = renderHook(() => useHandoffProfiles("task-1"));
|
||||
|
||||
expect(result.current.map((p) => p.id)).toEqual(["profile-a", "profile-b"]);
|
||||
});
|
||||
|
||||
it("returns empty list when no profiles configured", () => {
|
||||
mockProfiles = [];
|
||||
const { result } = renderHook(() => useHandoffProfiles("task-1"));
|
||||
@@ -108,4 +120,52 @@ describe("useHandoffProfiles", () => {
|
||||
renderHook(() => useHandoffProfiles("task-1", false));
|
||||
expect(mockUseTaskExecutorProfile).toHaveBeenCalledWith("task-1", false);
|
||||
});
|
||||
|
||||
it.each(["not_installed", "auth_required", "failed", "not_configured"] as const)(
|
||||
"excludes profiles whose agent capability_status is %s",
|
||||
(capability_status) => {
|
||||
mockProfiles = [PROFILE_A, { ...PROFILE_B, capability_status }];
|
||||
const { result } = renderHook(() => useHandoffProfiles("task-1"));
|
||||
expect(result.current.map((p) => p.id)).toEqual(["profile-a"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["ok", "probing", undefined] as const)(
|
||||
"keeps profiles whose agent capability_status is %s",
|
||||
(capability_status) => {
|
||||
mockProfiles = [PROFILE_A, { ...PROFILE_B, capability_status }];
|
||||
const { result } = renderHook(() => useHandoffProfiles("task-1"));
|
||||
expect(result.current.map((p) => p.id)).toEqual(["profile-a", "profile-b"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("useHasSelectableAgentProfiles", () => {
|
||||
afterEach(() => {
|
||||
mockProfiles = [PROFILE_A, PROFILE_B];
|
||||
});
|
||||
|
||||
it("is true when a selectable profile exists, even if every agent is unhealthy", () => {
|
||||
mockProfiles = [
|
||||
{ ...PROFILE_A, capability_status: "not_installed" },
|
||||
{ ...PROFILE_B, capability_status: "failed" },
|
||||
];
|
||||
const { result } = renderHook(() => useHasSelectableAgentProfiles());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when there are no profiles at all", () => {
|
||||
mockProfiles = [];
|
||||
const { result } = renderHook(() => useHasSelectableAgentProfiles());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when every profile is disabled (not selectable)", () => {
|
||||
mockProfiles = [
|
||||
{ ...PROFILE_A, enabled: false },
|
||||
{ ...PROFILE_B, enabled: false },
|
||||
];
|
||||
const { result } = renderHook(() => useHasSelectableAgentProfiles());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,13 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { isHealthyAgentProfile } from "@/hooks/domains/settings/use-healthy-agent-profiles";
|
||||
import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs";
|
||||
import { useTaskExecutorProfile } from "@/hooks/domains/session/use-task-executor-profile";
|
||||
import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat";
|
||||
import {
|
||||
isAgentConfiguredOnExecutor,
|
||||
shouldFilterHandoffByHostHealth,
|
||||
} from "@/lib/agent-executor-compat";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -45,31 +49,52 @@ export function useHandoffProfiles(taskId: string, enabled = true): HandoffProfi
|
||||
const { specs: authSpecs, loaded: authLoaded } = useRemoteAuthSpecs();
|
||||
|
||||
return useMemo(() => {
|
||||
return agentProfiles.filter(isSelectableAgentProfile).map((profile) => {
|
||||
const { label, agentName } = profileDisplayLabel(profile);
|
||||
let disabled = false;
|
||||
if (executorProfile && authLoaded) {
|
||||
disabled = !isAgentConfiguredOnExecutor(profile, executorProfile, authSpecs);
|
||||
}
|
||||
return { id: profile.id, label, agentName, disabled };
|
||||
});
|
||||
const filterByHostHealth = shouldFilterHandoffByHostHealth(executorProfile);
|
||||
return agentProfiles
|
||||
.filter(isSelectableAgentProfile)
|
||||
.filter((profile) => !filterByHostHealth || isHealthyAgentProfile(profile))
|
||||
.map((profile) => {
|
||||
const { label, agentName } = profileDisplayLabel(profile);
|
||||
let disabled = false;
|
||||
if (executorProfile && authLoaded) {
|
||||
disabled = !isAgentConfiguredOnExecutor(profile, executorProfile, authSpecs);
|
||||
}
|
||||
return { id: profile.id, label, agentName, disabled };
|
||||
});
|
||||
}, [agentProfiles, executorProfile, authSpecs, authLoaded]);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when at least one selectable (enabled) agent profile is configured,
|
||||
* regardless of its agent's capability health. Distinguishes "no profiles
|
||||
* configured" from "profiles exist but every agent is currently unhealthy" so
|
||||
* the Handoff empty state can point at the actual remedy (agent install/auth)
|
||||
* instead of profile creation.
|
||||
*/
|
||||
export function useHasSelectableAgentProfiles(): boolean {
|
||||
const agentProfiles = useAppStore((s) => s.agentProfiles.items);
|
||||
return useMemo(() => agentProfiles.some(isSelectableAgentProfile), [agentProfiles]);
|
||||
}
|
||||
|
||||
function HandoffProfileList({
|
||||
profiles,
|
||||
hasSelectableProfiles,
|
||||
onSelectProfile,
|
||||
Item,
|
||||
}: {
|
||||
profiles: HandoffProfile[];
|
||||
hasSelectableProfiles: boolean;
|
||||
onSelectProfile: (profileId: string) => void;
|
||||
Item: typeof ContextMenuItem | typeof DropdownMenuItem;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (profiles.length === 0) {
|
||||
const emptyMessageKey = hasSelectableProfiles
|
||||
? "task:noAgentProfilesReadyForHandoff"
|
||||
: "task:noAgentProfilesConfigured";
|
||||
return (
|
||||
<Item disabled className="text-xs text-muted-foreground">
|
||||
{t("task:noAgentProfilesConfigured")}
|
||||
{t(emptyMessageKey)}
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +127,7 @@ export function HandoffContextMenuSub({ taskId, disabled, onSelectProfile }: Han
|
||||
const { t } = useTranslation();
|
||||
const [submenuOpen, setSubmenuOpen] = useState(false);
|
||||
const profiles = useHandoffProfiles(taskId, submenuOpen);
|
||||
const hasSelectableProfiles = useHasSelectableAgentProfiles();
|
||||
const submenuDisabled = disabled || (profiles.length > 0 && profiles.every((p) => p.disabled));
|
||||
|
||||
return (
|
||||
@@ -116,6 +142,7 @@ export function HandoffContextMenuSub({ taskId, disabled, onSelectProfile }: Han
|
||||
<ContextMenuSubContent className="w-48">
|
||||
<HandoffProfileList
|
||||
profiles={profiles}
|
||||
hasSelectableProfiles={hasSelectableProfiles}
|
||||
onSelectProfile={onSelectProfile}
|
||||
Item={ContextMenuItem}
|
||||
/>
|
||||
@@ -128,6 +155,7 @@ export function HandoffDropdownMenuSub({ taskId, disabled, onSelectProfile }: Ha
|
||||
const { t } = useTranslation();
|
||||
const [submenuOpen, setSubmenuOpen] = useState(false);
|
||||
const profiles = useHandoffProfiles(taskId, submenuOpen);
|
||||
const hasSelectableProfiles = useHasSelectableAgentProfiles();
|
||||
const submenuDisabled = disabled || (profiles.length > 0 && profiles.every((p) => p.disabled));
|
||||
|
||||
return (
|
||||
@@ -142,6 +170,7 @@ export function HandoffDropdownMenuSub({ taskId, disabled, onSelectProfile }: Ha
|
||||
<DropdownMenuSubContent className="w-48">
|
||||
<HandoffProfileList
|
||||
profiles={profiles}
|
||||
hasSelectableProfiles={hasSelectableProfiles}
|
||||
onSelectProfile={onSelectProfile}
|
||||
Item={DropdownMenuItem}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
import type { ExecutorProfile } from "@/lib/types/http";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
|
||||
const mockToast = vi.fn();
|
||||
const mockSummarize = vi.fn();
|
||||
@@ -9,9 +11,10 @@ const mockLaunchSession = vi.fn();
|
||||
const mockSetActiveSession = vi.fn();
|
||||
let mockAgentSelectorValue: string | undefined;
|
||||
let mockAgentSelectorOnChange: ((value: string) => void) | undefined;
|
||||
let mockExecutorProfile: ExecutorProfile | null = null;
|
||||
const PLUGIN_COMPOSER_LABEL = "Plugin composer action";
|
||||
|
||||
const BASE_PROFILE = {
|
||||
const BASE_PROFILE: AgentProfileOption = {
|
||||
id: "profile-1",
|
||||
label: "Profile 1",
|
||||
agent_name: "agent-1",
|
||||
@@ -188,7 +191,7 @@ vi.mock("@/hooks/domains/settings/use-remote-auth-specs", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/session/use-task-executor-profile", () => ({
|
||||
useTaskExecutorProfile: () => null,
|
||||
useTaskExecutorProfile: () => mockExecutorProfile,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-is-utility-configured", () => ({
|
||||
@@ -238,6 +241,7 @@ describe("NewSessionDialog", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockState.agentProfiles.items = [BASE_PROFILE];
|
||||
mockExecutorProfile = null;
|
||||
mockAgentSelectorValue = undefined;
|
||||
mockAgentSelectorOnChange = undefined;
|
||||
});
|
||||
@@ -354,4 +358,40 @@ describe("NewSessionDialog", () => {
|
||||
|
||||
await waitFor(() => expect(mockAgentSelectorValue).toBe("profile-2"));
|
||||
});
|
||||
|
||||
it("does not keep an unhealthy profile selected during a local handoff", async () => {
|
||||
mockExecutorProfile = {
|
||||
id: "executor-profile-1",
|
||||
name: "Local",
|
||||
executor_id: "executor-1",
|
||||
executor_type: "local_pc",
|
||||
prepare_script: "",
|
||||
cleanup_script: "",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
mockState.agentProfiles.items = [
|
||||
BASE_PROFILE,
|
||||
{ ...BASE_PROFILE, id: "profile-2", label: "Profile 2", capability_status: "not_installed" },
|
||||
];
|
||||
|
||||
render(
|
||||
<NewSessionDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
taskId="task-1"
|
||||
handoff={{ sourceSessionId: "session-9", targetProfileId: "profile-2" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: PLUGIN_COMPOSER_LABEL }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockBuildStartRequest).toHaveBeenCalledWith(
|
||||
"task-1",
|
||||
"profile-1",
|
||||
expect.objectContaining({ profileExplicit: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,11 @@ import { useSummarizeSession } from "@/hooks/use-summarize-session";
|
||||
import { useTaskSessions } from "@/hooks/use-task-sessions";
|
||||
import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs";
|
||||
import { useTaskExecutorProfile } from "@/hooks/domains/session/use-task-executor-profile";
|
||||
import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat";
|
||||
import {
|
||||
isAgentConfiguredOnExecutor,
|
||||
shouldFilterHandoffByHostHealth,
|
||||
} from "@/lib/agent-executor-compat";
|
||||
import { isHealthyAgentProfile } from "@/hooks/domains/settings/use-healthy-agent-profiles";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types";
|
||||
import type { ExecutorProfile } from "@/lib/types/http";
|
||||
@@ -164,13 +168,19 @@ function isMissingCompatibleProfile(
|
||||
function useCompatibleAgentProfiles(
|
||||
agentProfiles: AgentProfileOption[],
|
||||
executorProfile: ExecutorProfile | null,
|
||||
filterByHostHealth: boolean,
|
||||
): AgentProfileOption[] {
|
||||
const { specs: authSpecs, loaded: authLoaded } = useRemoteAuthSpecs();
|
||||
return useMemo(() => {
|
||||
const selectable = agentProfiles.filter(isSelectableAgentProfile);
|
||||
if (!executorProfile || !authLoaded) return selectable;
|
||||
return selectable.filter((ap) => isAgentConfiguredOnExecutor(ap, executorProfile, authSpecs));
|
||||
}, [agentProfiles, executorProfile, authSpecs, authLoaded]);
|
||||
const healthFiltered = filterByHostHealth
|
||||
? selectable.filter((profile) => isHealthyAgentProfile(profile))
|
||||
: selectable;
|
||||
if (!executorProfile || !authLoaded) return healthFiltered;
|
||||
return healthFiltered.filter((ap) =>
|
||||
isAgentConfiguredOnExecutor(ap, executorProfile, authSpecs),
|
||||
);
|
||||
}, [agentProfiles, executorProfile, authSpecs, authLoaded, filterByHostHealth]);
|
||||
}
|
||||
|
||||
function useHandoffAutoSummarize(
|
||||
@@ -257,14 +267,20 @@ function useSessionProfileSelection({
|
||||
defaultProfileId,
|
||||
selectedProfileId,
|
||||
setSelectedProfileId,
|
||||
handoff,
|
||||
}: {
|
||||
agentProfiles: AgentProfileOption[];
|
||||
executorProfile: ExecutorProfile | null;
|
||||
defaultProfileId: string;
|
||||
selectedProfileId: string;
|
||||
setSelectedProfileId: (value: string) => void;
|
||||
handoff?: HandoffPreset;
|
||||
}) {
|
||||
const compatibleAgentProfiles = useCompatibleAgentProfiles(agentProfiles, executorProfile);
|
||||
const compatibleAgentProfiles = useCompatibleAgentProfiles(
|
||||
agentProfiles,
|
||||
executorProfile,
|
||||
Boolean(handoff && shouldFilterHandoffByHostHealth(executorProfile)),
|
||||
);
|
||||
useEnforceCompatibleProfile(compatibleAgentProfiles, selectedProfileId, setSelectedProfileId);
|
||||
const profileOptions = useAgentProfileOptions(compatibleAgentProfiles);
|
||||
const hasProfiles = profileOptions.length > 0;
|
||||
@@ -391,6 +407,7 @@ function NewSessionForm({
|
||||
defaultProfileId,
|
||||
selectedProfileId,
|
||||
setSelectedProfileId,
|
||||
handoff,
|
||||
});
|
||||
const { handleEnhancePrompt, isEnhancingPrompt, pendingResult, applyPending, copyPending } =
|
||||
useSessionPromptController(promptRef, taskId);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { test, expect, type Page } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
||||
|
||||
type AgentProfileOption = {
|
||||
id: string;
|
||||
capability_status?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type E2EStoreWindow = Window & {
|
||||
__KANDEV_E2E_STORE__?: {
|
||||
getState: () => { agentProfiles: { items: AgentProfileOption[] } };
|
||||
setState: (
|
||||
updater: (state: { agentProfiles: { items: AgentProfileOption[] } }) => void,
|
||||
) => void;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks an existing agent profile option as capability-not-ready by mutating
|
||||
* the client store directly, mirroring the effect of a real
|
||||
* `agent.available.updated` broadcast reporting the agent's CLI missing (that
|
||||
* event refreshes `capability_status` on both `agentProfiles.items` and
|
||||
* `settingsAgents.items` — see agents.ts `refreshProfileCapabilities` /
|
||||
* `refreshSettingsAgentsCapabilities`). The e2e mock agent
|
||||
* (KANDEV_MOCK_AGENT=only) never enters the host-utility cache, so
|
||||
* `capability_status` is always undefined for it in this suite — this is the
|
||||
* only way to exercise the "not ready" branch end to end.
|
||||
*/
|
||||
async function markProfileCapabilityNotInstalled(testPage: Page, profileId: string) {
|
||||
await testPage.evaluate((id) => {
|
||||
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
|
||||
if (!store) {
|
||||
throw new Error("E2E store bridge missing — is __KANDEV_E2E_EXPOSE_STORE__ set?");
|
||||
}
|
||||
store.setState((state) => {
|
||||
const index = state.agentProfiles.items.findIndex((p) => p.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error(`agent profile ${id} not found in store`);
|
||||
}
|
||||
state.agentProfiles.items[index] = {
|
||||
...state.agentProfiles.items[index],
|
||||
capability_status: "not_installed",
|
||||
};
|
||||
});
|
||||
const updated = store.getState().agentProfiles.items.find((p) => p.id === id);
|
||||
if (updated?.capability_status !== "not_installed") {
|
||||
throw new Error("Failed to set capability_status on agent profile in store");
|
||||
}
|
||||
}, profileId);
|
||||
}
|
||||
|
||||
async function createProfiles(
|
||||
apiClient: InstanceType<typeof import("../../helpers/api-client").ApiClient>,
|
||||
) {
|
||||
const { agents } = await apiClient.listAgents();
|
||||
if (agents.length === 0) throw new Error("no agents available in test fixtures");
|
||||
const agentId = agents[0].id;
|
||||
const profileA = await apiClient.createAgentProfile(agentId, "Handoff Filter Profile A", {
|
||||
model: "mock-fast",
|
||||
});
|
||||
const profileBAgentId = agents[1]?.id ?? agentId;
|
||||
const profileB = await apiClient.createAgentProfile(profileBAgentId, "Handoff Filter Profile B", {
|
||||
model: "mock-slow",
|
||||
});
|
||||
return { profileA, profileB, profilesShareAgent: profileBAgentId === agentId };
|
||||
}
|
||||
|
||||
test.describe("Session handoff filters unhealthy profiles", () => {
|
||||
test("hides a profile once its agent capability is not ready, keeps healthy profiles visible", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const { profileA, profileB, profilesShareAgent } = await createProfiles(apiClient);
|
||||
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Handoff Filter Task",
|
||||
profileA.id,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
executor_profile_id: seedData.worktreeExecutorProfileId,
|
||||
},
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return DONE_STATES.includes(sessions[0]?.state ?? "");
|
||||
},
|
||||
{ timeout: 30_000, message: "Waiting for first session to finish" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
const session1Id = sessions[0].id;
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.taskCardByTitle("Handoff Filter Task").click();
|
||||
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
await session.sessionTabBySessionId(session1Id).click({ button: "right" });
|
||||
await session.handoffSubmenu().hover();
|
||||
await expect(session.handoffProfileItem(profileB.id)).toBeVisible({ timeout: 5_000 });
|
||||
await testPage.keyboard.press("Escape");
|
||||
|
||||
// A real agent.available.updated event changes every profile for the
|
||||
// agent. Use separate agents for the mixed-health assertion when the
|
||||
// fixture provides them. The single-agent mock must mark both profiles
|
||||
// unhealthy instead of creating an impossible mixed state. Include the
|
||||
// fixture's seeded profile because it belongs to the same agent and would
|
||||
// otherwise keep one healthy handoff option visible.
|
||||
if (profilesShareAgent) {
|
||||
await markProfileCapabilityNotInstalled(testPage, profileA.id);
|
||||
await markProfileCapabilityNotInstalled(testPage, profileB.id);
|
||||
await markProfileCapabilityNotInstalled(testPage, seedData.agentProfileId);
|
||||
} else {
|
||||
await markProfileCapabilityNotInstalled(testPage, profileB.id);
|
||||
}
|
||||
|
||||
await session.sessionTabBySessionId(session1Id).click({ button: "right" });
|
||||
await session.handoffSubmenu().hover();
|
||||
if (profilesShareAgent) {
|
||||
await expect(session.handoffProfileItem(profileA.id)).not.toBeVisible();
|
||||
await expect(
|
||||
testPage.getByText(
|
||||
"No agent profiles are ready. Install or reconnect an agent CLI in Settings → Agents.",
|
||||
),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(session.handoffProfileItem(profileA.id)).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
await expect(session.handoffProfileItem(profileB.id)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,22 @@
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
|
||||
/**
|
||||
* True when a profile has no capability issues: an unset status (no host-utility
|
||||
* probe recorded yet, e.g. the e2e mock agent), "ok", or the in-flight "probing"
|
||||
* state. If `selectedId` is provided and the profile is the selected one, it is
|
||||
* still considered healthy so the user can see what's currently set instead of
|
||||
* seeing a blank select.
|
||||
*/
|
||||
export function isHealthyAgentProfile(profile: AgentProfileOption, selectedId?: string): boolean {
|
||||
return (
|
||||
!profile.capability_status ||
|
||||
profile.capability_status === "ok" ||
|
||||
profile.capability_status === "probing" ||
|
||||
profile.id === selectedId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns agent profiles that are healthy (no capability issues). If `selectedId`
|
||||
* is provided and the selected profile is unhealthy, it is still included so the
|
||||
@@ -10,11 +26,5 @@ import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
*/
|
||||
export function useHealthyAgentProfiles(selectedId?: string): AgentProfileOption[] {
|
||||
const agentProfiles = useAppStore((s) => s.agentProfiles.items);
|
||||
return agentProfiles.filter(
|
||||
(p) =>
|
||||
!p.capability_status ||
|
||||
p.capability_status === "ok" ||
|
||||
p.capability_status === "probing" ||
|
||||
p.id === selectedId,
|
||||
);
|
||||
return agentProfiles.filter((p) => isHealthyAgentProfile(p, selectedId));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { create, type StoreApi } from "zustand";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import type { Agent, AgentProfile } from "@/lib/types/http";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
|
||||
import { createSettingsSlice } from "@/lib/state/slices/settings/settings-slice";
|
||||
import type { SettingsSlice } from "@/lib/state/slices/settings/types";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import { registerAgentsHandlers } from "@/lib/ws/handlers/agents";
|
||||
import { applyProfileDuplicated } from "./use-profile-duplicate";
|
||||
|
||||
vi.mock("@/app/actions/agents", () => ({ duplicateAgentProfileAction: vi.fn() }));
|
||||
@@ -172,6 +178,26 @@ describe("applyProfileDuplicated", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the owning agent's capability_status onto the copy when the agent is absent from the store", () => {
|
||||
// Mirrors the mid-request scenario above, but the duplicated profile's
|
||||
// agent is unhealthy: the copy must not silently read as healthy just
|
||||
// because it took the stub-agent branch (toAgentProfileOption dropped
|
||||
// capability_status when only { id, name } was passed).
|
||||
const copy = profile("p2", "a1", COPY_NAME);
|
||||
const unhealthyAgent = {
|
||||
...agent("a1"),
|
||||
capability_status: "not_installed",
|
||||
capability_error: "agent CLI not found",
|
||||
} as Agent;
|
||||
const initial = stateWith([], []);
|
||||
|
||||
const next = applyProfileDuplicated(initial, unhealthyAgent, copy);
|
||||
|
||||
const created = next.agentProfiles.items.find((o) => o.id === "p2");
|
||||
expect(created?.capability_status).toBe("not_installed");
|
||||
expect(created?.capability_error).toBe("agent CLI not found");
|
||||
});
|
||||
|
||||
it("preserves WS-delivered orphan options for agents absent from the store", () => {
|
||||
// p-orphan was delivered by WS for an agent not present in settingsAgents;
|
||||
// duplicating a profile of a DIFFERENT agent must not wipe it.
|
||||
@@ -187,3 +213,89 @@ describe("applyProfileDuplicated", () => {
|
||||
expect(ids.filter((id) => id === "p2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProfileDuplicated capability_status sync", () => {
|
||||
it("picks up a fresh agent.available.updated capability instead of reverting to the stale settingsAgents value", () => {
|
||||
// Regression for the desync where agent.available.updated only refreshed
|
||||
// agentProfiles options: settingsAgents stayed on its stale boot-time
|
||||
// capability_status, so duplicating any profile rebuilt every option from
|
||||
// that stale value and clobbered the refresh (both directions).
|
||||
const store = create<SettingsSlice>()(
|
||||
immer((set, get, api) => createSettingsSlice(set, get, api)),
|
||||
) as unknown as StoreApi<AppState>;
|
||||
const handlers = registerAgentsHandlers(store) as unknown as Record<
|
||||
string,
|
||||
(event: {
|
||||
id: string;
|
||||
type: string;
|
||||
action: string;
|
||||
timestamp: string;
|
||||
payload: unknown;
|
||||
}) => void
|
||||
>;
|
||||
const source = profile("p1", "a1", "Default");
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
settingsAgents: {
|
||||
items: [
|
||||
{
|
||||
id: "a1",
|
||||
name: "a1",
|
||||
supports_mcp: false,
|
||||
profiles: [source],
|
||||
capability_status: "not_installed",
|
||||
capability_error: "agent not installed",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
} as unknown as Agent,
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
// The agent gets installed: available.updated reports it healthy.
|
||||
handlers["agent.available.updated"]({
|
||||
id: "m1",
|
||||
type: "notification",
|
||||
action: "agent.available.updated",
|
||||
timestamp: "2026-08-11T21:00:00Z",
|
||||
payload: {
|
||||
agents: [
|
||||
{
|
||||
name: "a1",
|
||||
display_name: "a1",
|
||||
supports_mcp: false,
|
||||
installation_paths: [],
|
||||
available: true,
|
||||
capabilities: {
|
||||
supports_session_resume: false,
|
||||
supports_shell: false,
|
||||
supports_workspace_only: false,
|
||||
},
|
||||
model_config: {
|
||||
default_model: "default",
|
||||
available_models: [],
|
||||
supports_dynamic_models: false,
|
||||
status: "ok",
|
||||
},
|
||||
updated_at: "2026-08-11T21:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// `agent("a1")` mirrors the click-time Agent captured by the caller
|
||||
// before the WS refresh above landed in the store — it carries no
|
||||
// capability_status, so it must lose to the rebuilt value for both the
|
||||
// pre-existing profile and the new copy.
|
||||
const copy = profile("p2", "a1", COPY_NAME);
|
||||
const next = applyProfileDuplicated(store.getState(), agent("a1"), copy);
|
||||
|
||||
const rebuilt = next.agentProfiles.items.find((o) => o.id === "p1");
|
||||
expect(rebuilt?.capability_status).toBe("ok");
|
||||
expect(rebuilt?.capability_error).toBeUndefined();
|
||||
|
||||
const rebuiltCopy = next.agentProfiles.items.find((o) => o.id === "p2");
|
||||
expect(rebuiltCopy?.capability_status).toBe("ok");
|
||||
expect(rebuiltCopy?.capability_error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,8 +59,16 @@ export function applyProfileDuplicated(
|
||||
const merged = mergeOptionsByNewest(state.agentProfiles.items, rebuiltOptions);
|
||||
// The copy must appear exactly once with the known agent metadata: append
|
||||
// it when absent, replace an OLDER existing option (e.g. a WS stub) with
|
||||
// it, and keep a NEWER WS-delivered option untouched.
|
||||
const copyOption = toAgentProfileOption({ id: agent.id, name: agent.name }, created);
|
||||
// it, and keep a NEWER WS-delivered option untouched. When the owning
|
||||
// agent is present in the store, prefer the rebuilt option (sourced from
|
||||
// the live `item`) over a fresh copyOption built from the `agent`
|
||||
// parameter: `agent` is a click-time snapshot that can be stale relative
|
||||
// to a capability refresh delivered after the row rendered but before this
|
||||
// duplicate request resolved, so falling back to it here would silently
|
||||
// regress the copy's capability_status even though the correct value was
|
||||
// already rebuilt.
|
||||
const rebuiltCopy = rebuiltOptions.find((option) => option.id === created.id);
|
||||
const copyOption = rebuiltCopy ?? toAgentProfileOption(agent, created);
|
||||
const existingCopy = merged.find((option) => option.id === created.id);
|
||||
let agentProfilesItems: AgentProfileOption[];
|
||||
if (!existingCopy) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldFilterHandoffByHostHealth } from "./agent-executor-compat";
|
||||
import type { ExecutorType } from "@/lib/types/http";
|
||||
|
||||
describe("shouldFilterHandoffByHostHealth", () => {
|
||||
it.each(["local", "local_pc", "worktree"])("returns true for %s", (executor_type) => {
|
||||
expect(shouldFilterHandoffByHostHealth({ executor_type: executor_type as ExecutorType })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["local_docker", "remote_docker", "ssh", "remote_vps", "k8s"])(
|
||||
"returns false for %s",
|
||||
(executor_type) => {
|
||||
expect(
|
||||
shouldFilterHandoffByHostHealth({ executor_type: executor_type as ExecutorType }),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not apply a host-health filter while the executor is unknown", () => {
|
||||
expect(shouldFilterHandoffByHostHealth(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,21 @@ import { createDebugLogger } from "@/lib/debug/log";
|
||||
const debug = createDebugLogger("executor-compat");
|
||||
|
||||
const REMOTE_EXECUTOR_TYPES = new Set(["local_docker", "remote_docker", "sprites"]);
|
||||
const HOST_AGENT_EXECUTOR_TYPES = new Set(["local", "local_pc", "worktree"]);
|
||||
|
||||
/**
|
||||
* Host capability probes are authoritative only for executors that launch the
|
||||
* agent on this Kandev host. Remote and container executors resolve the agent
|
||||
* runtime in their own environment, so a host probe must not hide those
|
||||
* profiles from handoff.
|
||||
*/
|
||||
export function shouldFilterHandoffByHostHealth(
|
||||
executorProfile: Pick<ExecutorProfile, "executor_type"> | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
executorProfile && HOST_AGENT_EXECUTOR_TYPES.has(executorProfile.executor_type ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
export function executorRequiresAgentCredentials(executorType?: string | null): boolean {
|
||||
if (!executorType) return false;
|
||||
|
||||
@@ -4,6 +4,11 @@ import { immer } from "zustand/middleware/immer";
|
||||
|
||||
import { createSettingsSlice } from "./settings-slice";
|
||||
import type { SettingsSlice } from "./types";
|
||||
import type { AvailableAgent, CapabilityStatus } from "@/lib/types/http-agents";
|
||||
|
||||
const AGENT_NAME = "claude-acp";
|
||||
const TIMESTAMP = "2026-07-26T10:00:00Z";
|
||||
const NEWER_TIMESTAMP = "2026-07-26T11:00:00Z";
|
||||
|
||||
function makeStore() {
|
||||
return create<SettingsSlice>()(immer((set, get, store) => createSettingsSlice(set, get, store)));
|
||||
@@ -12,12 +17,12 @@ function makeStore() {
|
||||
function updateJob(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
job_id: "update-1",
|
||||
agent_name: "claude-acp",
|
||||
agent_name: AGENT_NAME,
|
||||
status: "updating",
|
||||
current_version: "1.0.0",
|
||||
target_version: "1.1.0",
|
||||
output: "",
|
||||
started_at: "2026-07-26T10:00:00Z",
|
||||
started_at: TIMESTAMP,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -55,7 +60,7 @@ describe("settings update jobs", () => {
|
||||
};
|
||||
|
||||
actions.setAgentUpdateJobs([
|
||||
updateJob({ job_id: "older", started_at: "2026-07-26T10:00:00Z" }),
|
||||
updateJob({ job_id: "older", started_at: TIMESTAMP }),
|
||||
updateJob({ job_id: "newer", started_at: "2026-07-26T10:01:00Z" }),
|
||||
]);
|
||||
|
||||
@@ -101,7 +106,7 @@ describe("settings update jobs", () => {
|
||||
updateJob({
|
||||
job_id: "original",
|
||||
status: "failed",
|
||||
started_at: "2026-07-26T10:00:00Z",
|
||||
started_at: TIMESTAMP,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -134,3 +139,197 @@ describe("settings update jobs", () => {
|
||||
expect(output.startsWith("old")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** Builds an AvailableAgent fixture reporting the agent as settled-but-uninstalled. */
|
||||
function notInstalledAvailableAgent(overrides: Partial<AvailableAgent> = {}): AvailableAgent {
|
||||
return {
|
||||
name: AGENT_NAME,
|
||||
display_name: "Claude",
|
||||
supports_mcp: false,
|
||||
installation_paths: [],
|
||||
available: false,
|
||||
capabilities: {
|
||||
supports_session_resume: false,
|
||||
supports_shell: false,
|
||||
supports_workspace_only: false,
|
||||
},
|
||||
model_config: {
|
||||
default_model: "default",
|
||||
available_models: [],
|
||||
supports_dynamic_models: false,
|
||||
status: "not_installed",
|
||||
error: "agent not installed",
|
||||
},
|
||||
updated_at: TIMESTAMP,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an AvailableAgent fixture for a non-inference agent (e.g. a
|
||||
* TUI/passthrough agent) that never enters the host-utility probe cache, so
|
||||
* `model_config.status` stays the cache-miss "not_configured" regardless of
|
||||
* install state. `available` reflects the agent's own detection separately.
|
||||
*/
|
||||
function nonInferenceAvailableAgent(overrides: Partial<AvailableAgent> = {}): AvailableAgent {
|
||||
return {
|
||||
name: AGENT_NAME,
|
||||
display_name: "Claude",
|
||||
supports_mcp: false,
|
||||
installation_paths: [],
|
||||
available: true,
|
||||
capabilities: {
|
||||
supports_session_resume: false,
|
||||
supports_shell: false,
|
||||
supports_workspace_only: false,
|
||||
},
|
||||
model_config: {
|
||||
default_model: "default",
|
||||
available_models: [],
|
||||
supports_dynamic_models: false,
|
||||
status: "not_configured",
|
||||
},
|
||||
updated_at: TIMESTAMP,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Seeds one profile + its owning settingsAgents entry, both under AGENT_NAME/"agent-1". */
|
||||
function seedSingleAgentProfile(
|
||||
store: ReturnType<typeof makeStore>,
|
||||
capabilityStatus?: CapabilityStatus,
|
||||
) {
|
||||
const capability = capabilityStatus ? { capability_status: capabilityStatus } : {};
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
agentProfiles: {
|
||||
...state.agentProfiles,
|
||||
items: [
|
||||
{
|
||||
id: "profile-1",
|
||||
label: "Claude • Default",
|
||||
agent_id: "agent-1",
|
||||
agent_name: AGENT_NAME,
|
||||
cli_passthrough: false,
|
||||
...capability,
|
||||
},
|
||||
],
|
||||
},
|
||||
settingsAgents: {
|
||||
items: [
|
||||
{
|
||||
id: "agent-1",
|
||||
name: AGENT_NAME,
|
||||
supports_mcp: false,
|
||||
profiles: [],
|
||||
created_at: TIMESTAMP,
|
||||
updated_at: TIMESTAMP,
|
||||
...capability,
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
describe("setAvailableAgents capability propagation", () => {
|
||||
it("does not let an older HTTP snapshot clobber a newer capability snapshot", () => {
|
||||
const store = makeStore();
|
||||
const actions = store.getState();
|
||||
seedSingleAgentProfile(store, "not_installed");
|
||||
|
||||
actions.setAvailableAgents([
|
||||
notInstalledAvailableAgent({
|
||||
available: true,
|
||||
model_config: {
|
||||
default_model: "default",
|
||||
available_models: [],
|
||||
supports_dynamic_models: false,
|
||||
status: "ok",
|
||||
},
|
||||
updated_at: NEWER_TIMESTAMP,
|
||||
}),
|
||||
]);
|
||||
actions.setAvailableAgentsLoading(true);
|
||||
actions.setAvailableAgents([notInstalledAvailableAgent({ updated_at: TIMESTAMP })]);
|
||||
|
||||
expect(store.getState().availableAgents.items[0]?.model_config.status).toBe("ok");
|
||||
expect(store.getState().agentProfiles.items[0]?.capability_status).toBe("ok");
|
||||
expect(store.getState().availableAgents.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("flips a profile and its settingsAgents entry from probing to the settled status a poll snapshot reports", () => {
|
||||
const store = makeStore();
|
||||
const actions = store.getState();
|
||||
seedSingleAgentProfile(store, "probing");
|
||||
|
||||
actions.setAvailableAgents([notInstalledAvailableAgent()]);
|
||||
|
||||
const profile = store.getState().agentProfiles.items[0];
|
||||
const settingsAgent = store.getState().settingsAgents.items[0];
|
||||
expect(profile?.capability_status).toBe("not_installed");
|
||||
expect(profile?.capability_error).toBe("agent not installed");
|
||||
expect(settingsAgent?.capability_status).toBe("not_installed");
|
||||
expect(settingsAgent?.capability_error).toBe("agent not installed");
|
||||
});
|
||||
|
||||
it("treats a non-inference agent's own failed detection as not_installed, not healthy", () => {
|
||||
// TUI/passthrough agents never enter the host-utility probe cache (they
|
||||
// are not InferenceAgents), so model_config.status is permanently
|
||||
// "not_configured" regardless of whether the agent is actually
|
||||
// installed. `available: false` is the only signal that its own
|
||||
// detection failed; collapsing "not_configured" to undefined here would
|
||||
// silently show it as healthy in Handoff.
|
||||
const store = makeStore();
|
||||
const actions = store.getState();
|
||||
seedSingleAgentProfile(store);
|
||||
|
||||
actions.setAvailableAgents([nonInferenceAvailableAgent({ available: false })]);
|
||||
|
||||
const profile = store.getState().agentProfiles.items[0];
|
||||
const settingsAgent = store.getState().settingsAgents.items[0];
|
||||
expect(profile?.capability_status).toBe("not_installed");
|
||||
expect(settingsAgent?.capability_status).toBe("not_installed");
|
||||
});
|
||||
|
||||
it("keeps a not-yet-probed non-inference agent healthy when its own detection succeeded", () => {
|
||||
// The pre-existing cache-miss case: available (installed), just not yet
|
||||
// (or never) probed by the host-utility cache. Must stay undefined so
|
||||
// it does not vanish from Handoff.
|
||||
const store = makeStore();
|
||||
const actions = store.getState();
|
||||
seedSingleAgentProfile(store);
|
||||
|
||||
actions.setAvailableAgents([nonInferenceAvailableAgent({ available: true })]);
|
||||
|
||||
const profile = store.getState().agentProfiles.items[0];
|
||||
const settingsAgent = store.getState().settingsAgents.items[0];
|
||||
expect(profile?.capability_status).toBeUndefined();
|
||||
expect(settingsAgent?.capability_status).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves capability_status untouched when the poll snapshot has no matching agent name", () => {
|
||||
const store = makeStore();
|
||||
const actions = store.getState();
|
||||
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
agentProfiles: {
|
||||
...state.agentProfiles,
|
||||
items: [
|
||||
{
|
||||
id: "profile-1",
|
||||
label: "Claude • Default",
|
||||
agent_id: "agent-1",
|
||||
agent_name: AGENT_NAME,
|
||||
cli_passthrough: false,
|
||||
capability_status: "probing",
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
actions.setAvailableAgents([notInstalledAvailableAgent({ name: "other-agent" })]);
|
||||
|
||||
expect(store.getState().agentProfiles.items[0]?.capability_status).toBe("probing");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import { createDefaultUserSettings } from "@/lib/ssr/user-settings";
|
||||
import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision";
|
||||
import type { SettingsSlice, SettingsSliceState } from "./types";
|
||||
import {
|
||||
refreshProfileCapabilities,
|
||||
refreshSettingsAgentsCapabilities,
|
||||
isStaleAvailableAgentsSnapshot,
|
||||
type SettingsSlice,
|
||||
type SettingsSliceState,
|
||||
} from "./types";
|
||||
|
||||
export const defaultSettingsState: SettingsSliceState = {
|
||||
executors: { items: [] },
|
||||
@@ -207,10 +213,27 @@ function createCoreActions(
|
||||
}),
|
||||
setAvailableAgents: (agents, tools) =>
|
||||
set((draft) => {
|
||||
if (isStaleAvailableAgentsSnapshot(draft.availableAgents.items, agents)) {
|
||||
// The request completed, even though its data is stale. Keep the
|
||||
// newer snapshot but do not leave the polling indicator stuck.
|
||||
draft.availableAgents.loading = false;
|
||||
draft.availableAgents.loaded = true;
|
||||
return;
|
||||
}
|
||||
draft.availableAgents.items = agents;
|
||||
if (tools) draft.availableAgents.tools = tools;
|
||||
draft.availableAgents.loading = false;
|
||||
draft.availableAgents.loaded = true;
|
||||
// The revalidation poll in use-available-agents.ts is the only place
|
||||
// a probing/not_configured status ever settles outside a WS push
|
||||
// (agents.ts's "agent.available.updated" handler covers that path);
|
||||
// without applying the same refresh here, a settled status never
|
||||
// reaches agentProfiles/settingsAgents on a cold launch.
|
||||
draft.agentProfiles.items = refreshProfileCapabilities(draft.agentProfiles.items, agents);
|
||||
draft.settingsAgents.items = refreshSettingsAgentsCapabilities(
|
||||
draft.settingsAgents.items,
|
||||
agents,
|
||||
);
|
||||
}),
|
||||
setAvailableAgentsLoading: (loading) =>
|
||||
set((draft) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agentProfileId } from "@/lib/types/ids";
|
||||
import { isSelectableAgentProfile, toAgentProfileOption } from "./types";
|
||||
import { isSelectableAgentProfile, toAgentProfileOption, toProfileCapability } from "./types";
|
||||
|
||||
describe("isSelectableAgentProfile", () => {
|
||||
it.each([
|
||||
@@ -36,4 +36,50 @@ describe("toAgentProfileOption", () => {
|
||||
});
|
||||
expect(legacy.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the owning agent capability fields", () => {
|
||||
const option = toAgentProfileOption(
|
||||
{
|
||||
...agent,
|
||||
capability_status: "not_installed",
|
||||
capability_error: "CLI not found",
|
||||
},
|
||||
{
|
||||
id: agentProfileId("p3"),
|
||||
agentDisplayName: "Claude Code",
|
||||
name: "default",
|
||||
},
|
||||
);
|
||||
|
||||
expect(option).toMatchObject({
|
||||
capability_status: "not_installed",
|
||||
capability_error: "CLI not found",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toProfileCapability", () => {
|
||||
it("maps an unavailable agent to not_installed even when its cached status is ok", () => {
|
||||
expect(
|
||||
toProfileCapability({
|
||||
name: "claude-acp",
|
||||
display_name: "Claude",
|
||||
supports_mcp: false,
|
||||
installation_paths: [],
|
||||
available: false,
|
||||
capabilities: {
|
||||
supports_session_resume: false,
|
||||
supports_shell: false,
|
||||
supports_workspace_only: false,
|
||||
},
|
||||
model_config: {
|
||||
default_model: "default",
|
||||
available_models: [],
|
||||
supports_dynamic_models: false,
|
||||
status: "ok",
|
||||
},
|
||||
updated_at: "2026-07-26T10:00:00Z",
|
||||
}),
|
||||
).toMatchObject({ capability_status: "not_installed" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,33 @@ export function compareTimestamps(a: string | undefined, b: string | undefined):
|
||||
return aMs - bMs;
|
||||
}
|
||||
|
||||
function latestAvailableAgentRevision(agents: AvailableAgent[]): string | undefined {
|
||||
return agents.reduce<string | undefined>(
|
||||
(latest, agent) =>
|
||||
compareTimestamps(agent.updated_at, latest) > 0 ? agent.updated_at : latest,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when an available-agent snapshot is older than the one already
|
||||
* in the store. HTTP polling and WebSocket broadcasts can complete in either
|
||||
* order, so an older response must not replace a newer capability state.
|
||||
* Empty snapshots are treated as stale when a timestamped snapshot is already
|
||||
* present. This protects the store from an error fallback clearing good data.
|
||||
*/
|
||||
export function isStaleAvailableAgentsSnapshot(
|
||||
current: AvailableAgent[],
|
||||
incoming: AvailableAgent[],
|
||||
): boolean {
|
||||
if (current.length === 0) return false;
|
||||
const currentRevision = latestAvailableAgentRevision(current);
|
||||
const incomingRevision = latestAvailableAgentRevision(incoming);
|
||||
if (currentRevision && !incomingRevision) return true;
|
||||
if (!currentRevision || !incomingRevision) return false;
|
||||
return compareTimestamps(incomingRevision, currentRevision) < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two option lists by ID, keeping the NEWER revision per id (missing or
|
||||
* equal updatedAt defers to the rebuilt option). A newer WebSocket-delivered
|
||||
@@ -120,6 +147,107 @@ export function mergeOptionsByNewest(
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an AvailableAgent's host-utility probe result to AgentProfileOption's
|
||||
* capability fields. `buildModelConfigFromHostUtility` (backend) emits the
|
||||
* literal "not_configured" both on a genuine cache miss (agent not probed
|
||||
* yet, e.g. a non-ACP agent that is actually installed) and, permanently,
|
||||
* for agents the host-utility probe never covers at all (TUI/passthrough
|
||||
* agents are not InferenceAgents, so they never enter the probe cache
|
||||
* regardless of install state). `agent.available` disambiguates the two:
|
||||
* when it is explicitly false the agent's own detection already ran and
|
||||
* failed, so map to "not_installed" instead of collapsing to undefined.
|
||||
* The AgentDTO path used by agent.profile.created/updated leaves the field
|
||||
* empty for the true cache-miss case — map that back to undefined so both
|
||||
* paths agree with isHealthyAgentProfile, which treats undefined as healthy
|
||||
* and "not_configured"/"not_installed" as unhealthy.
|
||||
*/
|
||||
export function toProfileCapability(agent: AvailableAgent): {
|
||||
capability_status?: CapabilityStatus;
|
||||
capability_error?: string;
|
||||
} {
|
||||
if (agent.available === false) {
|
||||
return {
|
||||
capability_status: "not_installed",
|
||||
capability_error: agent.model_config.error,
|
||||
};
|
||||
}
|
||||
if (agent.model_config.status === "not_configured") {
|
||||
return { capability_status: undefined, capability_error: undefined };
|
||||
}
|
||||
return {
|
||||
capability_status: agent.model_config.status,
|
||||
capability_error: agent.model_config.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes capability_status/capability_error on agent profile options from
|
||||
* a fresh AvailableAgent snapshot (delivered either via the
|
||||
* agent.available.updated WebSocket broadcast or the /agents HTTP poll),
|
||||
* matched by agent name. Without this, a profile hidden from Handoff at boot
|
||||
* stays hidden even after its agent is installed or reconnected, until a full
|
||||
* page reload. Returns the original array/entries when nothing actually
|
||||
* changed so an identical snapshot does not invalidate every
|
||||
* `agentProfiles.items` subscriber.
|
||||
*/
|
||||
export function refreshProfileCapabilities(
|
||||
profiles: AgentProfileOption[],
|
||||
agents: AvailableAgent[],
|
||||
): AgentProfileOption[] {
|
||||
if (agents.length === 0) return profiles;
|
||||
const byName = new Map(agents.map((agent) => [agent.name, agent]));
|
||||
let changed = false;
|
||||
const next = profiles.map((profile) => {
|
||||
const match = byName.get(profile.agent_name);
|
||||
if (!match) return profile;
|
||||
const capability = toProfileCapability(match);
|
||||
if (
|
||||
profile.capability_status === capability.capability_status &&
|
||||
profile.capability_error === capability.capability_error
|
||||
) {
|
||||
return profile;
|
||||
}
|
||||
changed = true;
|
||||
return { ...profile, ...capability };
|
||||
});
|
||||
return changed ? next : profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes capability_status/capability_error on `settingsAgents.items` from
|
||||
* a fresh AvailableAgent snapshot (WS or HTTP poll), matched by agent name.
|
||||
* This is the single source `agent.profile.created`/`updated` and
|
||||
* `applyProfileDuplicated` rebuild profile options FROM — without refreshing
|
||||
* it here too, any later profile create/update/duplicate silently reverts a
|
||||
* profile's capability back to its stale boot-time value, in both
|
||||
* directions: an install can make an installed-but-just-edited profile
|
||||
* vanish from Handoff, or a break can bring an uninstalled provider back
|
||||
* into it.
|
||||
*/
|
||||
export function refreshSettingsAgentsCapabilities(
|
||||
settingsAgents: Agent[],
|
||||
agents: AvailableAgent[],
|
||||
): Agent[] {
|
||||
if (agents.length === 0) return settingsAgents;
|
||||
const byName = new Map(agents.map((agent) => [agent.name, agent]));
|
||||
let changed = false;
|
||||
const next = settingsAgents.map((item) => {
|
||||
const match = byName.get(item.name);
|
||||
if (!match) return item;
|
||||
const capability = toProfileCapability(match);
|
||||
if (
|
||||
item.capability_status === capability.capability_status &&
|
||||
item.capability_error === capability.capability_error
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
changed = true;
|
||||
return { ...item, ...capability };
|
||||
});
|
||||
return changed ? next : settingsAgents;
|
||||
}
|
||||
|
||||
/** Single source of truth for mapping an API Agent+Profile to a store AgentProfileOption. */
|
||||
export function toAgentProfileOption(
|
||||
agent: Pick<Agent, "id" | "name" | "capability_status" | "capability_error">,
|
||||
|
||||
@@ -14,10 +14,15 @@ function makeStore() {
|
||||
}
|
||||
|
||||
const TIMESTAMP = "2026-07-26T10:00:00Z";
|
||||
const NEWER_TIMESTAMP = "2026-07-26T11:00:00Z";
|
||||
const NOTIFICATION_TYPE = "notification";
|
||||
const PROFILE_CREATED = "agent.profile.created";
|
||||
const PROFILE_UPDATED = "agent.profile.updated";
|
||||
const PROFILE_DELETED = "agent.profile.deleted";
|
||||
const AVAILABLE_UPDATED = "agent.available.updated";
|
||||
const AGENT_NAME = "claude-acp";
|
||||
const NOT_INSTALLED = "not_installed";
|
||||
const AGENT_NOT_INSTALLED_ERROR = "agent not installed";
|
||||
|
||||
/** Builds a WS notification message fixture with the given action, payload, and timestamp. */
|
||||
function message(action: string, payload: unknown, timestamp = TIMESTAMP) {
|
||||
@@ -44,6 +49,30 @@ function profilePayload(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds an AvailableAgent fixture (agent.available.updated payload entry), overridable per test. */
|
||||
function availableAgentPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: AGENT_NAME,
|
||||
display_name: "Claude",
|
||||
supports_mcp: false,
|
||||
installation_paths: [],
|
||||
available: true,
|
||||
capabilities: {
|
||||
supports_session_resume: false,
|
||||
supports_shell: false,
|
||||
supports_workspace_only: false,
|
||||
},
|
||||
model_config: {
|
||||
default_model: "default",
|
||||
available_models: [],
|
||||
supports_dynamic_models: false,
|
||||
status: "ok",
|
||||
},
|
||||
updated_at: TIMESTAMP,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds an office-scoped profile payload fixture (workspace_id set). */
|
||||
function officeProfilePayload() {
|
||||
return {
|
||||
@@ -65,24 +94,50 @@ function handlersFor(store: ReturnType<typeof makeStore>) {
|
||||
>;
|
||||
}
|
||||
|
||||
/** Builds a settingsAgents Agent fixture, overridable per test. */
|
||||
function settingsAgentPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "agent-1",
|
||||
name: AGENT_NAME,
|
||||
supports_mcp: false,
|
||||
profiles: [],
|
||||
created_at: TIMESTAMP,
|
||||
updated_at: TIMESTAMP,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds an AvailableAgent payload reporting the agent as not installed. */
|
||||
function notInstalledAvailableAgentPayload(error = AGENT_NOT_INSTALLED_ERROR) {
|
||||
return availableAgentPayload({
|
||||
model_config: {
|
||||
default_model: "default",
|
||||
available_models: [],
|
||||
supports_dynamic_models: false,
|
||||
status: NOT_INSTALLED,
|
||||
error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("agent update job websocket handlers", () => {
|
||||
it("upserts update snapshots and appends each output chunk once", () => {
|
||||
const store = makeStore();
|
||||
const handlers = handlersFor(store);
|
||||
const snapshot = {
|
||||
job_id: "update-1",
|
||||
agent_name: "claude-acp",
|
||||
agent_name: AGENT_NAME,
|
||||
status: "updating",
|
||||
current_version: "1.0.0",
|
||||
target_version: "1.1.0",
|
||||
started_at: "2026-07-26T10:00:00Z",
|
||||
started_at: TIMESTAMP,
|
||||
};
|
||||
|
||||
handlers["agent.update.started"](message("agent.update.started", snapshot));
|
||||
handlers["agent.update.output"](
|
||||
message("agent.update.output", {
|
||||
job_id: "update-1",
|
||||
agent_name: "claude-acp",
|
||||
agent_name: AGENT_NAME,
|
||||
chunk: "downloaded\n",
|
||||
}),
|
||||
);
|
||||
@@ -99,7 +154,7 @@ describe("agent update job websocket handlers", () => {
|
||||
store.getState() as unknown as {
|
||||
updateJobs: { byAgent: Record<string, { status: string; output: string }> };
|
||||
}
|
||||
).updateJobs.byAgent["claude-acp"],
|
||||
).updateJobs.byAgent[AGENT_NAME],
|
||||
).toMatchObject({ status: "succeeded", output: "downloaded\n" });
|
||||
});
|
||||
});
|
||||
@@ -122,7 +177,7 @@ describe("agent profile events", () => {
|
||||
it("does not let a delayed profile event overwrite newer stored state", () => {
|
||||
const store = makeStore();
|
||||
const handlers = handlersFor(store);
|
||||
const newer = profilePayload({ model: "new-model", updated_at: "2026-07-26T11:00:00Z" });
|
||||
const newer = profilePayload({ model: "new-model", updated_at: NEWER_TIMESTAMP });
|
||||
handlers[PROFILE_CREATED](message(PROFILE_CREATED, { profile: newer }, "2026-07-26T11:05:00Z"));
|
||||
// A delayed older event (e.g. a replayed duplicate response) must not
|
||||
// regress the newer revision.
|
||||
@@ -137,9 +192,7 @@ describe("agent profile events", () => {
|
||||
const store = makeStore();
|
||||
const handlers = handlersFor(store);
|
||||
|
||||
handlers[PROFILE_CREATED](
|
||||
message(PROFILE_CREATED, { profile: profilePayload() }, "2026-07-26T10:00:00Z"),
|
||||
);
|
||||
handlers[PROFILE_CREATED](message(PROFILE_CREATED, { profile: profilePayload() }, TIMESTAMP));
|
||||
expect(store.getState().agentProfiles.items).toHaveLength(1);
|
||||
|
||||
handlers[PROFILE_DELETED](
|
||||
@@ -148,9 +201,7 @@ describe("agent profile events", () => {
|
||||
expect(store.getState().agentProfiles.items).toHaveLength(0);
|
||||
|
||||
// A create event produced BEFORE the deletion is stale: no resurrection.
|
||||
handlers[PROFILE_CREATED](
|
||||
message(PROFILE_CREATED, { profile: profilePayload() }, "2026-07-26T11:00:00Z"),
|
||||
);
|
||||
handlers[PROFILE_CREATED](message(PROFILE_CREATED, { profile: profilePayload() }, TIMESTAMP));
|
||||
expect(store.getState().agentProfiles.items).toHaveLength(0);
|
||||
|
||||
// A genuinely newer create (after the deletion) wins and clears the
|
||||
@@ -165,6 +216,39 @@ describe("agent profile events", () => {
|
||||
expect(store.getState().agentProfiles.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves capability_status on the rebuilt option when a profile update arrives", () => {
|
||||
const store = makeStore();
|
||||
const handlers = handlersFor(store);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
settingsAgents: {
|
||||
items: [
|
||||
settingsAgentPayload({
|
||||
capability_status: NOT_INSTALLED,
|
||||
capability_error: AGENT_NOT_INSTALLED_ERROR,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
handlers[PROFILE_CREATED](message(PROFILE_CREATED, { profile: profilePayload() }, TIMESTAMP));
|
||||
expect(store.getState().agentProfiles.items[0]?.capability_status).toBe(NOT_INSTALLED);
|
||||
|
||||
const updatedAt = "2026-07-26T14:00:00Z";
|
||||
handlers[PROFILE_UPDATED](
|
||||
message(
|
||||
PROFILE_UPDATED,
|
||||
{ profile: profilePayload({ model: "mock-slow", updated_at: updatedAt }) },
|
||||
updatedAt,
|
||||
),
|
||||
);
|
||||
|
||||
const updated = store.getState().agentProfiles.items[0];
|
||||
expect(updated?.model).toBe("mock-slow");
|
||||
expect(updated?.capability_status).toBe(NOT_INSTALLED);
|
||||
expect(updated?.capability_error).toBe(AGENT_NOT_INSTALLED_ERROR);
|
||||
});
|
||||
|
||||
it("ignores office-scoped delete events", () => {
|
||||
const store = makeStore();
|
||||
const handlers = handlersFor(store);
|
||||
@@ -175,6 +259,205 @@ describe("agent profile events", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent.available.updated capability refresh", () => {
|
||||
it("does not let an older websocket snapshot clobber a newer capability snapshot", () => {
|
||||
const store = makeStore();
|
||||
const handlers = handlersFor(store);
|
||||
|
||||
handlers[AVAILABLE_UPDATED](
|
||||
message(AVAILABLE_UPDATED, {
|
||||
agents: [
|
||||
availableAgentPayload({
|
||||
model_config: {
|
||||
default_model: "default",
|
||||
available_models: [],
|
||||
supports_dynamic_models: false,
|
||||
status: "ok",
|
||||
},
|
||||
updated_at: NEWER_TIMESTAMP,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
handlers[AVAILABLE_UPDATED](
|
||||
message(AVAILABLE_UPDATED, {
|
||||
agents: [notInstalledAvailableAgentPayload()],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.getState().availableAgents.items[0]?.model_config.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("refreshes capability_status on matching profiles when agent.available.updated reports the agent is ready", () => {
|
||||
const store = makeStore();
|
||||
const handlers = handlersFor(store);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
agentProfiles: {
|
||||
...state.agentProfiles,
|
||||
items: [
|
||||
{
|
||||
id: "profile-1",
|
||||
label: "Claude • Default",
|
||||
agent_id: "agent-1",
|
||||
agent_name: AGENT_NAME,
|
||||
cli_passthrough: false,
|
||||
capability_status: NOT_INSTALLED,
|
||||
capability_error: AGENT_NOT_INSTALLED_ERROR,
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
handlers[AVAILABLE_UPDATED](message(AVAILABLE_UPDATED, { agents: [availableAgentPayload()] }));
|
||||
|
||||
const updated = store.getState().agentProfiles.items[0];
|
||||
expect(updated?.capability_status).toBe("ok");
|
||||
expect(updated?.capability_error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps the not_configured cache-miss sentinel back to undefined so the profile stays healthy", () => {
|
||||
const store = makeStore();
|
||||
const handlers = handlersFor(store);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
agentProfiles: {
|
||||
...state.agentProfiles,
|
||||
items: [
|
||||
{
|
||||
id: "profile-1",
|
||||
label: "Claude • Default",
|
||||
agent_id: "agent-1",
|
||||
agent_name: AGENT_NAME,
|
||||
cli_passthrough: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
handlers[AVAILABLE_UPDATED](
|
||||
message(AVAILABLE_UPDATED, {
|
||||
agents: [
|
||||
availableAgentPayload({
|
||||
model_config: {
|
||||
default_model: "default",
|
||||
available_models: [],
|
||||
supports_dynamic_models: false,
|
||||
status: "not_configured",
|
||||
error: "no host-utility entry",
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const updated = store.getState().agentProfiles.items[0];
|
||||
expect(updated?.capability_status).toBeUndefined();
|
||||
expect(updated?.capability_error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/** Seeds settingsAgents with a single unhealthy AGENT_NAME entry. */
|
||||
function seedUnhealthySettingsAgent(store: ReturnType<typeof makeStore>) {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
settingsAgents: {
|
||||
items: [
|
||||
settingsAgentPayload({
|
||||
capability_status: NOT_INSTALLED,
|
||||
capability_error: AGENT_NOT_INSTALLED_ERROR,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
describe("agent.available.updated keeps settingsAgents in sync so later profile events don't revert capability", () => {
|
||||
it("install then edit: a profile made healthy by available.updated stays healthy after a profile.updated event", () => {
|
||||
const store = makeStore();
|
||||
const handlers = handlersFor(store);
|
||||
seedUnhealthySettingsAgent(store);
|
||||
|
||||
// The profile already exists (created while the agent was unhealthy).
|
||||
handlers[PROFILE_CREATED](message(PROFILE_CREATED, { profile: profilePayload() }, TIMESTAMP));
|
||||
expect(store.getState().agentProfiles.items[0]?.capability_status).toBe(NOT_INSTALLED);
|
||||
|
||||
// The agent was just installed: a fresh available.updated snapshot
|
||||
// reports it healthy.
|
||||
handlers[AVAILABLE_UPDATED](message(AVAILABLE_UPDATED, { agents: [availableAgentPayload()] }));
|
||||
|
||||
// The user edits the profile in Settings; the backend broadcasts the
|
||||
// update. Before the fix, handleProfileUpdated rebuilt the option from
|
||||
// the still-stale settingsAgents entry and reverted it to unhealthy.
|
||||
const updatedAt = "2026-07-26T15:00:00Z";
|
||||
handlers[PROFILE_UPDATED](
|
||||
message(
|
||||
PROFILE_UPDATED,
|
||||
{ profile: profilePayload({ model: "mock-slow", updated_at: updatedAt }) },
|
||||
updatedAt,
|
||||
),
|
||||
);
|
||||
|
||||
const updated = store.getState().agentProfiles.items[0];
|
||||
expect(updated?.capability_status).toBe("ok");
|
||||
expect(updated?.capability_error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("break then edit: a profile made unhealthy by available.updated stays unhealthy after a profile.updated event", () => {
|
||||
const store = makeStore();
|
||||
const handlers = handlersFor(store);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
settingsAgents: { items: [settingsAgentPayload()] },
|
||||
}));
|
||||
|
||||
// The profile already exists (created while the agent was healthy).
|
||||
handlers[PROFILE_CREATED](message(PROFILE_CREATED, { profile: profilePayload() }, TIMESTAMP));
|
||||
expect(store.getState().agentProfiles.items[0]?.capability_status).toBeUndefined();
|
||||
|
||||
// The agent breaks after boot (uninstalled/deauthed): available.updated
|
||||
// reports it not_installed.
|
||||
handlers[AVAILABLE_UPDATED](
|
||||
message(AVAILABLE_UPDATED, {
|
||||
agents: [notInstalledAvailableAgentPayload("agent CLI not found")],
|
||||
}),
|
||||
);
|
||||
|
||||
// Any subsequent profile edit must not resurrect it into Handoff. Before
|
||||
// the fix, this rebuilt the option from the still-stale (healthy)
|
||||
// settingsAgents entry and re-listed the uninstalled provider.
|
||||
const updatedAt = "2026-07-26T15:00:00Z";
|
||||
handlers[PROFILE_UPDATED](
|
||||
message(
|
||||
PROFILE_UPDATED,
|
||||
{ profile: profilePayload({ model: "mock-slow", updated_at: updatedAt }) },
|
||||
updatedAt,
|
||||
),
|
||||
);
|
||||
|
||||
const updated = store.getState().agentProfiles.items[0];
|
||||
expect(updated?.capability_status).toBe(NOT_INSTALLED);
|
||||
expect(updated?.capability_error).toBe("agent CLI not found");
|
||||
});
|
||||
|
||||
it("does not touch settingsAgents entries for agents the snapshot has no match for", () => {
|
||||
const store = makeStore();
|
||||
const handlers = handlersFor(store);
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
settingsAgents: {
|
||||
items: [
|
||||
settingsAgentPayload({ id: "agent-other", name: "other-agent", capability_status: "ok" }),
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
handlers[AVAILABLE_UPDATED](message(AVAILABLE_UPDATED, { agents: [availableAgentPayload()] }));
|
||||
|
||||
expect(store.getState().settingsAgents.items[0]?.capability_status).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent profile event ordering", () => {
|
||||
it("ignores a stale delete event when a newer revision already exists", () => {
|
||||
const store = makeStore();
|
||||
@@ -182,7 +465,7 @@ describe("agent profile event ordering", () => {
|
||||
const current = profilePayload({
|
||||
id: "p-stale",
|
||||
model: "new-model",
|
||||
updated_at: "2026-07-26T11:00:00Z",
|
||||
updated_at: NEWER_TIMESTAMP,
|
||||
});
|
||||
handlers[PROFILE_CREATED](
|
||||
message(PROFILE_CREATED, { profile: current }, "2026-07-26T11:05:00Z"),
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { StoreApi } from "zustand";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { WsHandlers } from "@/lib/ws/handlers/types";
|
||||
import { compareTimestamps, toAgentProfileOption } from "@/lib/state/slices/settings/types";
|
||||
import {
|
||||
compareTimestamps,
|
||||
isStaleAvailableAgentsSnapshot,
|
||||
refreshProfileCapabilities,
|
||||
refreshSettingsAgentsCapabilities,
|
||||
toAgentProfileOption,
|
||||
} from "@/lib/state/slices/settings/types";
|
||||
import { normalizeAgentProfile } from "@/lib/api/domains/agent-profile-normalize";
|
||||
import type { AgentProfile } from "@/lib/types/agent-profile";
|
||||
|
||||
@@ -89,7 +95,12 @@ function handleProfileCreated(
|
||||
deletionTombstones.delete(normalized.id); // a genuinely newer create wins
|
||||
const agentId = getAgentId(profile);
|
||||
const agent = state.settingsAgents.items.find((a) => a.id === agentId);
|
||||
const agentStub = { id: agentId, name: agent?.name ?? "" };
|
||||
const agentStub = {
|
||||
id: agentId,
|
||||
name: agent?.name ?? "",
|
||||
capability_status: agent?.capability_status,
|
||||
capability_error: agent?.capability_error,
|
||||
};
|
||||
const nextProfiles = [
|
||||
...state.agentProfiles.items.filter((p) => p.id !== normalized.id),
|
||||
toAgentProfileOption(agentStub, normalized),
|
||||
@@ -125,7 +136,12 @@ function handleProfileUpdated(
|
||||
if (isStaleProfileEvent(state, normalized, eventTimestamp)) return {};
|
||||
const agentId = getAgentId(profile);
|
||||
const agent = state.settingsAgents.items.find((a) => a.id === agentId);
|
||||
const agentStub = { id: agentId, name: agent?.name ?? "" };
|
||||
const agentStub = {
|
||||
id: agentId,
|
||||
name: agent?.name ?? "",
|
||||
capability_status: agent?.capability_status,
|
||||
capability_error: agent?.capability_error,
|
||||
};
|
||||
const nextProfiles = state.agentProfiles.items.map((p) =>
|
||||
p.id === normalized.id ? toAgentProfileOption(agentStub, normalized) : p,
|
||||
);
|
||||
@@ -181,15 +197,27 @@ function handleProfileDeleted(
|
||||
export function registerAgentsHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
return {
|
||||
"agent.available.updated": (message) => {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
availableAgents: {
|
||||
items: message.payload.agents ?? [],
|
||||
tools: message.payload.tools ?? state.availableAgents.tools,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
const agents = message.payload.agents ?? [];
|
||||
store.setState((state) => {
|
||||
if (isStaleAvailableAgentsSnapshot(state.availableAgents.items, agents)) return state;
|
||||
return {
|
||||
...state,
|
||||
availableAgents: {
|
||||
items: agents,
|
||||
tools: message.payload.tools ?? state.availableAgents.tools,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
},
|
||||
agentProfiles: {
|
||||
...state.agentProfiles,
|
||||
items: refreshProfileCapabilities(state.agentProfiles.items, agents),
|
||||
},
|
||||
settingsAgents: {
|
||||
...state.settingsAgents,
|
||||
items: refreshSettingsAgentsCapabilities(state.settingsAgents.items, agents),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
"agent.install.started": (message) => {
|
||||
// Payload is the full job snapshot (queued → running transitions both emit this).
|
||||
|
||||
@@ -839,6 +839,7 @@
|
||||
"noAgentProfileConfiguredFor": "No agent profile is configured for <2>“{{name}}”</2>. Configure credentials in Settings → Executors.",
|
||||
"noAgentProfilesConfigured": "No agent profiles configured",
|
||||
"noAgentProfilesConfiguredAddOne": "No agent profiles configured. Add one in Settings → Agents first.",
|
||||
"noAgentProfilesReadyForHandoff": "No agent profiles are ready. Install or reconnect an agent CLI in Settings → Agents.",
|
||||
"noAgentRunning": "no agent running",
|
||||
"noAgentsFound": "No agents found.",
|
||||
"noAgentsFound2": "No agents found",
|
||||
|
||||
@@ -839,6 +839,7 @@
|
||||
"noAgentProfileConfiguredFor": "Ńō àĝēńţ ƥŕōƒĩĺē ĩś ćōńƒĩĝũŕēď ƒōŕ <2>“{{name}}”</2>. Ćōńƒĩĝũŕē ćŕēďēńţĩàĺś ĩń Śēţţĩńĝś → Ēxēćũţōŕś.",
|
||||
"noAgentProfilesConfigured": "Ńō àĝēńţ ƥŕōƒĩĺēś ćōńƒĩĝũŕēď",
|
||||
"noAgentProfilesConfiguredAddOne": "Ńō àĝēńţ ƥŕōƒĩĺēś ćōńƒĩĝũŕēď. Àďď ōńē ĩń Śēţţĩńĝś → Àĝēńţś ƒĩŕśţ.",
|
||||
"noAgentProfilesReadyForHandoff": "Ńō àĝēńţ ƥŕōƒĩĺēś àŕē ŕēàďŷ. Ĩńśţàĺĺ ōŕ ŕēćōńńēćţ àń àĝēńţ ĆĹĨ ĩń Śēţţĩńĝś → Àĝēńţś.",
|
||||
"noAgentRunning": "ńō àĝēńţ ŕũńńĩńĝ",
|
||||
"noAgentsFound": "Ńō àĝēńţś ƒōũńď.",
|
||||
"noAgentsFound2": "Ńō àĝēńţś ƒōũńď",
|
||||
|
||||
@@ -803,6 +803,7 @@
|
||||
"noAgentProfileConfiguredFor": "Não há nenhum perfil de agente configurado para <2>“{{name}}”</2>. Configure as credenciais em Definições → Executores.",
|
||||
"noAgentProfilesConfigured": "Nenhum perfil de agente configurado",
|
||||
"noAgentProfilesConfiguredAddOne": "Nenhum perfil de agente configurado. Adicione um primeiro em Definições → Agentes.",
|
||||
"noAgentProfilesReadyForHandoff": "Nenhum perfil de agente está pronto. Instale ou volte a ligar a CLI do agente em Definições → Agentes.",
|
||||
"noAgentRunning": "nenhum agente em execução",
|
||||
"noAgentsFound": "Nenhum agente encontrado.",
|
||||
"noAgentsFound2": "Nenhum agente encontrado",
|
||||
|
||||
@@ -802,6 +802,7 @@
|
||||
"noAgentProfileConfiguredFor": "未为 <2>“{{name}}”</2> 配置智能体配置。请在“设置 → 执行器”中配置凭据。",
|
||||
"noAgentProfilesConfigured": "未配置智能体配置",
|
||||
"noAgentProfilesConfiguredAddOne": "未配置智能体配置。请先在“设置 → 智能体”中添加。",
|
||||
"noAgentProfilesReadyForHandoff": "没有可用的智能体配置。请在“设置 → 智能体”中安装或重新连接智能体 CLI。",
|
||||
"noAgentRunning": "无智能体运行中",
|
||||
"noAgentsFound": "未找到智能体。",
|
||||
"noAgentsFound2": "未找到智能体",
|
||||
|
||||
@@ -802,6 +802,7 @@
|
||||
"noAgentProfileConfiguredFor": "未為 <2>「{{name}}」</2> 設定代理程式設定檔。請在「設定 → 執行器」中設定認證資料。",
|
||||
"noAgentProfilesConfigured": "未設定代理程式設定檔",
|
||||
"noAgentProfilesConfiguredAddOne": "未設定代理程式設定檔。請先在「設定 → 代理程式」中新增。",
|
||||
"noAgentProfilesReadyForHandoff": "沒有可用的代理程式設定檔。請在「設定 → 代理程式」中安裝或重新連接代理程式 CLI。",
|
||||
"noAgentRunning": "無代理程式執行中",
|
||||
"noAgentsFound": "未找到代理程式。",
|
||||
"noAgentsFound2": "未找到代理程式",
|
||||
|
||||
@@ -802,6 +802,7 @@
|
||||
"noAgentProfileConfiguredFor": "未為 <2>「{{name}}」</2> 設定代理程式設定檔。請在「設定 → 執行器」中設定認證資訊。",
|
||||
"noAgentProfilesConfigured": "未設定代理程式設定檔",
|
||||
"noAgentProfilesConfiguredAddOne": "未設定代理程式設定檔。請先在「設定 → 代理程式」中新增。",
|
||||
"noAgentProfilesReadyForHandoff": "沒有可用的代理程式設定檔。請在「設定 → 代理程式」中安裝或重新連線代理程式 CLI。",
|
||||
"noAgentRunning": "無代理程式執行中",
|
||||
"noAgentsFound": "未找到代理程式。",
|
||||
"noAgentsFound2": "未找到代理程式",
|
||||
|
||||
Reference in New Issue
Block a user