Files
kandev/apps/web/components/task-create-dialog-computed.test.ts
Jonathan Marbutt 97140168d8 fix(web): keep a usable agent selected when the executor changes (#3388)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-05 17:42:06 +00:00

345 lines
11 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { ExecutorProfile } from "@/lib/types/http";
import {
computeAgentCompatState,
computeHasRepositorySelection,
computeSelectedRepoCount,
filterCompatibleAgentProfiles,
} from "./task-create-dialog-computed";
import type { DialogFormState } from "@/components/task-create-dialog-types";
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
const URL_A = "github.com/a/b";
const URL_B = "github.com/c/d";
// Minimal fs stub for the two pure helpers below. Each function only reads
// `noRepository`, `useRemote`, `remoteRepos`, and `repositories`, so we cast a
// partial through `unknown` to avoid having to materialise the full
// DialogFormState surface in tests.
function fsStub(overrides: {
noRepository?: boolean;
useRemote?: boolean;
remoteRepos?: Array<{ url?: string; branch?: string }>;
repositories?: Array<{ repositoryId?: string; localPath?: string; branch?: string }>;
}): DialogFormState {
return {
noRepository: false,
useRemote: false,
remoteRepos: [],
repositories: [],
...overrides,
} as unknown as DialogFormState;
}
describe("computeHasRepositorySelection", () => {
it("returns true in no-repository mode", () => {
expect(computeHasRepositorySelection(fsStub({ noRepository: true }))).toBe(true);
});
it("returns true when any workspace/local row is set", () => {
expect(computeHasRepositorySelection(fsStub({ repositories: [{ repositoryId: "r-1" }] }))).toBe(
true,
);
expect(
computeHasRepositorySelection(fsStub({ repositories: [{ localPath: "/tmp/repo" }] })),
).toBe(true);
});
it("Remote mode: returns true when ANY row has a non-empty URL", () => {
// First row empty, second row populated — the previous implementation
// only inspected `remoteRepos[0]` and would have returned false here.
expect(
computeHasRepositorySelection(
fsStub({
useRemote: true,
remoteRepos: [{ url: "" }, { url: "github.com/acme/site" }],
}),
),
).toBe(true);
});
it("Remote mode: returns true when only the first row is populated", () => {
expect(
computeHasRepositorySelection(
fsStub({
useRemote: true,
remoteRepos: [{ url: "github.com/acme/site" }],
}),
),
).toBe(true);
});
it("Remote mode: returns false when every row is empty / whitespace", () => {
expect(
computeHasRepositorySelection(
fsStub({
useRemote: true,
remoteRepos: [{ url: "" }, { url: " " }],
}),
),
).toBe(false);
});
it("returns false on the empty form", () => {
expect(computeHasRepositorySelection(fsStub({}))).toBe(false);
});
it("Remote mode: ignores hidden workspace/local rows", () => {
// Toggle-back preserves the workspace/local rows when useRemote flips on
// — they're hidden from the UI and must not count toward the selection
// gate. Otherwise the submit button stays enabled in Remote mode even
// when the user hasn't filled in any URLs.
expect(
computeHasRepositorySelection(
fsStub({
useRemote: true,
repositories: [{ repositoryId: "r-1" }],
remoteRepos: [{ url: "" }],
}),
),
).toBe(false);
});
});
describe("computeSelectedRepoCount", () => {
it("counts workspace/local rows", () => {
expect(
computeSelectedRepoCount(
fsStub({ repositories: [{ repositoryId: "r-1" }, { localPath: "/x" }] }),
),
).toBe(2);
});
it("ignores empty workspace rows", () => {
// Half-filled rows (no repositoryId, no localPath) shouldn't count
// toward the multi-repo gate even though they exist in the array.
expect(computeSelectedRepoCount(fsStub({ repositories: [{ branch: "main" }] }))).toBe(0);
});
it("Remote mode: counts non-empty URL rows only", () => {
// Two remote URLs — without the local rows, this alone trips the gate.
expect(
computeSelectedRepoCount(
fsStub({
useRemote: true,
remoteRepos: [{ url: URL_A }, { url: URL_B }],
}),
),
).toBe(2);
});
it("Remote mode: ignores hidden workspace/local rows", () => {
// useRemote=true hides the workspace/local list — those rows must not
// contribute to the count even if they're still in the form state from
// a prior mode (toggle-back preserves them).
expect(
computeSelectedRepoCount(
fsStub({
useRemote: true,
repositories: [{ repositoryId: "r-1" }, { localPath: "/tmp/x" }],
remoteRepos: [{ url: URL_A }],
}),
),
).toBe(1);
});
it("Remote mode: returns 0 when only hidden workspace/local rows exist", () => {
expect(
computeSelectedRepoCount(
fsStub({
useRemote: true,
repositories: [{ repositoryId: "r-1" }],
remoteRepos: [],
}),
),
).toBe(0);
});
it("returns 0 in no-repository mode regardless of stale rows", () => {
expect(
computeSelectedRepoCount(
fsStub({
noRepository: true,
repositories: [{ repositoryId: "r-1" }],
remoteRepos: [{ url: URL_A }],
useRemote: true,
}),
),
).toBe(0);
});
it("ignores remote rows when useRemote is false", () => {
// The Remote toggle gates the count — switching modes shouldn't smuggle
// the URL rows into the local count.
expect(
computeSelectedRepoCount(
fsStub({
useRemote: false,
remoteRepos: [{ url: URL_A }, { url: URL_B }],
}),
),
).toBe(0);
});
it("trips the multi-repo gate (>1) on a 2-row Remote selection", () => {
// Multi-row Remote selection must trigger the same executor gating as
// a 2-workspace-repo task — the previous implementation only counted
// workspace/local rows, so this would have stayed at 0 even with two
// populated URLs.
const count = computeSelectedRepoCount(
fsStub({
useRemote: true,
remoteRepos: [{ url: "https://github.com/a/b" }, { url: "https://github.com/c/d" }],
}),
);
expect(count).toBe(2);
expect(count > 1).toBe(true);
});
});
describe("filterCompatibleAgentProfiles", () => {
it("removes disabled raw profiles before compatibility and autopick", () => {
const profiles = [
{
id: "enabled",
label: "Agent • Enabled",
agent_id: "a1",
agent_name: "mock-agent",
cli_passthrough: false,
enabled: true,
},
{
id: "disabled",
label: "Agent • Disabled",
agent_id: "a1",
agent_name: "mock-agent",
cli_passthrough: false,
enabled: false,
},
] satisfies AgentProfileOption[];
expect(
filterCompatibleAgentProfiles(
profiles,
{ executor_type: "local", config: {} } as unknown as ExecutorProfile,
true,
[],
).map((p) => p.id),
).toEqual(["enabled"]);
});
});
describe("computeAgentCompatState", () => {
const sprites = {
id: "exec-sprites",
executor_type: "sprites",
config: {},
} as unknown as ExecutorProfile;
function profile(id: string, enabled = true): AgentProfileOption {
return {
id,
label: id,
agent_id: `agent-${id}`,
agent_name: id,
cli_passthrough: false,
enabled,
};
}
const claude = profile("claude");
const cursor = profile("cursor");
function state(
overrides: Partial<Parameters<typeof computeAgentCompatState>[0]> = {},
): ReturnType<typeof computeAgentCompatState> {
return computeAgentCompatState({
selectedExecutorProfile: sprites,
compatibleAgentProfiles: [cursor],
selectedAgentProfileId: claude.id,
selectedAgentProfile: claude,
workflowAgentLocked: false,
dynamicRoutingEnabled: true,
...overrides,
});
}
it("is compatible while no executor profile is selected", () => {
expect(state({ selectedExecutorProfile: null, compatibleAgentProfiles: [] })).toBe(
"compatible",
);
});
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.4
it("is none-compatible when the compatible list is empty and nothing locks the agent", () => {
expect(state({ compatibleAgentProfiles: [] })).toBe("none-compatible");
expect(
state({
compatibleAgentProfiles: [],
selectedAgentProfileId: "",
selectedAgentProfile: null,
}),
).toBe("none-compatible");
});
it("is compatible while nothing is selected and a compatible profile exists", () => {
expect(state({ selectedAgentProfileId: "", selectedAgentProfile: null })).toBe("compatible");
});
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.6
it("is selected-incompatible, never none-compatible, when the selection is absent from a non-empty list", () => {
expect(state()).toBe("selected-incompatible");
});
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.3
it("is compatible when the selection is in the list", () => {
expect(state({ compatibleAgentProfiles: [claude, cursor] })).toBe("compatible");
});
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.5
it("keeps the workflow-locked state even when nothing else is compatible", () => {
expect(state({ compatibleAgentProfiles: [], workflowAgentLocked: true })).toBe(
"selected-incompatible",
);
});
it("treats a disabled locked profile as no compatible agent, not a credential problem", () => {
const disabled = profile("disabled", false);
expect(
state({
selectedAgentProfileId: disabled.id,
selectedAgentProfile: disabled,
workflowAgentLocked: true,
}),
).toBe("none-compatible");
});
it("keeps an unlocked disabled selection distinct when an alternative exists", () => {
const disabled = profile("disabled", false);
expect(
state({
selectedAgentProfileId: disabled.id,
selectedAgentProfile: disabled,
workflowAgentLocked: false,
compatibleAgentProfiles: [cursor],
}),
).toBe("selected-unavailable");
});
it("keeps an unlocked dynamic selection distinct when routing is disabled", () => {
const dynamic = { ...profile("dynamic"), kind: "dynamic" } satisfies AgentProfileOption;
expect(
state({
selectedAgentProfileId: dynamic.id,
selectedAgentProfile: dynamic,
workflowAgentLocked: false,
dynamicRoutingEnabled: false,
compatibleAgentProfiles: [cursor],
}),
).toBe("selected-unavailable");
});
it("treats an unknown selected id as a credential problem when a compatible profile exists", () => {
expect(state({ selectedAgentProfileId: "gone", selectedAgentProfile: null })).toBe(
"selected-incompatible",
);
});
});