// @vitest-environment jsdom import { act, useState, type ComponentProps } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Agent } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { agentsApi } from "@/api/agents"; import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker"; import type { ComposerRunSettings } from "./composer-run-settings"; import { getLastComposerEffort, rememberComposerEffort } from "@/lib/recent-composer-effort"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; const agent = { id: "a1", companyId: "company-1", name: "Clippy", role: "Engineering Lead", adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" }, } as unknown as Agent; const options = [{ id: "agent:a1", label: "Clippy" }]; const agents = new Map([[agent.id, agent]]); let container: HTMLDivElement | null = null; let root: ReturnType | null = null; globalThis.ResizeObserver = class { observe() {} disconnect() {} unobserve() {} }; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; async function click(label: string) { const button = document.querySelector(`button[aria-label="${label}"]`) ?? [...document.querySelectorAll('button[role="option"]')].find((item) => item.textContent?.trim().startsWith(label)); expect(button).toBeDefined(); flushSync(() => button!.click()); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } function render(onAssigneeChange: (value: string) => void, onSettingsChange: () => void, useCatalog = false, props: Partial> = {}, insideDialog = false) { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const picker = ; flushSync(() => root!.render(insideDialog ? New task{picker} : picker)); return queryClient; } beforeEach(() => localStorage.clear()); afterEach(() => { vi.restoreAllMocks(); flushSync(() => root?.unmount()); root = null; container?.remove(); container = null; }); describe("composer assignee picker", () => { it.each([false, true])("allows model and effort changes with a fixed conversation agent (mobile: %s)", async (mobile) => { const onAssigneeChange = vi.fn(); const onSettingsChange = vi.fn(); render(onAssigneeChange, onSettingsChange, false, { allowAssigneeChange: false, mobile, modelOptionsOverride: [{ id: "gpt-6-astra", label: "GPT-6 Astra" }] }); expect(container!.querySelector('[aria-label="Select assignee"]')).toBeNull(); await click("Select model and effort"); expect(document.querySelector('[aria-label="Choose assignee"]')).toBeNull(); expect(document.querySelector('input[aria-label="Effort"]')).not.toBeNull(); await click("Choose exact model"); await click("GPT-6 Astra"); expect(onSettingsChange).toHaveBeenLastCalledWith({ model: "gpt-6-astra", effort: "high", fast: false }); expect(onAssigneeChange).not.toHaveBeenCalled(); }); it.each([false, true])("keeps the trigger stable until the picker closes (mobile: %s)", async (mobile) => { function ControlledPicker() { const [settings, setSettings] = useState({ model: "gpt-6-sol", effort: "high", fast: false }); return setSettings(next! as typeof settings)} onAssigneeChange={vi.fn()} mobile={mobile} modelOptionsOverride={[{ id: "gpt-6-sol", label: "GPT-6 Sol" }, { id: "gpt-6-astra", label: "GPT-6 Astra" }]} />; } container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); const queryClient = new QueryClient(); flushSync(() => root!.render()); const label = () => container!.querySelector('[data-testid="task-chat-composer-model-label"]')!.textContent; expect(label()).toBe("GPT-6 Sol"); await click("Select model and effort"); expect(label()).toBe("Select model"); const range = document.querySelector('input[aria-label="Effort"]')!; flushSync(() => { Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(range, "1"); range.dispatchEvent(new Event("input", { bubbles: true })); }); expect(range.getAttribute("aria-valuetext")).toBe("Low"); expect(label()).toBe("Select model"); expect(container!.querySelector('[aria-label="Select model and effort"]')!.textContent).not.toContain("Low"); await click("Choose assignee"); expect(label()).toBe("Select model"); await click("Clippy"); expect(label()).toBe("GPT-6 Sol"); expect(container!.querySelector('[aria-label="Select model and effort"]')!.textContent).toContain("Low"); await click("Select model and effort"); await click("Choose exact model"); await click("GPT-6 Astra"); expect(label()).toBe("Select model"); if (mobile) await click("Close picker"); else { await act(async () => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); await new Promise((resolve) => setTimeout(resolve, 0)); }); } expect(label()).toBe("GPT-6 Astra"); expect(container!.querySelector('[aria-label="Select model and effort"]')!.textContent).toContain("Low"); }); it.each([ { mobile: false, view: "settings" as const }, { mobile: false, view: "models" as const }, { mobile: false, view: "agents" as const }, { mobile: true, view: "settings" as const }, { mobile: true, view: "models" as const }, { mobile: true, view: "agents" as const }, ])("allows wheel and touch scrolling in a nested $view picker (mobile: $mobile)", async ({ mobile, view }) => { render(vi.fn(), vi.fn(), false, { mobile, initialOpen: true, initialView: view }, true); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); const list = document.querySelector(view === "settings" ? '[data-testid="composer-run-settings-view"]' : `[role="listbox"][aria-label="${view === "models" ? "Models" : "Assignees"}"]`)!; expect(list).not.toBeNull(); list.style.overflowY = "auto"; Object.defineProperties(list, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { configurable: true, value: 200 }, }); const option = list.querySelector("button")!; const wheel = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 100 }); option.dispatchEvent(wheel); expect(wheel.defaultPrevented).toBe(false); const touch = (type: string, y: number) => { const event = new Event(type, { bubbles: true, cancelable: true }); Object.defineProperties(event, { touches: { value: [{ clientX: 100, clientY: y }] }, changedTouches: { value: [{ clientX: 100, clientY: y }] }, }); option.dispatchEvent(event); return event; }; touch("touchstart", 150); expect(touch("touchmove", 100).defaultPrevented).toBe(false); const outsideWheel = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 100 }); document.body.dispatchEvent(outsideWheel); expect(outsideWheel.defaultPrevented).toBe(true); }); it.each([ { adapterType: "claude_local", config: {}, label: "Default" }, { adapterType: "claude_local", config: { model: "claude-opus-5" }, label: "Claude Opus 5" }, { adapterType: "claude_local", config: { env: { ANTHROPIC_MODEL: "claude-opus-5" } }, label: "Claude Opus 5" }, { adapterType: "codex_local", config: {}, label: "Default" }, ])("labels the $adapterType default honestly", ({ adapterType, config, label }) => { const defaultAgent = { ...agent, adapterType, adapterConfig: config } as Agent; render(vi.fn(), vi.fn(), false, { settings: null, agents: new Map([[agent.id, defaultAgent]]), modelOptionsOverride: [{ id: "claude-opus-5", label: "Claude Opus 5" }] }); expect(container!.querySelector('[data-testid="task-chat-composer-model-label"]')?.textContent).toBe(label); }); it("does not offer effort levels for an unknown Claude default", async () => { const defaultAgent = { ...agent, adapterType: "claude_local", adapterConfig: {} } as Agent; render(vi.fn(), vi.fn(), false, { settings: null, agents: new Map([[agent.id, defaultAgent]]), modelOptionsOverride: [{ id: "claude-opus-5", label: "Claude Opus 5" }] }); await click("Select model and effort"); expect(document.querySelector('[aria-label="Effort"]')).toBeNull(); }); it.each([ { remembered: "high", settings: null, overrides: null, restored: "high" }, { remembered: "off", settings: null, overrides: null, restored: null }, { remembered: "high", settings: { model: null, effort: "low", fast: false }, overrides: null, restored: null }, { remembered: "high", settings: null, overrides: { adapterConfig: { modelReasoningEffort: "low" } }, restored: null }, ])("restores compatible history ($remembered) without replacing explicit settings", ({ remembered, settings, overrides, restored }) => { rememberComposerEffort("company-1", remembered); const onSettingsChange = vi.fn(); render(vi.fn(), onSettingsChange, false, { settings, overrides }); if (restored) expect(onSettingsChange).toHaveBeenCalledWith({ model: null, effort: restored, fast: false }); else expect(onSettingsChange).not.toHaveBeenCalled(); }); it("records effort changes and preserves effort when changing to a compatible model", async () => { const onSettingsChange = vi.fn(); render(vi.fn(), onSettingsChange, false, { modelOptionsOverride: [{ id: "gpt-6-astra", label: "GPT-6 Astra" }] }); await click("Select model and effort"); const range = document.querySelector('input[aria-label="Effort"]')!; flushSync(() => { Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(range, "1"); range.dispatchEvent(new Event("change", { bubbles: true })); range.dispatchEvent(new Event("input", { bubbles: true })); }); expect(getLastComposerEffort("company-1")).toBe("low"); await click("Choose exact model"); await click("GPT-6 Astra"); expect(onSettingsChange).toHaveBeenLastCalledWith({ model: "gpt-6-astra", effort: "high", fast: false }); await click("Reset to agent default"); expect(getLastComposerEffort("company-1")).toBeNull(); }); it("lets the assignee and model use the available composer width", () => { render(vi.fn(), vi.fn()); const trigger = container!.querySelector('[data-testid="task-chat-composer-assignee"]'); const assignee = trigger!.querySelector('[data-testid="task-chat-composer-assignee-label"]'); const model = container!.querySelector('[data-testid="task-chat-composer-model-label"]'); expect(trigger?.className).toContain("max-w-full"); expect(trigger?.className).not.toContain("max-w-64"); expect(assignee?.className).toContain("min-w-0"); expect(assignee?.className).not.toContain("max-w-24"); expect(model?.className).toContain("min-w-0"); expect(assignee?.className).toContain("truncate"); expect(model?.className).toContain("truncate"); }); it("finds assignees by their displayed role and harness", async () => { render(vi.fn(), vi.fn()); await click("Select assignee"); const input = document.querySelector('input[aria-label="Search assignees"]'); expect(input).not.toBeNull(); const setValue = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; for (const query of ["Engineering Lead", "Codex"]) { flushSync(() => { setValue.call(input, query); input!.dispatchEvent(new Event("input", { bubbles: true })); }); expect([...document.querySelectorAll('button[role="option"]')] .some((option) => option.textContent?.includes("Clippy"))).toBe(true); } }); it("offers the Codex CLI catalog instead of unrelated OpenAI API models", async () => { vi.spyOn(agentsApi, "adapterModels").mockResolvedValueOnce([ { id: "gpt-6-sol", label: "GPT-6 Sol" }, { id: "gpt-5.5", label: "GPT-5.5" }, ]); render(vi.fn(), vi.fn(), true); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); await click("Select model and effort"); await click("Choose exact model"); const options = [...document.querySelectorAll('button[role="option"]')] .map((item) => item.textContent ?? ""); expect(options.some((item) => item.includes("gpt-5.5"))).toBe(true); expect(options.some((item) => item.includes("gpt-6-sol"))).toBe(true); expect(options.some((item) => item.includes("gpt-image"))).toBe(false); expect(options.some((item) => item.includes("text-embedding"))).toBe(false); expect(document.body.textContent).not.toContain("Loading models…"); }); it("shows an instance-declared Codex model list instead of bundled alternatives", async () => { const loadModels = vi.spyOn(agentsApi, "adapterModels").mockResolvedValueOnce([ { id: "private-codex", label: "Private Codex" }, ]); render(vi.fn(), vi.fn(), true); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(loadModels).toHaveBeenCalledWith("company-1", "codex_local", { environmentId: null, provider: undefined, }); await click("Select model and effort"); await click("Choose exact model"); const choices = [...document.querySelectorAll('button[role="option"]')] .map((item) => item.textContent ?? ""); expect(choices.some((item) => item.includes("Private Codex"))).toBe(true); expect(choices.some((item) => item.includes("GPT-6 Sol"))).toBe(false); }); it.each(["copilot", "antigravity"])("keeps %s discovery passive during reconnect reconciliation and refreshes on opening", async acpxAgent => { const copilot = { ...agent, adapterType: "paperclip_runner", defaultEnvironmentId: "daytona-1", adapterConfig: { provider: "acpx", acpxAgent, model: "gpt-5.6-luna" } } as Agent; const loadModels = vi.spyOn(agentsApi, "adapterModels") .mockResolvedValueOnce([{ id: "gpt-5.6-luna", label: "Copilot Luna" }]) .mockResolvedValue([{ id: "gpt-5.6-luna", label: "Updated Copilot Luna" }]); const client = render(vi.fn(), vi.fn(), true, { agents: new Map([[agent.id, copilot]]), settings: null }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(loadModels).not.toHaveBeenCalled(); await act(async () => { await client.invalidateQueries({ type: "active" }, { cancelRefetch: false }); }); expect(loadModels).not.toHaveBeenCalled(); await click("Select model and effort"); expect(loadModels).toHaveBeenCalledTimes(1); await act(async () => { await client.invalidateQueries({ type: "active" }, { cancelRefetch: false }); }); expect(loadModels).toHaveBeenCalledTimes(1); await act(async () => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); await new Promise((resolve) => setTimeout(resolve, 0)); }); await click("Select model and effort"); expect(loadModels).toHaveBeenCalledTimes(2); await click("Choose exact model"); expect(document.body.textContent).toContain("Updated Copilot Luna"); }); it.each([ { name: "Copilot", acpxAgent: "copilot", provider: "github", method: "api_key", model: "gpt-5.6-luna", label: "Copilot Luna" }, { name: "Antigravity", acpxAgent: "antigravity", provider: "google", method: "subscription", model: "gemini-exact", label: "Personal Gemini" }, ] as const)("discovers $name task models with the assignee connection and environment", async ({ acpxAgent, provider, method, model, label }) => { const binding = { provider, method, mode: "shared", connectionId: "11111111-1111-4111-8111-111111111111", grantId: "33333333-3333-4333-8333-333333333333" } as const; const selectedAgent = { ...agent, adapterType: "paperclip_runner", defaultEnvironmentId: "daytona-1", adapterConfig: { provider: "acpx", acpxAgent, model }, runtimeConfig: { aiConnection: binding } } as Agent; const loadModels = vi.spyOn(agentsApi, "adapterModels").mockResolvedValueOnce([{ id: model, label }]); render(vi.fn(), vi.fn(), true, { agents: new Map([[agent.id, selectedAgent]]), settings: null }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(loadModels).not.toHaveBeenCalled(); await click("Select model and effort"); expect(loadModels).toHaveBeenCalledWith("company-1", "paperclip_runner", { environmentId: "daytona-1", provider: "acpx", poolId: undefined, acpxAgent, agentId: agent.id, aiConnection: binding, }); await click("Choose exact model"); expect(document.body.textContent).toContain(label); expect(document.body.textContent).not.toContain("Claude Sonnet"); }); it.each(["subscription", "api_key"] as const)("allows an exact Antigravity model absent from its %s catalog", async method => { const selectedAgent = { ...agent, adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "antigravity", model: "gemini-default" }, runtimeConfig: { aiConnection: { provider: "google", method, mode: "responsible_user" } } } as Agent; vi.spyOn(agentsApi, "adapterModels").mockResolvedValue([{ id: "gemini-listed", label: "Listed native model" }]); const onSettingsChange = vi.fn(); render(vi.fn(), onSettingsChange, true, { agents: new Map([[agent.id, selectedAgent]]), settings: null, initialOpen: true, initialView: "models", initialModelSearch: "gemini-custom-exact" }); await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); const manual = [...document.querySelectorAll("button")].find(button => button.textContent?.includes("Use exact ID")); expect(manual?.disabled).toBe(false); await act(async () => manual!.click()); expect(onSettingsChange).toHaveBeenLastCalledWith(expect.objectContaining({ model: "gemini-custom-exact" })); }); it("preserves settings when the selected assignee is chosen again", async () => { const onAssigneeChange = vi.fn(); const onSettingsChange = vi.fn(); render(onAssigneeChange, onSettingsChange); await click("Select assignee"); await click("Clippy"); expect(onAssigneeChange).not.toHaveBeenCalled(); expect(onSettingsChange).not.toHaveBeenCalled(); expect(document.querySelector('[role="listbox"]')).toBeNull(); }); it("offers No assignee and clears settings when selected", async () => { const onAssigneeChange = vi.fn(); const onSettingsChange = vi.fn(); render(onAssigneeChange, onSettingsChange); await click("Select assignee"); await click("No assignee"); expect(onAssigneeChange).toHaveBeenCalledWith(""); expect(onSettingsChange).toHaveBeenCalledWith(null); expect(document.querySelector('[role="listbox"]')).toBeNull(); }); it.each(["", "user:me"])("hides model and effort for %s and opens assignees in one click", async (assigneeValue) => { const loadModels = vi.spyOn(agentsApi, "adapterModels"); render(vi.fn(), vi.fn(), true, { assigneeValue, currentAssigneeValue: assigneeValue, options: [...options, { id: "user:me", label: "Me" }], }); expect(container!.querySelector('[data-testid="task-chat-composer-model-label"]')).toBeNull(); expect(container!.querySelector('[aria-label="Select model and effort"]')).toBeNull(); expect(container!.textContent).not.toContain("Harness default"); expect(container!.textContent).not.toContain("High"); await click("Select assignee"); expect(document.querySelector('[aria-label="Search assignees"]')).not.toBeNull(); expect(document.querySelector('[aria-label="Effort"]')).toBeNull(); expect(document.querySelector('[aria-label="Choose exact model"]')).toBeNull(); expect(document.body.textContent).not.toContain("Choose an agent"); expect(loadModels).not.toHaveBeenCalled(); }); it.each([false, true])("hides unsupported Muse settings without loading a catalog (mobile: %s)", async (mobile) => { const loadModels = vi.spyOn(agentsApi, "adapterModels").mockResolvedValue([]); const onSettingsChange = vi.fn(); const muse = { ...agent, adapterType: "paperclip_runner", adapterConfig: { provider: "muse", model: "old-model" } } as Agent; rememberComposerEffort("company-1", "high"); render(vi.fn(), onSettingsChange, true, { mobile, settings: null, agents: new Map([[muse.id, muse]]) }); expect(container!.querySelector('[aria-label="Select model and effort"]')).toBeNull(); expect(container!.querySelector('[data-testid="task-chat-composer-model-label"]')).toBeNull(); await click("Select assignee"); expect(document.querySelector('[aria-label="Search assignees"]')).not.toBeNull(); expect(document.querySelector('[aria-label="Choose exact model"]')).toBeNull(); expect(document.querySelector('[aria-label="Effort"]')).toBeNull(); expect(loadModels).not.toHaveBeenCalled(); expect(onSettingsChange).not.toHaveBeenCalled(); }); it.each([false, true])("removes an open model menu when the same agent switches to Muse (mobile: %s)", async (mobile) => { const onSettingsChange = vi.fn(); function ControlledPicker() { const [muse, setMuse] = useState(false); const [settings, setSettings] = useState({ model: "gpt-6-sol", effort: "high", fast: true }); const selectedAgent = muse ? { ...agent, adapterType: "paperclip_runner", adapterConfig: { provider: "muse" } } as Agent : agent; return <> { onSettingsChange(next); setSettings(next); }} onAssigneeChange={vi.fn()} mobile={mobile} modelOptionsOverride={[{ id: "gpt-6-sol", label: "GPT-6 Sol" }]} /> ; } container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); flushSync(() => root!.render()); await click("Select model and effort"); await click("Choose exact model"); expect(document.querySelector('[aria-label="Search or paste a model ID"]')).not.toBeNull(); await click("Switch to Muse"); expect(container!.querySelector('[aria-label="Select model and effort"]')).toBeNull(); expect(document.querySelector('[aria-label="Search or paste a model ID"]')).toBeNull(); expect(document.querySelector('[aria-label="Choose exact model"]')).toBeNull(); expect(document.querySelector('[aria-label="Effort"]')).toBeNull(); expect(document.querySelector('[data-testid="model-unavailable"]')?.textContent).toContain("Muse chooses its model and effort"); expect(onSettingsChange).toHaveBeenCalledOnce(); expect(onSettingsChange).toHaveBeenCalledWith(null); }); it("opens the assignee list directly on mobile", async () => { render(vi.fn(), vi.fn(), false, { mobile: true }); await click("Select assignee"); expect(document.querySelector('[data-testid="composer-mobile-dialog"]')).not.toBeNull(); expect(document.querySelector('[aria-label="Search assignees"]')).not.toBeNull(); expect(document.querySelector('[aria-label="Choose exact model"]')).toBeNull(); }); });