import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { WebSocketRequestError } from "@/lib/ws/request-error"; import { sendMessageRequest, useMessageHandler } from "./use-message-handler"; const getWebSocketClientMock = vi.hoisted(() => vi.fn()); const listTaskSessionsMock = vi.hoisted(() => vi.fn()); const queueMock = vi.hoisted(() => vi.fn()); const addMessageMock = vi.hoisted(() => vi.fn()); const setTaskPlanCommentsMock = vi.hoisted(() => vi.fn()); const setTaskPreviewFeedbackMock = vi.hoisted(() => vi.fn()); const getTaskPlanCommentsMock = vi.hoisted(() => vi.fn()); const getTaskPreviewFeedbackMock = vi.hoisted(() => vi.fn()); const storeState = vi.hoisted(() => ({ current: { taskSessions: { items: {} as Record }, queue: { metaBySessionId: {} as Record }, addMessage: addMessageMock, setTaskPlanComments: setTaskPlanCommentsMock, setTaskPreviewFeedback: setTaskPreviewFeedbackMock, }, })); vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: getWebSocketClientMock, })); vi.mock("@/lib/api/domains/session-api", () => ({ listTaskSessions: listTaskSessionsMock, })); vi.mock("@/components/state-provider", () => ({ useAppStoreApi: () => ({ getState: () => storeState.current }), })); vi.mock("@/lib/api/domains/plan-comment-api", () => ({ getTaskPlanComments: getTaskPlanCommentsMock, })); vi.mock("@/lib/api/domains/preview-feedback-api", () => ({ getTaskPreviewFeedback: getTaskPreviewFeedbackMock, })); vi.mock("./domains/session/use-queue", () => ({ useQueue: () => ({ queue: queueMock }), })); const TASK_ID = "task-1"; const SESSION_ID = "session-1"; const COMMENT_ID = "comment-1"; const PREVIEW_ID = "preview-1"; const MESSAGE_ADD_ACTION = "message.add"; function selectedSession(state: string, foregroundActivity?: string) { storeState.current.taskSessions.items = { [SESSION_ID]: { state, foreground_activity: foregroundActivity }, }; } function renderMessageHandler() { return renderHook(() => useMessageHandler({ resolvedSessionId: SESSION_ID, taskId: TASK_ID, sessionModel: null, activeModel: null, }), ); } beforeEach(() => { vi.clearAllMocks(); queueMock.mockResolvedValue(true); storeState.current.taskSessions.items = {}; storeState.current.queue.metaBySessionId = {}; getWebSocketClientMock.mockReturnValue({ request: vi.fn().mockResolvedValue(undefined) }); listTaskSessionsMock.mockResolvedValue({ sessions: [{ id: SESSION_ID }], total: 1 }); getTaskPlanCommentsMock.mockResolvedValue({ task_id: TASK_ID, plan_id: "plan-1", revision: 3, comments: [], }); getTaskPreviewFeedbackMock.mockResolvedValue({ task_id: TASK_ID, revision: 4, items: [], }); }); describe("sendMessageRequest task plan comments", () => { it("forwards task plan comment references and the primary guard", async () => { const request = vi.fn().mockResolvedValue(undefined); getWebSocketClientMock.mockReturnValue({ request }); await sendMessageRequest({ taskId: TASK_ID, resolvedSessionId: SESSION_ID, finalMessage: "", modelToSend: undefined, planMode: true, planCommentRefs: [{ id: COMMENT_ID, version: 4 }], requirePrimarySession: true, }); expect(request).toHaveBeenCalledWith( MESSAGE_ADD_ACTION, expect.objectContaining({ content: "", plan_mode: true, plan_comment_refs: [{ id: COMMENT_ID, version: 4 }], require_primary_session: true, }), 10000, ); }); it("forwards exact preview feedback references", async () => { const request = vi.fn().mockResolvedValue(undefined); getWebSocketClientMock.mockReturnValue({ request }); await sendMessageRequest({ taskId: TASK_ID, resolvedSessionId: SESSION_ID, finalMessage: "", modelToSend: undefined, planMode: false, previewFeedbackRefs: [{ id: PREVIEW_ID, version: 3 }], }); expect(request).toHaveBeenCalledWith( MESSAGE_ADD_ACTION, expect.objectContaining({ content: "", preview_feedback_refs: [{ id: PREVIEW_ID, version: 3 }], }), 10000, ); }); }); describe("sendMessageRequest routed reconciliation", () => { it("finds a committed message after turn-start routing changes the session", async () => { const replacementSessionId = "session-routed"; const committed = { id: "routed-message", session_id: replacementSessionId, task_id: TASK_ID, author_type: "user" as const, content: "retryable", type: "message" as const, created_at: "2026-08-01T18:00:00Z", }; listTaskSessionsMock.mockResolvedValueOnce({ sessions: [{ id: SESSION_ID }, { id: replacementSessionId }], total: 2, }); const request = vi.fn(async (action: string, payload: { session_id?: string }) => { if (action === MESSAGE_ADD_ACTION) { throw new Error("WebSocket request timed out: message.add"); } return payload.session_id === replacementSessionId ? { messages: [committed] } : { messages: [] }; }); getWebSocketClientMock.mockReturnValue({ request, getStatus: () => "connected" }); await expect( sendMessageRequest({ taskId: TASK_ID, resolvedSessionId: SESSION_ID, clientMessageId: committed.id, finalMessage: committed.content, modelToSend: undefined, planMode: false, }), ).resolves.toEqual(committed); expect(request).toHaveBeenCalledWith( "message.list", { session_id: replacementSessionId, limit: 100, sort: "desc" }, 5000, ); }); }); describe("useMessageHandler queued plan comments", () => { it("returns false for a queue no-op so the composer keeps its draft", async () => { queueMock.mockResolvedValue(false); const request = vi.fn(); getWebSocketClientMock.mockReturnValue({ request }); selectedSession("WAITING_FOR_INPUT"); const { result } = renderHook(() => useMessageHandler({ resolvedSessionId: SESSION_ID, taskId: TASK_ID, sessionModel: null, activeModel: null, hasPendingClarification: true, }), ); await expect(result.current.handleSendMessage({ message: "Do not clear me" })).resolves.toBe( false, ); expect(request).not.toHaveBeenCalled(); }); it("queues preview feedback with the same idempotent admission", async () => { selectedSession("RUNNING", "generating"); const { result } = renderMessageHandler(); await act(async () => { await result.current.handleSendMessage({ message: "Use this preview feedback", previewFeedbackRefs: [{ id: PREVIEW_ID, version: 5 }], } as never); }); expect(queueMock).toHaveBeenCalledWith( expect.objectContaining({ taskId: TASK_ID, clientQueueId: expect.any(String), previewFeedbackRefs: [{ id: PREVIEW_ID, version: 5 }], }), ); expect(getTaskPreviewFeedbackMock).toHaveBeenCalledWith(TASK_ID); }); it("queues displayed task plan references as one idempotent admission", async () => { selectedSession("RUNNING", "generating"); const { result } = renderMessageHandler(); await act(async () => { await result.current.handleSendMessage({ message: "Use this feedback", planCommentRefs: [{ id: COMMENT_ID, version: 2 }], } as never); }); expect(queueMock).toHaveBeenCalledWith( expect.objectContaining({ taskId: TASK_ID, content: "Use this feedback", clientQueueId: expect.any(String), planCommentRefs: [{ id: COMMENT_ID, version: 2 }], }), ); }); it("queues behind existing prompts for a steer-capable running session", async () => { selectedSession("RUNNING", "generating"); Object.assign(storeState.current.taskSessions.items[SESSION_ID] as object, { supports_steering: true, }); storeState.current.queue.metaBySessionId[SESSION_ID] = { count: 1 }; const { result } = renderMessageHandler(); await result.current.handleSendMessage({ message: "Use this feedback", planCommentRefs: [{ id: COMMENT_ID, version: 2 }], } as never); expect(queueMock).toHaveBeenCalled(); expect(getWebSocketClientMock().request).not.toHaveBeenCalled(); }); }); describe("useMessageHandler ordinary queue admission", () => { it("passes a stable admission ID for ordinary queued messages", async () => { selectedSession("WAITING_FOR_INPUT"); const { result } = renderHook(() => useMessageHandler({ resolvedSessionId: SESSION_ID, taskId: TASK_ID, sessionModel: null, activeModel: null, hasPendingClarification: true, }), ); await act(async () => { await result.current.handleSendMessage({ message: "Keep this queued" }); }); expect(queueMock).toHaveBeenCalledWith( expect.objectContaining({ taskId: TASK_ID, content: "Keep this queued", clientQueueId: expect.any(String), }), ); expect(queueMock.mock.calls[0]?.[0]?.planCommentRefs).toBeUndefined(); }); }); describe("useMessageHandler plan comment retries", () => { it("reuses an unresolved queue admission ID on manual retry", async () => { selectedSession("RUNNING", "generating"); queueMock .mockRejectedValueOnce(new Error("WebSocket request timed out: message.queue.add")) .mockResolvedValueOnce(true); const { result } = renderMessageHandler(); const payload = { message: "Retry this feedback", planCommentRefs: [{ id: COMMENT_ID, version: 2 }], } as never; await expect(result.current.handleSendMessage(payload)).rejects.toThrow( "WebSocket request timed out", ); await expect(result.current.handleSendMessage(payload)).resolves.toBeUndefined(); const firstID = queueMock.mock.calls[0]?.[0]?.clientQueueId; const secondID = queueMock.mock.calls[1]?.[0]?.clientQueueId; expect(firstID).toEqual(expect.any(String)); expect(secondID).toBe(firstID); }); it("reconciles an accepted admission before retrying in a new input mode", async () => { selectedSession("RUNNING", "generating"); queueMock.mockRejectedValueOnce(new Error("WebSocket request timed out: message.queue.add")); const { result } = renderMessageHandler(); const payload = { message: "Retry after ready", planCommentRefs: [{ id: COMMENT_ID, version: 2 }], } as never; await expect(result.current.handleSendMessage(payload)).rejects.toThrow( "WebSocket request timed out", ); const admissionID = queueMock.mock.calls[0]?.[0]?.clientQueueId; const committed = { id: admissionID, session_id: SESSION_ID, task_id: TASK_ID, author_type: "user" as const, content: "Retry after ready", type: "message" as const, created_at: "2026-08-01T18:00:00Z", }; const request = vi.fn(async (action: string) => action === "message.list" ? { messages: [committed] } : undefined, ); getWebSocketClientMock.mockReturnValue({ request }); selectedSession("WAITING_FOR_INPUT"); await expect(result.current.handleSendMessage(payload)).resolves.toBeUndefined(); expect(queueMock).toHaveBeenCalledTimes(1); expect(request).not.toHaveBeenCalledWith( MESSAGE_ADD_ACTION, expect.anything(), expect.anything(), ); expect(addMessageMock).toHaveBeenCalledWith(committed); }); }); describe("useMessageHandler direct plan comments", () => { it("sends refs without duplicating their text in active-plan context", async () => { selectedSession("WAITING_FOR_INPUT"); const request = vi.fn().mockResolvedValue(undefined); getWebSocketClientMock.mockReturnValue({ request }); const { result } = renderHook(() => useMessageHandler({ resolvedSessionId: SESSION_ID, taskId: TASK_ID, sessionModel: null, activeModel: null, planModeEnabled: true, activeDocument: { type: "plan", taskId: TASK_ID } as never, planComments: [{ id: COMMENT_ID, version: 2, text: "Do not duplicate this text" } as never], }), ); await act(async () => { await result.current.handleSendMessage({ message: "Continue", planCommentRefs: [{ id: COMMENT_ID, version: 2 }], } as never); }); const sent = request.mock.calls[0]?.[1] as Record; expect(sent.content).not.toContain("Do not duplicate this text"); expect(sent.plan_comment_refs).toEqual([{ id: COMMENT_ID, version: 2 }]); }); it("reconciles a stale preview snapshot and returns a deterministic error", async () => { selectedSession("WAITING_FOR_INPUT"); const snapshot = { task_id: TASK_ID, revision: 9, items: [] }; const request = vi.fn().mockRejectedValue( new WebSocketRequestError("Task preview feedback changed", "preview_feedback_changed", { snapshot, }), ); getWebSocketClientMock.mockReturnValue({ request }); const { result } = renderMessageHandler(); await expect( result.current.handleSendMessage({ message: "Keep my draft", previewFeedbackRefs: [{ id: PREVIEW_ID, version: 1 }], } as never), ).rejects.toMatchObject({ code: "preview-feedback-changed" }); expect(setTaskPreviewFeedbackMock).toHaveBeenCalledWith(TASK_ID, snapshot); }); it("refreshes preview feedback after direct acceptance", async () => { selectedSession("WAITING_FOR_INPUT"); const request = vi.fn().mockResolvedValue(undefined); getWebSocketClientMock.mockReturnValue({ request }); const { result } = renderMessageHandler(); await result.current.handleSendMessage({ message: "Apply it", previewFeedbackRefs: [{ id: PREVIEW_ID, version: 2 }], } as never); expect(getTaskPreviewFeedbackMock).toHaveBeenCalledWith(TASK_ID); expect(setTaskPreviewFeedbackMock).toHaveBeenCalledWith(TASK_ID, { task_id: TASK_ID, revision: 4, items: [], }); }); }); describe("useMessageHandler direct admission identity", () => { it("reconciles a stale snapshot and returns a deterministic error", async () => { selectedSession("WAITING_FOR_INPUT"); const snapshot = { task_id: TASK_ID, plan_id: "plan-1", revision: 7, comments: [] }; const request = vi.fn().mockRejectedValue( new WebSocketRequestError("Task plan comments changed", "plan_comments_changed", { snapshot, }), ); getWebSocketClientMock.mockReturnValue({ request }); const { result } = renderMessageHandler(); await expect( result.current.handleSendMessage({ message: "Keep my draft", planCommentRefs: [{ id: COMMENT_ID, version: 1 }], } as never), ).rejects.toMatchObject({ code: "plan-comments-changed" }); expect(setTaskPlanCommentsMock).toHaveBeenCalledWith(TASK_ID, snapshot); }); it("keeps the admission ID when reconciliation reports a comment conflict", async () => { selectedSession("WAITING_FOR_INPUT"); const request = vi .fn() .mockRejectedValueOnce( new WebSocketRequestError("Task plan comments changed", "plan_comments_changed", { snapshot: { task_id: TASK_ID, plan_id: "plan-1", revision: 7, comments: [] }, }), ) .mockResolvedValueOnce(undefined); getWebSocketClientMock.mockReturnValue({ request }); const { result } = renderMessageHandler(); const payload = { message: "Keep one admission", planCommentRefs: [{ id: COMMENT_ID, version: 2 }], } as never; await expect(result.current.handleSendMessage(payload)).rejects.toMatchObject({ code: "plan-comments-changed", }); await expect(result.current.handleSendMessage(payload)).resolves.toBeUndefined(); const admissions = request.mock.calls.filter(([action]) => action === MESSAGE_ADD_ACTION); expect(admissions[1]?.[1]?.client_message_id).toBe(admissions[0]?.[1]?.client_message_id); }); it("uses a new admission ID when review-comment presence changes", async () => { selectedSession("WAITING_FOR_INPUT"); const request = vi .fn() .mockRejectedValueOnce(new Error("rejected")) .mockResolvedValueOnce(undefined); getWebSocketClientMock.mockReturnValue({ request }); const { result } = renderMessageHandler(); const original = { message: "Review this", planCommentRefs: [{ id: COMMENT_ID, version: 2 }], }; await expect(result.current.handleSendMessage(original)).rejects.toThrow("rejected"); await expect( result.current.handleSendMessage({ ...original, reviewComments: [{}] as never, }), ).resolves.toBeUndefined(); const admissions = request.mock.calls.filter(([action]) => action === MESSAGE_ADD_ACTION); expect(admissions[1]?.[1]?.client_message_id).not.toBe(admissions[0]?.[1]?.client_message_id); }); });