Files
kandev/apps/web/components/task-create-dialog-effects.test.ts

745 lines
28 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import {
useDefaultSelectionsEffect,
useWorkflowAgentProfileEffect,
useWorkflowStepsEffect,
} from "./task-create-dialog-effects";
import {
decideAgentProfileAutopick,
type AgentProfileAutopickInput,
} from "./task-create-dialog-autopick";
import type { DialogFormState, StoreSelections } from "@/components/task-create-dialog-types";
import { readQueuedTaskCreateLastUsedState } from "./task-create-dialog-handlers";
import type { AgentProfileOption } from "@/lib/state/slices";
import type { Workspace } from "@/lib/types/http";
const STORAGE_KEYS = { LAST_AGENT_PROFILE_ID: "kandev.dialog.lastAgentProfileId" } as const;
const workflowApiMocks = vi.hoisted(() => ({
listWorkflowSteps: vi.fn(),
}));
vi.mock("@/lib/api/domains/workflow-api", () => ({
listWorkflowSteps: workflowApiMocks.listWorkflowSteps,
}));
// Minimal fake of DialogFormState - the hook destructures only three fields,
// so the rest can be undefined behind an `as` cast and never read.
type Fake = Pick<
DialogFormState,
| "agentProfileId"
| "workflowAgentProfileId"
| "selectedWorkflowId"
| "executorProfileId"
| "setAgentProfileId"
| "setWorkflowAgentProfileId"
>;
function makeFs(overrides: Partial<Fake> = {}): DialogFormState {
return {
agentProfileId: "",
workflowAgentProfileId: "",
selectedWorkflowId: null,
executorProfileId: "profile-1",
setAgentProfileId: vi.fn(),
setWorkflowAgentProfileId: vi.fn(),
...overrides,
} as unknown as DialogFormState;
}
function makeProfile(id: string): AgentProfileOption {
return {
id,
label: `agent • ${id}`,
agent_id: `agent-${id}`,
agent_name: "agent",
cli_passthrough: false,
};
}
beforeEach(() => {
localStorage.clear();
workflowApiMocks.listWorkflowSteps.mockReset();
});
describe("useWorkflowStepsEffect", () => {
it("loads the visible fallback steps when hidden task context was removed", async () => {
workflowApiMocks.listWorkflowSteps.mockResolvedValue({
steps: [
{
id: "visible-backlog",
workflow_id: "visible",
name: "Backlog",
position: 0,
is_start_step: false,
prompt: "Backlog prompt",
},
{
id: "visible-start",
workflow_id: "visible",
name: "In Progress",
position: 1,
is_start_step: true,
},
],
total: 2,
});
const setFetchedSteps = vi.fn();
const fs = {
selectedWorkflowId: null,
setFetchedSteps,
} as unknown as DialogFormState;
renderHook(() => useWorkflowStepsEffect(fs, true, null, "visible"));
await waitFor(() => expect(workflowApiMocks.listWorkflowSteps).toHaveBeenCalledWith("visible"));
await waitFor(() =>
expect(setFetchedSteps).toHaveBeenCalledWith([
{
id: "visible-backlog",
title: "Backlog",
position: 0,
is_start_step: false,
prompt: "Backlog prompt",
events: undefined,
workflowId: "visible",
},
{
id: "visible-start",
title: "In Progress",
position: 1,
is_start_step: true,
events: undefined,
workflowId: "visible",
},
]),
);
});
it("clears stale steps and retries the visible fallback when the dialog reopens", async () => {
workflowApiMocks.listWorkflowSteps.mockRejectedValueOnce(new Error("temporary failure"));
workflowApiMocks.listWorkflowSteps.mockResolvedValueOnce({ steps: [], total: 0 });
const setFetchedSteps = vi.fn();
const fs = {
selectedWorkflowId: null,
setFetchedSteps,
} as unknown as DialogFormState;
const { rerender } = renderHook(
({ open }) => useWorkflowStepsEffect(fs, open, null, "visible"),
{ initialProps: { open: true } },
);
await waitFor(() => expect(workflowApiMocks.listWorkflowSteps).toHaveBeenCalledTimes(1));
await waitFor(() => expect(setFetchedSteps).toHaveBeenCalledWith(null));
rerender({ open: false });
rerender({ open: true });
await waitFor(() => expect(workflowApiMocks.listWorkflowSteps).toHaveBeenCalledTimes(2));
expect(workflowApiMocks.listWorkflowSteps).toHaveBeenNthCalledWith(2, "visible");
await waitFor(() => expect(setFetchedSteps).toHaveBeenLastCalledWith([]));
});
});
describe("useWorkflowAgentProfileEffect", () => {
it("clears workflowAgentProfileId and leaves agentProfileId alone when no workflow is selected", () => {
const fs = makeFs({ selectedWorkflowId: null });
renderHook(() =>
useWorkflowAgentProfileEffect(fs, [], [makeProfile("real-id")], [makeProfile("real-id")]),
);
expect(fs.setWorkflowAgentProfileId).toHaveBeenCalledWith("");
// No workflow → effect must early-return before touching agentProfileId,
// even if localStorage holds a value (stale or otherwise).
expect(fs.setAgentProfileId).not.toHaveBeenCalled();
});
it("locks to workflow.agent_profile_id when the profile exists", () => {
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1", agent_profile_id: "real-id" }];
renderHook(() =>
useWorkflowAgentProfileEffect(
fs,
workflows,
[makeProfile("real-id")],
[makeProfile("real-id")],
),
);
expect(fs.setWorkflowAgentProfileId).toHaveBeenCalledWith("real-id");
expect(fs.setAgentProfileId).toHaveBeenCalledWith("real-id");
});
it("locks the selector but does NOT set agentProfileId when workflow's profile is missing", () => {
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1", agent_profile_id: "missing-id" }];
renderHook(() =>
useWorkflowAgentProfileEffect(
fs,
workflows,
[makeProfile("real-id")],
[makeProfile("real-id")],
),
);
// The lock still applies (otherwise the user could pick an alternate
// profile and submit a workflow-locked task with the wrong agent).
expect(fs.setWorkflowAgentProfileId).toHaveBeenCalledWith("missing-id");
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith("missing-id");
});
it("restores backend last-used agentProfileId when the workflow has no override", () => {
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1" /* no agent_profile_id */ }];
renderHook(() =>
useWorkflowAgentProfileEffect(
fs,
workflows,
[makeProfile("real-id")],
[makeProfile("real-id")],
{ lastUsedAgentProfileId: "real-id" },
),
);
expect(fs.setWorkflowAgentProfileId).toHaveBeenCalledWith("");
expect(fs.setAgentProfileId).toHaveBeenCalledWith("real-id");
});
it("does NOT restore a stale localStorage agentProfileId that is not in agentProfiles", () => {
// Regression guard: a clean DB mints fresh UUIDs for the same agents, so
// the previous run's lastAgentProfileId no longer matches any profile.
// Writing it back here poisons the dialog into "No compatible agent
// profiles" because useDefaultSelectionsEffect skips when agentProfileId
// is already truthy.
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify("stale-id"));
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1" /* no agent_profile_id */ }];
renderHook(() =>
useWorkflowAgentProfileEffect(
fs,
workflows,
[makeProfile("real-id")],
[makeProfile("real-id")],
),
);
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith("stale-id");
});
it("does NOT restore a lastId that exists but is incompatible with the current executor", () => {
// Regression for #1075 (CodeRabbit): if lastId names a profile present in
// `agentProfiles` but absent from `compatibleAgentProfiles` (e.g. user
// switched from a local executor to a remote one without the matching
// credential), workflow-unlock used to restore it anyway, which made
// useDefaultSelectionsEffect early-exit on "already-set" and leave the
// dialog stuck on "No compatible agent profiles".
localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify("incompat-id"));
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1" /* no agent_profile_id */ }];
const all = [makeProfile("incompat-id"), makeProfile("compat-id")];
const compatible = [makeProfile("compat-id")];
renderHook(() => useWorkflowAgentProfileEffect(fs, workflows, all, compatible));
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith("incompat-id");
// Empty hand-off lets useDefaultSelectionsEffect pick a compatible default.
expect(fs.setAgentProfileId).toHaveBeenCalledWith("");
});
it("clears agentProfileId when the workflow has no override and there is no last-used id", () => {
const fs = makeFs({ selectedWorkflowId: "wf-1" });
const workflows = [{ id: "wf-1" /* no agent_profile_id */ }];
renderHook(() =>
useWorkflowAgentProfileEffect(
fs,
workflows,
[makeProfile("real-id")],
[makeProfile("real-id")],
),
);
// Empty string lets useDefaultSelectionsEffect take over the fallback
// chain (workspace default → first profile).
expect(fs.setAgentProfileId).toHaveBeenCalledWith("");
});
});
describe("useWorkflowAgentProfileEffect — settings fallback readiness", () => {
it("clears stale workflow agents before deferring last-used restore", async () => {
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const workflows = [{ id: "wf-1" /* no agent_profile_id */ }];
const fsBefore = makeFs({ selectedWorkflowId: "wf-1", executorProfileId: "" });
const { rerender } = renderHook(
({ fs, compatible }) =>
useWorkflowAgentProfileEffect(fs, workflows, [claude, cursor], compatible, {
lastUsedAgentProfileId: claude.id,
}),
{ initialProps: { fs: fsBefore, compatible: [claude, cursor] } },
);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fsBefore.setAgentProfileId).toHaveBeenCalledWith("");
expect(fsBefore.setAgentProfileId).not.toHaveBeenCalledWith(claude.id);
const fsAfter = makeFs({ selectedWorkflowId: "wf-1", executorProfileId: "profile-1" });
rerender({ fs: fsAfter, compatible: [cursor] });
await waitFor(() => expect(fsAfter.setAgentProfileId).toHaveBeenCalledWith(""));
expect(fsAfter.setAgentProfileId).not.toHaveBeenCalledWith(claude.id);
});
it("defers workflow last-used restore until auth compatibility is ready", async () => {
const claude = makeProfile("claude");
const workflows = [{ id: "wf-1" /* no agent_profile_id */ }];
const fsBefore = makeFs({ selectedWorkflowId: "wf-1", executorProfileId: "profile-1" });
const { rerender } = renderHook(
({ fs, authLoaded }) =>
useWorkflowAgentProfileEffect(fs, workflows, [claude], [claude], {
lastUsedAgentProfileId: claude.id,
authLoaded,
}),
{ initialProps: { fs: fsBefore, authLoaded: false } },
);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fsBefore.setAgentProfileId).toHaveBeenCalledWith("");
expect(fsBefore.setAgentProfileId).not.toHaveBeenCalledWith(claude.id);
const fsAfter = makeFs({ selectedWorkflowId: "wf-1", executorProfileId: "profile-1" });
rerender({ fs: fsAfter, authLoaded: true });
await waitFor(() => expect(fsAfter.setAgentProfileId).toHaveBeenCalledWith(claude.id));
});
});
type DefaultSelFake = Pick<
DialogFormState,
| "agentProfileId"
| "workflowAgentProfileId"
| "selectedWorkflowId"
| "executorId"
| "executorProfileId"
| "setAgentProfileId"
| "setExecutorId"
| "setExecutorProfileId"
| "noRepository"
| "repositories"
| "remoteRepos"
| "useRemote"
>;
function makeDefaultSelFs(overrides: Partial<DefaultSelFake> = {}): DialogFormState {
return {
agentProfileId: "",
workflowAgentProfileId: "",
selectedWorkflowId: null,
executorId: "exec-1",
executorProfileId: "profile-1",
setAgentProfileId: vi.fn(),
setExecutorId: vi.fn(),
setExecutorProfileId: vi.fn(),
noRepository: false,
remoteRepos: [],
useRemote: false,
// Multi-repo guard effect inside useDefaultSelectionsEffect reads
// fs.repositories when executorProfileId is set. Empty list keeps that
// branch a no-op for these autopick-focused tests.
repositories: [],
...overrides,
} as unknown as DialogFormState;
}
function makeSel(overrides: Partial<StoreSelections> = {}): StoreSelections {
return {
agentProfiles: [],
compatibleAgentProfiles: [],
// Default to loaded=true so the bulk of existing-behavior tests don't
// each have to flip it explicitly. The dedicated deferral test overrides.
authLoaded: true,
executors: [],
workspaceDefaults: null,
...overrides,
};
}
function decideAutopick(
agentProfiles: AgentProfileOption[],
compatibleAgentProfiles: AgentProfileOption[],
overrides: Partial<AgentProfileAutopickInput> = {},
) {
return decideAgentProfileAutopick({
open: true,
agentProfileId: "",
workflowAgentProfileId: "",
workflowHasAgent: false,
agentProfiles,
compatibleAgentProfiles,
authLoaded: true,
executorProfileId: "",
hasExecutors: false,
lastAgentProfileId: null,
userSettingsLoaded: true,
defaultAgentProfileId: null,
...overrides,
});
}
describe("decideAgentProfileAutopick — user settings deferral", () => {
it("defers agent auto-pick until user settings have loaded or settled", () => {
const cursor = makeProfile("cursor");
expect(decideAutopick([cursor], [cursor], { userSettingsLoaded: false })).toEqual({
kind: "defer",
reason: "user-settings-not-loaded",
});
expect(decideAutopick([cursor], [cursor])).toEqual({
kind: "pick",
source: "first",
id: cursor.id,
});
});
it("defers auto-pick while user settings load", () => {
const cursor = makeProfile("cursor");
expect(
decideAutopick([cursor], [cursor], {
userSettingsLoaded: false,
lastAgentProfileId: cursor.id,
}),
).toEqual({ kind: "defer", reason: "user-settings-not-loaded" });
});
it("never picks a disabled profile (last-used, workspace default, or first)", () => {
// The compat list is already filtered by useExecutorProfileCompat, so
// the disabled profile is absent — the decision must not resurrect it
// via the last-used or workspace-default candidates.
const enabled = makeProfile("enabled");
const disabled = { ...makeProfile("disabled"), enabled: false };
const agentProfiles = [disabled, enabled];
const compatibleAgentProfiles = [enabled];
expect(
decideAutopick(agentProfiles, compatibleAgentProfiles, { lastAgentProfileId: disabled.id }),
).toEqual({
kind: "pick",
source: "first",
id: enabled.id,
});
expect(
decideAutopick(agentProfiles, compatibleAgentProfiles, {
defaultAgentProfileId: disabled.id,
}),
).toEqual({
kind: "pick",
source: "first",
id: enabled.id,
});
expect(decideAutopick(agentProfiles, compatibleAgentProfiles)).toEqual({
kind: "pick",
source: "first",
id: enabled.id,
});
});
it("treats all-disabled as no compatible profiles", () => {
const disabled = { ...makeProfile("disabled"), enabled: false };
expect(decideAutopick([disabled], [])).toEqual({ kind: "skip", reason: "no-compatible" });
});
});
describe("useDefaultSelectionsEffect — executor-aware agent restoration", () => {
it("restores lastId when it is compatible with the current executor", async () => {
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const fs = makeDefaultSelFs();
const sel = makeSel({
agentProfiles: [claude, cursor],
compatibleAgentProfiles: [cursor],
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
await waitFor(() => expect(fs.setAgentProfileId).toHaveBeenCalledWith(cursor.id));
});
it("falls back to first compatible when lastId is incompatible with the current executor (regression for sprites + Claude)", async () => {
// Real-world scenario: user last opened the dialog under local executor
// and picked Claude. Now they're switching to Sprites where Claude has
// no creds wired. Before the fix this restored Claude → noCompatibleAgent
// flipped true via the "selected agent not in compatible list" branch →
// dialog showed "No compatible agent profiles" despite Cursor/Codex being
// wired up.
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const codex = makeProfile("codex");
const fs = makeDefaultSelFs();
const sel = makeSel({
agentProfiles: [claude, cursor, codex],
compatibleAgentProfiles: [cursor, codex],
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
await waitFor(() => expect(fs.setAgentProfileId).toHaveBeenCalledWith(cursor.id));
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith(claude.id);
});
it("prefers the workspace default over the first compatible when lastId is incompatible but defId is compatible", async () => {
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const codex = makeProfile("codex");
const fs = makeDefaultSelFs();
const sel = makeSel({
agentProfiles: [claude, cursor, codex],
compatibleAgentProfiles: [cursor, codex],
workspaceDefaults: { default_agent_profile_id: codex.id } as unknown as Workspace,
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
await waitFor(() => expect(fs.setAgentProfileId).toHaveBeenCalledWith(codex.id));
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith(cursor.id);
});
it("skips defId when it is incompatible and falls back to first compatible", async () => {
const claude = makeProfile("claude");
const gemini = makeProfile("gemini");
const cursor = makeProfile("cursor");
const fs = makeDefaultSelFs();
const sel = makeSel({
agentProfiles: [claude, gemini, cursor],
compatibleAgentProfiles: [cursor],
// Workspace default points at an incompatible agent — bypass it and
// fall through to the first compatible one rather than restoring a
// selection that would itself trip the empty-state gate.
workspaceDefaults: { default_agent_profile_id: gemini.id } as unknown as Workspace,
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
await waitFor(() => expect(fs.setAgentProfileId).toHaveBeenCalledWith(cursor.id));
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith(gemini.id);
});
it("does not pick anything when no profile is compatible with the executor", async () => {
// The dialog renders the "No compatible agent profiles" empty state in
// this branch — silently picking an incompatible profile would just trip
// the same gate via the "selected agent not in compatible list" check,
// producing a worse UX (selector populated, but submit still blocked).
const claude = makeProfile("claude");
const gemini = makeProfile("gemini");
const fs = makeDefaultSelFs();
const sel = makeSel({
agentProfiles: [claude, gemini],
compatibleAgentProfiles: [],
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
// Give any deferred setter a chance to fire so we'd catch an unwanted call.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fs.setAgentProfileId).not.toHaveBeenCalled();
});
});
describe("useDefaultSelectionsEffect — auth-spec load race", () => {
it("defers auto-pick while executorProfileId is empty even when authLoaded (regression for race that restored an incompatible lastId)", async () => {
// The other half of the executor-aware autopick race: `authLoaded` flips
// true before `executorProfileId` settles, because the executor
// auto-select runs as its own useEffect via a Promise.resolve().then()
// microtask. During that single render `useExecutorProfileCompat`
// short-circuits to `agentProfiles` (no filter) because
// `selectedExecutorProfile` is null, so without this gate we'd happily
// restore an incompatible lastId, then the executor lands milliseconds
// later, the filter narrows the list, and `noCompatibleAgent` flips true.
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const fs = makeDefaultSelFs({ executorProfileId: "" });
// Mirror the production shape: specs loaded, no executor picked yet, so
// useExecutorProfileCompat returns agentProfiles unfiltered.
const selBefore = makeSel({
agentProfiles: [claude, cursor],
compatibleAgentProfiles: [claude, cursor],
authLoaded: true,
executors: [{ id: "exec-1" } as unknown as StoreSelections["executors"][number]],
});
const { rerender } = renderHook(
({ sel, fs: fsArg }) => useDefaultSelectionsEffect(fsArg, true, sel, []),
{ initialProps: { sel: selBefore, fs } },
);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fs.setAgentProfileId).not.toHaveBeenCalled();
// Executor finally lands. Compat filter narrows the list. lastId=claude
// is no longer compatible; effect re-fires and picks the first compatible.
const fsAfter = makeDefaultSelFs({ executorProfileId: "profile-1" });
const selAfter = makeSel({
agentProfiles: [claude, cursor],
compatibleAgentProfiles: [cursor],
authLoaded: true,
executors: [{ id: "exec-1" } as unknown as StoreSelections["executors"][number]],
});
rerender({ sel: selAfter, fs: fsAfter });
await waitFor(() => expect(fsAfter.setAgentProfileId).toHaveBeenCalledWith(cursor.id));
expect(fsAfter.setAgentProfileId).not.toHaveBeenCalledWith(claude.id);
});
it("defers auto-pick until the remote-auth catalog has loaded (regression for race that restored an incompatible lastId)", async () => {
// Race the bundled effect hit in production: dialog opens, lastId=claude.
// While authLoaded=false, useExecutorProfileCompat short-circuits to
// `compatibleAgentProfiles = agentProfiles` (full list) → effect happily
// restores claude → specs land → filter kicks in → claude drops out of
// compatibleAgentProfiles → noCompatibleAgent flips true via the
// "selected-not-in-compatible" branch → "No compatible agent profiles"
// empty state shows even though Codex / OpenCode / Cursor are right there.
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const codex = makeProfile("codex");
const fs = makeDefaultSelFs();
// Phase 1: specs not yet loaded — compatibleAgentProfiles is the full
// list because the executor-compat hook returns agentProfiles when
// !authLoaded. The effect MUST NOT auto-pick yet.
const selBefore = makeSel({
agentProfiles: [claude, cursor, codex],
compatibleAgentProfiles: [claude, cursor, codex],
authLoaded: false,
});
const { rerender } = renderHook(({ sel }) => useDefaultSelectionsEffect(fs, true, sel, []), {
initialProps: { sel: selBefore },
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fs.setAgentProfileId).not.toHaveBeenCalled();
// Phase 2: specs land. compatibleAgentProfiles narrows to the executor's
// actual valid set. Now the effect re-fires and must skip the
// incompatible lastId, landing on the first compatible profile.
const selAfter = makeSel({
agentProfiles: [claude, cursor, codex],
compatibleAgentProfiles: [cursor, codex],
authLoaded: true,
});
rerender({ sel: selAfter });
await waitFor(() => expect(fs.setAgentProfileId).toHaveBeenCalledWith(cursor.id));
expect(fs.setAgentProfileId).not.toHaveBeenCalledWith(claude.id);
});
});
const SPRITES_PROFILE_ID = "profile-sprites";
const ALREADY_SET = { kind: "skip", reason: "already-set" } as const;
describe("decideAgentProfileAutopick — replacing an incompatible selection", () => {
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const codex = makeProfile("codex");
const selectedOnExecutor = {
agentProfileId: claude.id,
executorProfileId: SPRITES_PROFILE_ID,
hasExecutors: true,
};
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.2
it("replaces a selection that is absent from a non-empty compatible list", () => {
expect(decideAutopick([claude, cursor, codex], [cursor, codex], selectedOnExecutor)).toEqual({
kind: "pick",
source: "first",
id: cursor.id,
replaces: claude.id,
});
});
it("prefers the last-used profile, then the workspace default, for the replacement", () => {
expect(
decideAutopick([claude, cursor, codex], [cursor, codex], {
...selectedOnExecutor,
lastAgentProfileId: codex.id,
defaultAgentProfileId: cursor.id,
}),
).toEqual({ kind: "pick", source: "lastId", id: codex.id, replaces: claude.id });
expect(
decideAutopick([claude, cursor, codex], [cursor, codex], {
...selectedOnExecutor,
defaultAgentProfileId: codex.id,
}),
).toEqual({ kind: "pick", source: "defId", id: codex.id, replaces: claude.id });
});
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.3
it("keeps a selection that is still compatible", () => {
expect(decideAutopick([claude, cursor], [claude, cursor], selectedOnExecutor)).toEqual(
ALREADY_SET,
);
});
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.5
it("never replaces a workflow-locked selection", () => {
expect(
decideAutopick([claude, cursor], [cursor], {
...selectedOnExecutor,
workflowAgentProfileId: claude.id,
}),
).toEqual({ kind: "skip", reason: "workflow-locked" });
expect(
decideAutopick([claude, cursor], [cursor], { ...selectedOnExecutor, workflowHasAgent: true }),
).toEqual({ kind: "skip", reason: "workflow-has-agent" });
});
it("leaves the selection alone while the catalog, executor, or compatible list is not ready", () => {
expect(
decideAutopick([claude, cursor], [cursor], { ...selectedOnExecutor, authLoaded: false }),
).toEqual(ALREADY_SET);
expect(
decideAutopick([claude, cursor], [cursor], { ...selectedOnExecutor, executorProfileId: "" }),
).toEqual(ALREADY_SET);
expect(decideAutopick([claude, cursor], [], selectedOnExecutor)).toEqual(ALREADY_SET);
});
});
describe("useDefaultSelectionsEffect — executor switch after an agent was chosen", () => {
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.2
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.8
it("replaces an incompatible selection without touching the queued last-used preference", async () => {
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const codex = makeProfile("codex");
const fs = makeDefaultSelFs({
agentProfileId: claude.id,
executorProfileId: SPRITES_PROFILE_ID,
});
const sel = makeSel({
agentProfiles: [claude, cursor, codex],
compatibleAgentProfiles: [cursor, codex],
});
const queuedBefore = readQueuedTaskCreateLastUsedState();
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
await waitFor(() => expect(fs.setAgentProfileId).toHaveBeenCalledWith(cursor.id));
expect(readQueuedTaskCreateLastUsedState()).toEqual(queuedBefore);
});
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.3
it("keeps a selection that stays compatible with the new executor", async () => {
const claude = makeProfile("claude");
const cursor = makeProfile("cursor");
const fs = makeDefaultSelFs({
agentProfileId: cursor.id,
executorProfileId: SPRITES_PROFILE_ID,
});
const sel = makeSel({
agentProfiles: [claude, cursor],
compatibleAgentProfiles: [cursor],
});
renderHook(() => useDefaultSelectionsEffect(fs, true, sel, []));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(fs.setAgentProfileId).not.toHaveBeenCalled();
});
});