Files
kandev/apps/web/components/task/chat/chat-input-area.test.tsx
T
2026-09-14 19:19:09 +01:00

634 lines
19 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { planCommentRecovery } from "@/lib/plan-comment-recovery";
import { QueueAdmissionError, QueueFullError } from "@/lib/api/domains/queue-api";
const toastMock = vi.fn();
const handleSendMessageMock = vi.fn();
const useKeyboardShortcutMock = vi.hoisted(() => vi.fn());
const MESSAGE_NOT_SENT_TITLE = "Message not sent";
let mockProceedStepName: string | null = null;
let mockQueuePopulated = false;
const mockState = {
userSettings: { keyboardShortcuts: {}, chatSubmitKey: "enter" },
quickChat: { sessions: [] },
kanban: { workflowId: null, tasks: [], steps: [] },
kanbanMulti: { snapshots: {} },
workflows: { items: [] },
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: typeof mockState) => unknown) => selector(mockState),
useAppStoreApi: () => ({ getState: () => mockState }),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: toastMock }),
}));
vi.mock("@/components/github/pr-status-chip", () => ({
PRStatusChip: () => null,
}));
vi.mock("@/components/gitlab/mr-status-chip", () => ({
MRStatusChip: () => null,
}));
vi.mock("@/components/azure-devops/azure-devops-task-pull-request-chip", () => ({
AzureDevOpsTaskPullRequestChip: () => null,
}));
vi.mock("@/components/integrations/registered-change-request-status", () => ({
RegisteredChangeRequestStatus: () => null,
}));
vi.mock("@/components/task/share/share-button", () => ({
ShareButton: () => null,
shareableSessionStateClient: () => false,
}));
vi.mock("@/components/task/chat/chat-input-container", () => ({
ChatInputContainer: () => <textarea aria-label="Draft" />,
}));
vi.mock("@/components/task/chat/queued-ghost-list", () => ({
QueueAffordance: ({
children,
renderStatusBar,
}: {
children: ReactNode;
renderStatusBar?: (queueChip: ReactNode) => ReactNode;
}) =>
mockQueuePopulated ? (
<>
{renderStatusBar?.(null)}
<aside>Queued messages</aside>
{null}
{children}
</>
) : (
<>
{renderStatusBar?.(null)}
{children}
</>
),
}));
vi.mock("./composer-agent-start-hint", () => ({
ComposerAgentStartHint: () => null,
}));
vi.mock("./dynamic-route-recovery", () => ({
DynamicRouteRecovery: () => null,
}));
vi.mock("./chat-status-bar", () => ({
ComposerCIStatus: () => null,
ChatStatusBar: (props: {
nextStepName: string | null;
isAgentBusy: boolean;
hasPendingClarification: boolean;
}) =>
props.nextStepName && !props.isAgentBusy && !props.hasPendingClarification ? (
<button type="button" data-testid="proceed-next-step" />
) : null,
resolveStatusRowTaskId: (sessionTaskId: string | null, statusTaskId: string | null) =>
sessionTaskId ?? statusTaskId,
}));
vi.mock("@/components/task/chat/todo-indicator", () => ({
TodoIndicator: () => null,
}));
vi.mock("./pr-archive-banners", () => ({
PRMergedBanner: () => null,
PRClosedBanner: () => null,
}));
vi.mock("@/hooks/use-keyboard-shortcut", () => ({
useKeyboardShortcut: useKeyboardShortcutMock,
}));
vi.mock("@/hooks/use-message-handler", () => ({
buildTaskMentionsContext: vi.fn(),
useMessageHandler: () => ({ handleSendMessage: handleSendMessageMock }),
}));
vi.mock("@/hooks/domains/kanban/use-plan-actions", () => ({
usePlanActions: () => ({
implementPlanHandler: vi.fn(),
proceedStepName: mockProceedStepName,
proceed: vi.fn(),
isMoving: false,
}),
}));
vi.mock("@/hooks/domains/session/use-executor-environment-availability", () => ({
useExecutorEnvironmentAvailability: () => ({
unavailable: false,
status: null,
}),
}));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ send: vi.fn() }),
}));
import {
ChatInputArea,
resolveInputPlaceholder,
useChatPanelHandlers,
useSubmitHandler,
} from "./chat-input-area";
beforeEach(() => {
handleSendMessageMock.mockReset();
handleSendMessageMock.mockResolvedValue(undefined);
useKeyboardShortcutMock.mockReset();
});
afterEach(() => {
cleanup();
mockProceedStepName = null;
mockQueuePopulated = false;
vi.restoreAllMocks();
vi.clearAllMocks();
});
function panelState(overrides = {}) {
return {
resolvedSessionId: "session-1",
taskId: "task-1",
sessionModel: null,
activeModel: null,
isAgentBusy: false,
activeDocument: null,
planComments: [],
pendingPRFeedback: [],
walkthroughComments: [],
messageComments: [],
contextFiles: [],
prompts: [],
markCommentsSent: vi.fn(),
clearSessionPlanComments: vi.fn(),
handleClearPRFeedback: vi.fn(),
handleClearWalkthroughComments: vi.fn(),
clearEphemeral: vi.fn(),
addContextFile: vi.fn(),
planModeEnabled: false,
planCommentMigration: {
status: "complete",
pendingCount: 0,
failure: null,
needsAttention: false,
isReady: true,
isBlocking: false,
retry: vi.fn(),
},
...overrides,
} as never;
}
function composerPanelState(overrides = {}) {
return {
...panelState(),
session: { state: "WAITING_FOR_INPUT", pending_action: "clarification" },
task: { id: "task-1", title: "Task" },
taskDescription: "Task description",
needsRecovery: false,
planModeAvailable: true,
mcpServers: [],
mcpAttachmentHistory: [],
supportsSteering: false,
isStarting: false,
isQueueReady: true,
inputMode: "direct",
isPreparingEnvironment: false,
isFailed: false,
isCompleted: false,
agentCommands: [],
todoItems: [],
pendingCommentsByFile: {},
handleToggleContextFile: vi.fn(),
handleAddContextFile: vi.fn(),
contextItems: [],
planContextEnabled: false,
handlePlanModeChange: vi.fn(),
...overrides,
} as never;
}
function composerElement(panelStateOverride = {}) {
mockProceedStepName = "Review";
return (
<ChatInputArea
chatInputRef={{ current: null }}
clarificationKey={0}
onClarificationResolved={vi.fn()}
handleSubmit={vi.fn()}
handleCancelTurn={vi.fn().mockResolvedValue(undefined)}
showRequestChangesTooltip={false}
panelState={composerPanelState(panelStateOverride)}
isSending={false}
/>
);
}
function renderComposer(panelStateOverride = {}) {
return render(composerElement(panelStateOverride));
}
it("keeps the composer mounted and focused as the queue fills and drains", () => {
const view = renderComposer();
const editor = screen.getByRole("textbox", { name: "Draft" });
fireEvent.change(editor, { target: { value: "next draft" } });
act(() => editor.focus());
for (const populated of [true, false]) {
mockQueuePopulated = populated;
view.rerender(composerElement());
expect(screen.getByRole("textbox", { name: "Draft" })).toBe(editor);
expect((editor as HTMLTextAreaElement).value).toBe("next draft");
expect(document.activeElement).toBe(editor);
}
});
describe("resolveInputPlaceholder", () => {
it("invites queueing while a clarification remains pending", () => {
expect(resolveInputPlaceholder(false, undefined, false, true, false)).toBe(
"Queue instructions while the question is pending...",
);
});
});
describe("ChatInputArea proceed visibility", () => {
it("keeps proceed hidden until durable clarification hydration clears", () => {
const { rerender } = renderComposer();
expect(screen.queryByTestId("proceed-next-step")).toBeNull();
rerender(
<ChatInputArea
chatInputRef={{ current: null }}
clarificationKey={0}
onClarificationResolved={vi.fn()}
handleSubmit={vi.fn()}
handleCancelTurn={vi.fn().mockResolvedValue(undefined)}
showRequestChangesTooltip={false}
panelState={composerPanelState({
session: { state: "WAITING_FOR_INPUT", pending_action: null },
})}
isSending={false}
/>,
);
expect(screen.getByTestId("proceed-next-step")).toBeTruthy();
});
});
describe("useSubmitHandler", () => {
it("reports accepted delivery", async () => {
const { result } = renderHook(() => useSubmitHandler(panelState()));
await expect(result.current.handleSubmit({ message: "hello" })).resolves.toBe(true);
});
it("shows a toast when sending fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
handleSendMessageMock.mockRejectedValueOnce(new Error("WebSocket request timed out"));
const { result } = renderHook(() => useSubmitHandler(panelState()));
await act(async () => {
await result.current.handleSubmit({ message: "hello" });
});
expect(toastMock).toHaveBeenCalledWith({
title: "Message send status unknown",
description:
"The connection dropped or timed out. Refresh the task to confirm whether it went through.",
variant: "error",
});
});
});
describe("useChatPanelHandlers", () => {
it("can disable the global focus shortcut for embedded panels", () => {
renderHook(() =>
useChatPanelHandlers("session-1", { current: null }, { enableFocusShortcut: false }),
);
expect(useKeyboardShortcutMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ enabled: false }),
);
});
});
describe("useSubmitHandler routing", () => {
it("queues through the shared handler instead of preview onSend while clarification is pending", async () => {
const onSend = vi.fn();
const { result } = renderHook(() =>
useSubmitHandler(panelState({ pendingClarification: { id: "clarification-1" } }), onSend),
);
await act(async () => {
await result.current.handleSubmit({ message: "queued instruction" });
});
expect(onSend).not.toHaveBeenCalled();
expect(handleSendMessageMock).toHaveBeenCalledWith({ message: "queued instruction" });
});
it("uses preview onSend when no clarification is pending", async () => {
const onSend = vi.fn();
const { result } = renderHook(() => useSubmitHandler(panelState(), onSend));
await act(async () => {
await result.current.handleSubmit({ message: "direct preview message" });
});
expect(onSend).toHaveBeenCalledWith({ message: "direct preview message" });
expect(handleSendMessageMock).not.toHaveBeenCalled();
});
it("does not clear composer side effects when admission reports unsuccessful", async () => {
const clearEphemeral = vi.fn();
handleSendMessageMock.mockResolvedValueOnce(false);
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
contextFiles: [{ path: "src", name: "src", isDirectory: true }],
clearEphemeral,
}),
),
);
await act(async () => {
await result.current.handleSubmit({ message: "keep this draft" });
});
expect(clearEphemeral).not.toHaveBeenCalled();
});
});
describe("useSubmitHandler plan mode", () => {
it("uses the normal send path in plan mode", async () => {
const onSend = vi.fn();
const { result } = renderHook(() =>
useSubmitHandler(panelState({ planModeEnabled: true }), onSend),
);
await act(async () => {
await result.current.handleSubmit({ message: "plan-mode instruction" });
});
expect(onSend).toHaveBeenCalledWith({ message: "plan-mode instruction" });
expect(handleSendMessageMock).not.toHaveBeenCalled();
expect(toastMock).not.toHaveBeenCalled();
});
});
describe("useSubmitHandler task plan comments", () => {
it.each(["idle", "retrying", "failed"] as const)(
"accepts plain Send with no identified drafts during %s recovery",
async (status) => {
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
planCommentMigration: {
...planCommentRecovery({ status, pendingCount: 0, failure: "transient" }),
retry: vi.fn(),
},
}),
),
);
await act(async () => {
await expect(result.current.handleSubmit({ message: "Send my message" })).resolves.toBe(
true,
);
});
expect(handleSendMessageMock).toHaveBeenCalledWith({ message: "Send my message" });
expect(toastMock).not.toHaveBeenCalled();
},
);
it.each(["transient", "conflict", "rejected"] as const)(
"preserves blocked delivery without promising retries for %s recovery",
async (failure) => {
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
planCommentMigration: {
status: "failed",
pendingCount: 1,
failure,
needsAttention: true,
isReady: false,
isBlocking: true,
retry: vi.fn(),
},
}),
),
);
await act(async () => {
await expect(result.current.handleSubmit({ message: "Keep my draft" })).resolves.toBe(
false,
);
});
expect(handleSendMessageMock).not.toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith({
title: MESSAGE_NOT_SENT_TITLE,
description: "Saved plan comments are still being restored. Your message is kept.",
variant: "error",
});
},
);
it("submits displayed IDs and versions without clearing the shared snapshot locally", async () => {
const clearSessionPlanComments = vi.fn();
const comment = {
id: "plan-comment-1",
taskId: "task-1",
planId: "plan-1",
version: 3,
text: "Split this step.",
selectedText: "Large step",
};
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
planComments: [comment],
clearSessionPlanComments,
planCommentMigration: {
...planCommentRecovery({ status: "failed", pendingCount: 0, failure: "transient" }),
retry: vi.fn(),
},
}),
),
);
await act(async () => {
await result.current.handleSubmit({ message: "Please continue." });
});
expect(handleSendMessageMock).toHaveBeenCalledWith({
message: "Please continue.",
planCommentRefs: [{ id: "plan-comment-1", version: 3 }],
});
expect(clearSessionPlanComments).not.toHaveBeenCalled();
});
});
describe("useSubmitHandler deterministic failures", () => {
it.each([
["connection-unavailable", "Connection unavailable. Reconnect and try again."],
["session-unavailable", "The selected session is not available for input."],
])("reports deterministic %s preflight failures as not sent", async (code, message) => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const error = Object.assign(new Error(message), {
name: "MessageSendError",
code,
});
handleSendMessageMock.mockRejectedValueOnce(error);
const { result } = renderHook(() => useSubmitHandler(panelState()));
await act(async () => {
await result.current.handleSubmit({ message: "hello" });
});
expect(toastMock).toHaveBeenCalledWith({
title: MESSAGE_NOT_SENT_TITLE,
description: message,
variant: "error",
});
});
});
describe("useSubmitHandler queue admission failures", () => {
it("shows localized capacity feedback for a rejected queue admission", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
handleSendMessageMock.mockRejectedValueOnce(new QueueFullError(10, 10));
const { result } = renderHook(() => useSubmitHandler(panelState()));
await act(async () => {
await result.current.handleSubmit({ message: "keep this draft" });
});
expect(toastMock).toHaveBeenCalledWith({
title: MESSAGE_NOT_SENT_TITLE,
description: "The message queue is full. Wait for the next turn to drain.",
variant: "error",
});
});
it("shows localized identity feedback without exposing the server diagnostic", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
handleSendMessageMock.mockRejectedValueOnce(new QueueAdmissionError("identity-conflict"));
const { result } = renderHook(() => useSubmitHandler(panelState()));
await act(async () => {
await result.current.handleSubmit({ message: "keep this draft" });
});
expect(toastMock).toHaveBeenCalledWith({
title: MESSAGE_NOT_SENT_TITLE,
description: "This draft was already submitted with different content.",
variant: "error",
});
});
});
describe("useSubmitHandler message comments", () => {
it("keeps agent message comments pending when sending fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const markCommentsSent = vi.fn();
handleSendMessageMock.mockRejectedValueOnce(new Error("send failed"));
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
messageComments: [{ id: "comment-1" }],
markCommentsSent,
}),
),
);
await act(async () => {
await result.current.handleSubmit({ message: "hello" });
});
expect(markCommentsSent).not.toHaveBeenCalled();
});
it("includes each agent message comment once and marks it sent after success", async () => {
const markCommentsSent = vi.fn();
const comment = {
id: "comment-1",
selectedText: "settled answer",
text: "Please expand this.",
};
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
messageComments: [comment],
markCommentsSent,
}),
),
);
await act(async () => {
await result.current.handleSubmit({ message: "hello" });
});
const [{ message: sentMessage }] = handleSendMessageMock.mock.calls[0] as [{ message: string }];
expect(sentMessage.match(/### Agent Message Comments/g)).toHaveLength(1);
expect(sentMessage).toContain("> settled answer");
expect(sentMessage).toContain("> Please expand this.");
expect(markCommentsSent).toHaveBeenCalledWith(["comment-1"]);
});
});
describe("context file send retention", () => {
it("keeps ephemeral context files when sending fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const clearEphemeral = vi.fn();
handleSendMessageMock.mockRejectedValueOnce(new Error("send failed"));
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
contextFiles: [{ path: "src", name: "src", isDirectory: true }],
clearEphemeral,
}),
),
);
await act(async () => {
await result.current.handleSubmit({ message: "hello" });
});
expect(clearEphemeral).not.toHaveBeenCalled();
});
it("clears ephemeral context files after a successful send", async () => {
const clearEphemeral = vi.fn();
handleSendMessageMock.mockResolvedValueOnce(undefined);
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
contextFiles: [{ path: "src", name: "src", isDirectory: true }],
clearEphemeral,
}),
),
);
await act(async () => {
await result.current.handleSubmit({ message: "hello" });
});
expect(clearEphemeral).toHaveBeenCalledWith("session-1");
});
});