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:
nova28
2026-08-21 18:57:43 +08:00
committed by GitHub
parent c539cb8149
commit 186efbf6f3
23 changed files with 1248 additions and 76 deletions
@@ -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);
});
});
+15
View File
@@ -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" });
});
});
+128
View File
@@ -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">,
+295 -12
View File
@@ -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"),
+40 -12
View File
@@ -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).
+1
View File
@@ -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",
+1
View File
@@ -839,6 +839,7 @@
"noAgentProfileConfiguredFor": "Ńō àĝēńţ ƥŕōƒĩĺē ĩś ćōńƒĩĝũŕēď ƒōŕ <2>“{{name}}”</2>. Ćōńƒĩĝũŕē ćŕēďēńţĩàĺś ĩń Śēţţĩńĝś → Ēxēćũţōŕś.",
"noAgentProfilesConfigured": "Ńō àĝēńţ ƥŕōƒĩĺēś ćōńƒĩĝũŕēď",
"noAgentProfilesConfiguredAddOne": "Ńō àĝēńţ ƥŕōƒĩĺēś ćōńƒĩĝũŕēď. Àďď ōńē ĩń Śēţţĩńĝś → Àĝēńţś ƒĩŕśţ.",
"noAgentProfilesReadyForHandoff": "Ńō àĝēńţ ƥŕōƒĩĺēś àŕē ŕēàďŷ. Ĩńśţàĺĺ ōŕ ŕēćōńńēćţ àń àĝēńţ ĆĹĨ ĩń Śēţţĩńĝś → Àĝēńţś.",
"noAgentRunning": "ńō àĝēńţ ŕũńńĩńĝ",
"noAgentsFound": "Ńō àĝēńţś ƒōũńď.",
"noAgentsFound2": "Ńō àĝēńţś ƒōũńď",
+1
View File
@@ -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",
+1
View File
@@ -802,6 +802,7 @@
"noAgentProfileConfiguredFor": "未为 <2>“{{name}}”</2> 配置智能体配置。请在“设置 → 执行器”中配置凭据。",
"noAgentProfilesConfigured": "未配置智能体配置",
"noAgentProfilesConfiguredAddOne": "未配置智能体配置。请先在“设置 → 智能体”中添加。",
"noAgentProfilesReadyForHandoff": "没有可用的智能体配置。请在“设置 → 智能体”中安装或重新连接智能体 CLI。",
"noAgentRunning": "无智能体运行中",
"noAgentsFound": "未找到智能体。",
"noAgentsFound2": "未找到智能体",
+1
View File
@@ -802,6 +802,7 @@
"noAgentProfileConfiguredFor": "未為 <2>「{{name}}」</2> 設定代理程式設定檔。請在「設定 → 執行器」中設定認證資料。",
"noAgentProfilesConfigured": "未設定代理程式設定檔",
"noAgentProfilesConfiguredAddOne": "未設定代理程式設定檔。請先在「設定 → 代理程式」中新增。",
"noAgentProfilesReadyForHandoff": "沒有可用的代理程式設定檔。請在「設定 → 代理程式」中安裝或重新連接代理程式 CLI。",
"noAgentRunning": "無代理程式執行中",
"noAgentsFound": "未找到代理程式。",
"noAgentsFound2": "未找到代理程式",
+1
View File
@@ -802,6 +802,7 @@
"noAgentProfileConfiguredFor": "未為 <2>「{{name}}」</2> 設定代理程式設定檔。請在「設定 → 執行器」中設定認證資訊。",
"noAgentProfilesConfigured": "未設定代理程式設定檔",
"noAgentProfilesConfiguredAddOne": "未設定代理程式設定檔。請先在「設定 → 代理程式」中新增。",
"noAgentProfilesReadyForHandoff": "沒有可用的代理程式設定檔。請在「設定 → 代理程式」中安裝或重新連線代理程式 CLI。",
"noAgentRunning": "無代理程式執行中",
"noAgentsFound": "未找到代理程式。",
"noAgentsFound2": "未找到代理程式",