import { act, renderHook } from "@testing-library/react"; import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { UtilityGenerationResult } from "@/hooks/use-utility-agent-generator"; import type { FileAttachment } from "./chat/file-attachment"; const { mockCreateTask, mockReplaceTaskUrl, mockSetActiveTask, mockSetActiveSession, mockHasPendingAttachmentUploads, } = vi.hoisted(() => ({ mockCreateTask: vi.fn(), mockReplaceTaskUrl: vi.fn(), mockSetActiveTask: vi.fn(), mockSetActiveSession: vi.fn(), mockHasPendingAttachmentUploads: vi.fn(), })); const mockToast = vi.fn(); const mockEnhancePrompt = vi.fn(); vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: mockToast }), })); vi.mock("@/hooks/use-utility-agent-generator", () => ({ useUtilityAgentGenerator: () => ({ enhancePrompt: mockEnhancePrompt, isEnhancingPrompt: false, }), })); vi.mock("@/lib/api/domains/kanban-api", () => ({ createTask: mockCreateTask, })); vi.mock("@/lib/links", () => ({ replaceTaskUrl: mockReplaceTaskUrl, })); vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: unknown) => unknown) => selector({ setActiveTask: mockSetActiveTask, setActiveSession: mockSetActiveSession, }), })); vi.mock("@/components/task-create-dialog-helpers", () => ({ buildRepositoriesPayload: vi.fn(() => []), hasPendingAttachmentUploads: (...args: Parameters) => mockHasPendingAttachmentUploads(...args), toMessageAttachments: vi.fn(() => []), })); import { useSubtaskPromptZone, useSubtaskSubmit } from "./use-subtask-submit"; const GENERATED_RESULT = { content: "improved prompt", callId: "call-123", durationMs: 1_200, } satisfies UtilityGenerationResult; const ORIGINAL_PROMPT = "original prompt"; const CREATED_TASK_ID = "created-task"; const CREATED_SESSION_ID = "created-session"; function useSubtaskPromptHarness(initialPrompt = ORIGINAL_PROMPT) { const [promptValue, setPromptValue] = useState(initialPrompt); const [hasPrompt, setHasPrompt] = useState(Boolean(initialPrompt.trim())); const promptZone = useSubtaskPromptZone({ parentTaskId: "task-1", taskTitle: "Parent task", inputDisabled: false, contextValue: "blank", initialPrompt: null, promptValue, setPromptValue, setHasPrompt, }); return { ...promptZone, promptValue, setPromptValue, hasPrompt, }; } describe("useSubtaskPromptZone", () => { beforeEach(() => { vi.clearAllMocks(); }); it("applies an enhanced prompt immediately when the source text is unchanged", async () => { mockEnhancePrompt.mockImplementation( async (_source: string, deliver: (result: UtilityGenerationResult) => Promise) => deliver(GENERATED_RESULT), ); const { result } = renderHook(() => useSubtaskPromptHarness()); act(() => { result.current.promptRef.current = { value: ORIGINAL_PROMPT } as HTMLTextAreaElement; }); await act(async () => { await result.current.handleEnhancePrompt(); }); expect(result.current.promptValue).toBe("improved prompt"); expect(result.current.hasPrompt).toBe(true); expect(result.current.pendingResult).toBeNull(); expect(mockToast).toHaveBeenCalledWith( expect.objectContaining({ description: "Enhanced prompt applied.", variant: "success" }), ); }); it("retains the enhanced prompt when the user changes the source text before delivery", async () => { let deliverResult: ((result: UtilityGenerationResult) => Promise) | undefined; mockEnhancePrompt.mockImplementation( async (_source: string, deliver: (result: UtilityGenerationResult) => Promise) => { deliverResult = deliver; }, ); const { result } = renderHook(() => useSubtaskPromptHarness()); act(() => { result.current.promptRef.current = { value: ORIGINAL_PROMPT } as HTMLTextAreaElement; }); await act(async () => { await result.current.handleEnhancePrompt(); }); act(() => { result.current.setPromptValue("edited prompt"); }); await act(async () => { await deliverResult?.(GENERATED_RESULT); }); expect(result.current.promptValue).toBe("edited prompt"); expect(result.current.pendingResult).toEqual(GENERATED_RESULT); act(() => { result.current.applyPending(); }); expect(result.current.promptValue).toBe("improved prompt"); expect(result.current.pendingResult).toBeNull(); }); it("retains the enhanced prompt when the input target is unavailable", async () => { mockEnhancePrompt.mockImplementation( async (_source: string, deliver: (result: UtilityGenerationResult) => Promise) => deliver(GENERATED_RESULT), ); const { result } = renderHook(() => useSubtaskPromptHarness()); act(() => { result.current.promptRef.current = null; }); await act(async () => { await result.current.handleEnhancePrompt(); }); expect(result.current.promptValue).toBe(ORIGINAL_PROMPT); expect(result.current.pendingResult).toEqual(GENERATED_RESULT); }); it("preserves exact source text and retains the result after a whitespace-only edit", async () => { let deliverResult: ((result: UtilityGenerationResult) => Promise) | undefined; mockEnhancePrompt.mockImplementation( async (_source: string, deliver: (result: UtilityGenerationResult) => Promise) => { deliverResult = deliver; }, ); const initialPrompt = " original prompt "; const editedPrompt = " original prompt "; const { result } = renderHook(() => useSubtaskPromptHarness(initialPrompt)); act(() => { result.current.promptRef.current = { value: initialPrompt } as HTMLTextAreaElement; }); await act(async () => { await result.current.handleEnhancePrompt(); }); expect(mockEnhancePrompt).toHaveBeenCalledWith(initialPrompt, expect.any(Function)); act(() => { result.current.setPromptValue(editedPrompt); }); await act(async () => { await deliverResult?.(GENERATED_RESULT); }); expect(result.current.promptValue).toBe(editedPrompt); expect(result.current.pendingResult).toEqual(GENERATED_RESULT); }); it("resolves submission prompts from the same controlled prompt state", () => { const { result } = renderHook(() => useSubtaskPromptHarness()); act(() => { result.current.setPromptValue("next prompt"); }); expect(result.current.resolvePrompt()).toBe("next prompt"); }); }); function makeSubmitOptions( overrides: Partial[0]> = {}, ): Parameters[0] { return { fs: { useRemote: false, remoteRepos: [], prInfoByUrl: {}, repositories: [], discoveredRepositories: [], agentProfileId: "", executorProfileId: "", } as unknown as Parameters[0]["fs"], parentTaskId: "parent-task", defaultProfileId: "default-profile", workspaceId: "workspace-1", workflowId: "workflow-1", availableRepositories: [], attachments: [], resolvePrompt: () => "do the work", title: "Manual title", setIsCreating: vi.fn(), onClose: vi.fn(), workspaceMode: "new_workspace", ...overrides, }; } // eslint-disable-next-line max-lines-per-function -- subtask submission cases share one hook harness. describe("useSubtaskSubmit", () => { beforeEach(() => { vi.clearAllMocks(); mockHasPendingAttachmentUploads.mockReturnValue(false); mockCreateTask.mockResolvedValue({ id: CREATED_TASK_ID, session_id: CREATED_SESSION_ID }); }); it("sends the auto-title contract without a title", async () => { const onClose = vi.fn(); const opts = makeSubmitOptions({ autoTitle: true, onClose }); const { result } = renderHook(() => useSubtaskSubmit(opts)); await act(async () => { await result.current.handleSubmit({ preventDefault: vi.fn() } as never); }); expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ auto_title: true, description: "do the work" }), ); expect(mockCreateTask.mock.calls[0][0]).not.toHaveProperty("title"); expect(opts.onClose).toHaveBeenCalledOnce(); expect(mockSetActiveTask).toHaveBeenCalledWith(CREATED_TASK_ID); expect(mockSetActiveSession).toHaveBeenCalledWith(CREATED_TASK_ID, CREATED_SESSION_ID); expect(mockReplaceTaskUrl).toHaveBeenCalledWith(CREATED_TASK_ID); expect(onClose.mock.invocationCallOrder[0]).toBeLessThan( mockReplaceTaskUrl.mock.invocationCallOrder[0], ); }); it("sends the autopilot creation flag for a subtask", async () => { const opts = makeSubmitOptions({ autopilot: true }); const { result } = renderHook(() => useSubtaskSubmit(opts)); await act(async () => { await result.current.handleSubmit({ preventDefault: vi.fn() } as never); }); expect(mockCreateTask).toHaveBeenCalledWith(expect.objectContaining({ autopilot: true })); }); it("passes fresh-branch metadata when a local executor uses a policy row", async () => { const buildRepositoriesPayload = await import("@/components/task-create-dialog-helpers"); const opts = makeSubmitOptions({ isLocalExecutor: true, fs: { useRemote: false, remoteRepos: [], prInfoByUrl: {}, repositories: [ { key: "row-1", repositoryId: "repo-1", branch: "main", branchPolicyId: "policy-1", }, ], discoveredRepositories: [], agentProfileId: "", executorProfileId: "local-profile", freshBranchEnabled: true, } as unknown as Parameters[0]["fs"], }); const { result } = renderHook(() => useSubtaskSubmit(opts)); await act(async () => { await result.current.handleSubmit({ preventDefault: vi.fn() } as never); }); expect(buildRepositoriesPayload.buildRepositoriesPayload).toHaveBeenCalledWith( expect.objectContaining({ isLocalExecutor: true, freshBranch: { confirmDiscard: false, consentedDirtyFiles: [] }, }), ); }); it("requires a title when auto-title mode is omitted", async () => { const opts = makeSubmitOptions({ title: "" }); const { result } = renderHook(() => useSubtaskSubmit(opts)); await act(async () => { await result.current.handleSubmit({ preventDefault: vi.fn() } as never); }); expect(mockCreateTask).not.toHaveBeenCalled(); }); it("preserves the legacy title payload", async () => { const opts = makeSubmitOptions({ title: "Manual title" }); const { result } = renderHook(() => useSubtaskSubmit(opts)); await act(async () => { await result.current.handleSubmit({ preventDefault: vi.fn() } as never); }); expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ title: "Manual title", description: "do the work" }), ); expect(mockCreateTask.mock.calls[0][0]).not.toHaveProperty("auto_title"); }); it("rejects an empty prompt before creating", async () => { const opts = makeSubmitOptions({ resolvePrompt: () => " " }); const { result } = renderHook(() => useSubtaskSubmit(opts)); await act(async () => { await result.current.handleSubmit({ preventDefault: vi.fn() } as never); }); expect(mockCreateTask).not.toHaveBeenCalled(); }); it("does not create while an attachment upload is pending", async () => { mockHasPendingAttachmentUploads.mockReturnValue(true); const pendingAttachment = { id: "pending-attachment", file: new File(["pending"], "pending.txt"), data: "", mimeType: "text/plain", fileName: "pending.txt", size: 7, isImage: false, deliveryMode: "path", } satisfies FileAttachment; const opts = makeSubmitOptions({ attachments: [pendingAttachment] }); const { result } = renderHook(() => useSubtaskSubmit(opts)); await act(async () => { await result.current.handleSubmit({ preventDefault: vi.fn() } as never); }); expect(mockHasPendingAttachmentUploads).toHaveBeenCalledWith([pendingAttachment]); expect(mockCreateTask).not.toHaveBeenCalled(); expect(opts.setIsCreating).not.toHaveBeenCalled(); }); it("cleans up the creating state after a request failure", async () => { const opts = makeSubmitOptions(); const error = new Error("request failed"); mockCreateTask.mockRejectedValueOnce(error); const { result } = renderHook(() => useSubtaskSubmit(opts)); await act(async () => { await result.current.handleSubmit({ preventDefault: vi.fn() } as never); }); expect(opts.setIsCreating).toHaveBeenNthCalledWith(1, true); expect(opts.setIsCreating).toHaveBeenLastCalledWith(false); expect(opts.onClose).not.toHaveBeenCalled(); expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" })); }); });