"use client"; import { act, renderHook, waitFor } from "@testing-library/react"; import { useAgentChatSession } from "./useAgentChatSession"; import { abortAgentChat, forkAgentChat, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat, } from "@/lib/chatStream"; import type { StreamEvent } from "@/lib/chatStream"; jest.mock("@/lib/chatStream", () => ({ abortAgentChat: jest.fn(async () => undefined), forkAgentChat: jest.fn(async () => "forked-session"), replyAgentPermission: jest.fn(async () => undefined), replyAgentQuestion: jest.fn(async () => undefined), resumeAgentChatStream: jest.fn(async () => undefined), streamAgentChat: jest.fn(async () => undefined), })); const listChatSessions = jest.fn(); const deleteChatSession = jest.fn(); const updateChatSessionTitle = jest.fn(); jest.mock("../chatStorage", () => ({ createEmptyChatState: jest.fn(() => ({ title: undefined, isTitleManuallyEdited: false, messages: [], sessionId: undefined, })), deleteChatSession: (...args: unknown[]) => deleteChatSession(...args), listChatSessions: (...args: unknown[]) => listChatSessions(...args), loadChatSessionById: jest.fn(async () => ({ title: "已存在会话", isTitleManuallyEdited: false, messages: [], sessionId: "session-loaded", })), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), })); describe("useAgentChatSession", () => { beforeEach(() => { listChatSessions.mockReset(); deleteChatSession.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(abortAgentChat).mockReset(); jest.mocked(forkAgentChat).mockReset(); jest.mocked(replyAgentPermission).mockReset(); jest.mocked(replyAgentQuestion).mockReset(); jest.mocked(resumeAgentChatStream).mockReset(); jest.mocked(streamAgentChat).mockReset(); jest.mocked(abortAgentChat).mockImplementation(async () => undefined); jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session"); jest.mocked(replyAgentPermission).mockImplementation(async () => undefined); jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined); jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined); jest.mocked(streamAgentChat).mockImplementation(async () => undefined); deleteChatSession.mockImplementation(async () => undefined); updateChatSessionTitle.mockImplementation(async () => undefined); }); describe("useAgentChatSession actions", () => { it("tracks permission requests and submits replies", async () => { listChatSessions.mockResolvedValue([]); let emitStreamEvent: ((event: StreamEvent) => void) | undefined; jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { emitStreamEvent = onEvent; await new Promise(() => undefined); }); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); await act(async () => { void result.current.sendPrompt("删除临时文件"); await Promise.resolve(); }); act(() => { emitStreamEvent?.({ type: "permission_request", sessionId: "session-1", requestId: "perm-1", permission: "bash", patterns: ["rm *"], target: "rm tmp.txt", always: ["rm *"], createdAt: 123, }); }); expect(result.current.messages.at(-1)?.permissions).toEqual([ expect.objectContaining({ requestId: "perm-1", sessionId: "session-1", status: "pending", }), ]); await act(async () => { await result.current.replyPermission("perm-1", "once"); }); expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once"); expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual( expect.objectContaining({ requestId: "perm-1", status: "approved_once", }), ); }); it("finalizes running progress when aborting an active prompt", async () => { listChatSessions.mockResolvedValue([]); jest.mocked(streamAgentChat).mockImplementationOnce( ({ onEvent, signal }) => new Promise((_, reject) => { onEvent({ type: "progress", sessionId: "session-1", id: "request-received", phase: "start", status: "running", title: "开始分析", startedAt: 1000, } satisfies StreamEvent); onEvent({ type: "todo_update", sessionId: "session-1", todos: [ { id: "todo-1", content: "分析水位", status: "in_progress", }, { id: "todo-2", content: "生成建议", status: "pending", }, ], createdAt: 1001, } satisfies StreamEvent); onEvent({ type: "permission_request", sessionId: "session-1", requestId: "perm-abort", permission: "bash", patterns: ["npm test"], target: "npm test", always: ["npm test"], createdAt: 1002, } satisfies StreamEvent); onEvent({ type: "question_request", sessionId: "session-1", requestId: "question-abort", questions: [ { header: "范围", question: "请选择范围", options: [{ label: "城区", description: "中心城区" }], }, ], createdAt: 1003, } satisfies StreamEvent); signal?.addEventListener("abort", () => { reject(new Error("aborted")); }); }), ); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); act(() => { void result.current.sendPrompt("测试中断"); }); await waitFor(() => expect(result.current.isStreaming).toBe(true)); act(() => { result.current.abort(); }); await waitFor(() => expect(result.current.isStreaming).toBe(false)); expect(result.current.messages.at(-1)).toEqual( expect.objectContaining({ role: "assistant", content: "⚠️ **请求已中断**", isError: true, progress: [ expect.objectContaining({ id: "request-received", status: "completed", durationMs: expect.any(Number), endedAt: expect.any(Number), }), ], todos: expect.objectContaining({ todos: [ expect.objectContaining({ id: "todo-1", status: "cancelled", updatedAt: expect.any(Number), }), expect.objectContaining({ id: "todo-2", status: "cancelled", updatedAt: expect.any(Number), }), ], }), permissions: [ expect.objectContaining({ requestId: "perm-abort", status: "aborted", repliedAt: expect.any(Number), error: undefined, }), ], questions: [ expect.objectContaining({ requestId: "question-abort", status: "rejected", repliedAt: expect.any(Number), error: undefined, }), ], }), ); expect(abortAgentChat).toHaveBeenCalledWith("session-1"); }); it("ignores generated session titles after the title was edited manually", async () => { listChatSessions.mockResolvedValue([]); jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { onEvent({ type: "session_title", sessionId: "session-1", title: "自动标题", }); onEvent({ type: "done", sessionId: "session-1", }); }); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); await act(async () => { await result.current.switchSession("session-loaded"); }); await act(async () => { await result.current.renameSession("session-loaded", "手动标题"); }); await waitFor(() => expect(updateChatSessionTitle).toHaveBeenCalled()); await act(async () => { await result.current.sendPrompt("帮我分析一下"); }); expect(result.current.sessionTitle).toBe("手动标题"); expect(updateChatSessionTitle).not.toHaveBeenCalledWith( "session-loaded", "自动标题", expect.anything(), ); }); it("does not apply a late generated title to a newly created session", async () => { listChatSessions.mockResolvedValue([]); let emitStreamEvent: ((event: StreamEvent) => void) | undefined; let resolveStream: (() => void) | undefined; jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { emitStreamEvent = onEvent; await new Promise((resolve) => { resolveStream = resolve; }); }); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); await act(async () => { void result.current.sendPrompt("帮我分析一下"); await Promise.resolve(); }); act(() => { emitStreamEvent?.({ type: "done", sessionId: "old-session", }); }); await waitFor(() => expect(result.current.isStreaming).toBe(false)); act(() => { result.current.createSession(); }); expect(result.current.sessionTitle).toBe("新对话"); await act(async () => { emitStreamEvent?.({ type: "session_title", sessionId: "old-session", title: "旧请求标题", }); resolveStream?.(); await Promise.resolve(); }); expect(result.current.sessionTitle).toBe("新对话"); expect(updateChatSessionTitle).toHaveBeenCalledWith( "old-session", "旧请求标题", { isTitleManuallyEdited: false }, ); }); it("forks a copied conversation from an assistant message", async () => { listChatSessions.mockResolvedValue([]); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); await act(async () => { await result.current.sendPrompt("第一轮"); }); const firstAssistantMessageId = result.current.messages[1]?.id ?? ""; await act(async () => { await result.current.createBranch(firstAssistantMessageId); }); expect(forkAgentChat).toHaveBeenCalledWith(undefined, 2); expect(result.current.activeSessionId).toBe("forked-session"); expect(result.current.messages).toHaveLength(2); expect(result.current.messages[0]).toEqual( expect.objectContaining({ role: "user", content: "第一轮", }), ); expect(result.current.messages[1]).toEqual( expect.objectContaining({ role: "assistant", }), ); expect(streamAgentChat).toHaveBeenCalledTimes(1); }); }); });