diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 9849d98a59..aff03d1785 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -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)), ), ); }; diff --git a/apps/web/components/task/handoff-profile-menu-items.test.ts b/apps/web/components/task/handoff-profile-menu-items.test.ts index 4fa1fdbf8f..61b050be0f 100644 --- a/apps/web/components/task/handoff-profile-menu-items.test.ts +++ b/apps/web/components/task/handoff-profile-menu-items.test.ts @@ -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 = {}; 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); + }); }); diff --git a/apps/web/components/task/handoff-profile-menu-items.tsx b/apps/web/components/task/handoff-profile-menu-items.tsx index 2b4c6b5d2a..44bbe75721 100644 --- a/apps/web/components/task/handoff-profile-menu-items.tsx +++ b/apps/web/components/task/handoff-profile-menu-items.tsx @@ -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 ( - {t("task:noAgentProfilesConfigured")} + {t(emptyMessageKey)} ); } @@ -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 @@ -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 diff --git a/apps/web/components/task/new-session-dialog.test.tsx b/apps/web/components/task/new-session-dialog.test.tsx index a3c628ca44..c8b925dc68 100644 --- a/apps/web/components/task/new-session-dialog.test.tsx +++ b/apps/web/components/task/new-session-dialog.test.tsx @@ -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( + , + ); + + fireEvent.click(screen.getByRole("button", { name: PLUGIN_COMPOSER_LABEL })); + + await waitFor(() => + expect(mockBuildStartRequest).toHaveBeenCalledWith( + "task-1", + "profile-1", + expect.objectContaining({ profileExplicit: true }), + ), + ); + }); }); diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx index 26d3fcd855..335decd2da 100644 --- a/apps/web/components/task/new-session-dialog.tsx +++ b/apps/web/components/task/new-session-dialog.tsx @@ -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); diff --git a/apps/web/e2e/tests/session/session-handoff-unhealthy-profile.spec.ts b/apps/web/e2e/tests/session/session-handoff-unhealthy-profile.spec.ts new file mode 100644 index 0000000000..4f86e91db8 --- /dev/null +++ b/apps/web/e2e/tests/session/session-handoff-unhealthy-profile.spec.ts @@ -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, +) { + 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(); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts b/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts index 5bfa9ae83f..0c0599382b 100644 --- a/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts +++ b/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts @@ -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)); } diff --git a/apps/web/hooks/domains/settings/use-profile-duplicate.test.ts b/apps/web/hooks/domains/settings/use-profile-duplicate.test.ts index 22032045c2..ba9f3b11cd 100644 --- a/apps/web/hooks/domains/settings/use-profile-duplicate.test.ts +++ b/apps/web/hooks/domains/settings/use-profile-duplicate.test.ts @@ -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()( + immer((set, get, api) => createSettingsSlice(set, get, api)), + ) as unknown as StoreApi; + 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(); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-profile-duplicate.ts b/apps/web/hooks/domains/settings/use-profile-duplicate.ts index 7187cce319..86f10546b8 100644 --- a/apps/web/hooks/domains/settings/use-profile-duplicate.ts +++ b/apps/web/hooks/domains/settings/use-profile-duplicate.ts @@ -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) { diff --git a/apps/web/lib/agent-executor-compat.test.ts b/apps/web/lib/agent-executor-compat.test.ts new file mode 100644 index 0000000000..898d416ec0 --- /dev/null +++ b/apps/web/lib/agent-executor-compat.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/agent-executor-compat.ts b/apps/web/lib/agent-executor-compat.ts index 349f68a280..d9370b878e 100644 --- a/apps/web/lib/agent-executor-compat.ts +++ b/apps/web/lib/agent-executor-compat.ts @@ -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 | 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; diff --git a/apps/web/lib/state/slices/settings/settings-slice.test.ts b/apps/web/lib/state/slices/settings/settings-slice.test.ts index 20eae1689b..949f0722e3 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.test.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.test.ts @@ -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()(immer((set, get, store) => createSettingsSlice(set, get, store))); @@ -12,12 +17,12 @@ function makeStore() { function updateJob(overrides: Record = {}) { 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 { + 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 { + 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, + 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"); + }); +}); diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index 7577dab555..822f362733 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -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) => { diff --git a/apps/web/lib/state/slices/settings/types.test.ts b/apps/web/lib/state/slices/settings/types.test.ts index 12b754e587..ab106025ef 100644 --- a/apps/web/lib/state/slices/settings/types.test.ts +++ b/apps/web/lib/state/slices/settings/types.test.ts @@ -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" }); + }); }); diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index f5d87c1a58..e546b314bf 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -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( + (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, diff --git a/apps/web/lib/ws/handlers/agents.test.ts b/apps/web/lib/ws/handlers/agents.test.ts index 8ea7413010..b081d3ecc1 100644 --- a/apps/web/lib/ws/handlers/agents.test.ts +++ b/apps/web/lib/ws/handlers/agents.test.ts @@ -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 = {}) { }; } +/** Builds an AvailableAgent fixture (agent.available.updated payload entry), overridable per test. */ +function availableAgentPayload(overrides: Record = {}) { + 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) { >; } +/** Builds a settingsAgents Agent fixture, overridable per test. */ +function settingsAgentPayload(overrides: Record = {}) { + 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 }; } - ).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) { + 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"), diff --git a/apps/web/lib/ws/handlers/agents.ts b/apps/web/lib/ws/handlers/agents.ts index 43c200faa9..4086508aee 100644 --- a/apps/web/lib/ws/handlers/agents.ts +++ b/apps/web/lib/ws/handlers/agents.ts @@ -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): 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). diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 143a6d3a2e..cb73c9b0be 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -839,6 +839,7 @@ "noAgentProfileConfiguredFor": "No agent profile is configured for <2>“{{name}}”. 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", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 9c63c0a8a0..521bcc67e6 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -839,6 +839,7 @@ "noAgentProfileConfiguredFor": "Ńō àĝēńţ ƥŕōƒĩĺē ĩś ćōńƒĩĝũŕēď ƒōŕ <2>“{{name}}”. Ćōńƒĩĝũŕē ćŕēďēńţĩàĺś ĩń Śēţţĩńĝś → Ēxēćũţōŕś.", "noAgentProfilesConfigured": "Ńō àĝēńţ ƥŕōƒĩĺēś ćōńƒĩĝũŕēď", "noAgentProfilesConfiguredAddOne": "Ńō àĝēńţ ƥŕōƒĩĺēś ćōńƒĩĝũŕēď. Àďď ōńē ĩń Śēţţĩńĝś → Àĝēńţś ƒĩŕśţ.", + "noAgentProfilesReadyForHandoff": "Ńō àĝēńţ ƥŕōƒĩĺēś àŕē ŕēàďŷ. Ĩńśţàĺĺ ōŕ ŕēćōńńēćţ àń àĝēńţ ĆĹĨ ĩń Śēţţĩńĝś → Àĝēńţś.", "noAgentRunning": "ńō àĝēńţ ŕũńńĩńĝ", "noAgentsFound": "Ńō àĝēńţś ƒōũńď.", "noAgentsFound2": "Ńō àĝēńţś ƒōũńď", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 4d4207c4ee..332e32195e 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -803,6 +803,7 @@ "noAgentProfileConfiguredFor": "Não há nenhum perfil de agente configurado para <2>“{{name}}”. 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", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index ccddf21480..4337d33368 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -802,6 +802,7 @@ "noAgentProfileConfiguredFor": "未为 <2>“{{name}}” 配置智能体配置。请在“设置 → 执行器”中配置凭据。", "noAgentProfilesConfigured": "未配置智能体配置", "noAgentProfilesConfiguredAddOne": "未配置智能体配置。请先在“设置 → 智能体”中添加。", + "noAgentProfilesReadyForHandoff": "没有可用的智能体配置。请在“设置 → 智能体”中安装或重新连接智能体 CLI。", "noAgentRunning": "无智能体运行中", "noAgentsFound": "未找到智能体。", "noAgentsFound2": "未找到智能体", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index b44518666e..9e3c02bddc 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -802,6 +802,7 @@ "noAgentProfileConfiguredFor": "未為 <2>「{{name}}」 設定代理程式設定檔。請在「設定 → 執行器」中設定認證資料。", "noAgentProfilesConfigured": "未設定代理程式設定檔", "noAgentProfilesConfiguredAddOne": "未設定代理程式設定檔。請先在「設定 → 代理程式」中新增。", + "noAgentProfilesReadyForHandoff": "沒有可用的代理程式設定檔。請在「設定 → 代理程式」中安裝或重新連接代理程式 CLI。", "noAgentRunning": "無代理程式執行中", "noAgentsFound": "未找到代理程式。", "noAgentsFound2": "未找到代理程式", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index fae341acb4..47597575f1 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -802,6 +802,7 @@ "noAgentProfileConfiguredFor": "未為 <2>「{{name}}」 設定代理程式設定檔。請在「設定 → 執行器」中設定認證資訊。", "noAgentProfilesConfigured": "未設定代理程式設定檔", "noAgentProfilesConfiguredAddOne": "未設定代理程式設定檔。請先在「設定 → 代理程式」中新增。", + "noAgentProfilesReadyForHandoff": "沒有可用的代理程式設定檔。請在「設定 → 代理程式」中安裝或重新連線代理程式 CLI。", "noAgentRunning": "無代理程式執行中", "noAgentsFound": "未找到代理程式。", "noAgentsFound2": "未找到代理程式",