"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 lifecycle and resume", () => { it("does not add a new empty session to history until there is actual chat content", async () => { listChatSessions.mockResolvedValue([]); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); act(() => { void result.current.createSession(); }); await waitFor(() => expect(result.current.sessionTitle).toBe("新对话")); expect(result.current.chatSessions).toEqual([]); expect(result.current.activeSessionId).toBeUndefined(); expect(result.current.messages).toEqual([]); expect(result.current.isStreaming).toBe(false); expect(listChatSessions).toHaveBeenCalledTimes(1); }); it("keeps existing history entries when creating a blank new session", async () => { listChatSessions.mockResolvedValue([ { id: "session-1", title: "已有会话", createdAt: 1, updatedAt: 1, }, ]); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); act(() => { void result.current.createSession(); }); expect(result.current.chatSessions).toEqual([ { id: "session-1", title: "已有会话", createdAt: 1, updatedAt: 1, }, ]); }); it("removes a deleted history entry before the backend delete finishes", async () => { const initialSessions = [ { id: "session-1", title: "第一段会话", createdAt: 2, updatedAt: 2, }, { id: "session-2", title: "第二段会话", createdAt: 1, updatedAt: 1, }, ]; let resolveDelete: ((nextActiveSessionId?: string) => void) | undefined; listChatSessions.mockResolvedValue(initialSessions); deleteChatSession.mockImplementationOnce( () => new Promise((resolve) => { resolveDelete = resolve; }), ); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); act(() => { void result.current.removeSession("session-2"); }); expect(result.current.chatSessions).toEqual([ expect.objectContaining({ id: "session-1" }), ]); listChatSessions.mockResolvedValue([ { id: "session-1", title: "第一段会话", createdAt: 2, updatedAt: 2, }, ]); await act(async () => { resolveDelete?.(); await Promise.resolve(); }); await waitFor(() => expect(result.current.chatSessions).toEqual([ expect.objectContaining({ id: "session-1" }), ]), ); }); it("does not autosave full messages after the stream is done", 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)); jest.useFakeTimers(); try { await act(async () => { void result.current.sendPrompt("第一条消息"); await Promise.resolve(); }); expect(result.current.isStreaming).toBe(true); await act(async () => { jest.advanceTimersByTime(200); }); act(() => { emitStreamEvent?.({ type: "token", sessionId: "chat-stream-1", content: "收到", }); }); await act(async () => { jest.advanceTimersByTime(200); }); act(() => { emitStreamEvent?.({ type: "done", sessionId: "chat-stream-1", }); }); await act(async () => { jest.advanceTimersByTime(200); }); expect(result.current.messages).toEqual([ expect.objectContaining({ role: "user", content: "第一条消息" }), expect.objectContaining({ role: "assistant", content: "收到" }), ]); expect(result.current.activeSessionId).toBe("chat-stream-1"); } finally { jest.useRealTimers(); } }); it("shows shared todo state only on the latest assistant message in a session", async () => { listChatSessions.mockResolvedValue([]); jest.mocked(streamAgentChat) .mockImplementationOnce(async ({ onEvent }) => { onEvent({ type: "todo_update", sessionId: "session-1", todos: [ { id: "todo-1", content: "创建任务列表", status: "in_progress", }, ], createdAt: 1000, }); onEvent({ type: "done", sessionId: "session-1", }); }) .mockImplementationOnce(async ({ onEvent }) => { onEvent({ type: "todo_update", sessionId: "session-1", todos: [ { id: "todo-1", content: "创建任务列表", status: "completed", }, { id: "todo-2", content: "更新任务状态", status: "in_progress", }, ], createdAt: 2000, }); 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.sendPrompt("创建任务"); }); await waitFor(() => expect(result.current.isStreaming).toBe(false)); await act(async () => { await result.current.sendPrompt("更新任务"); }); await waitFor(() => expect(result.current.isStreaming).toBe(false)); const assistantMessages = result.current.messages.filter( (message) => message.role === "assistant", ); expect(assistantMessages).toHaveLength(2); expect(assistantMessages[0].todos).toBeUndefined(); expect(assistantMessages[1].todos).toEqual( expect.objectContaining({ sessionId: "session-1", createdAt: 2000, todos: [ expect.objectContaining({ id: "todo-1", status: "completed", }), expect.objectContaining({ id: "todo-2", status: "in_progress", }), ], }), ); }); it("hydrates a backend streaming session and resumes its stream", async () => { listChatSessions.mockResolvedValue([ { id: "session-streaming", title: "运行中", createdAt: 1, updatedAt: 2, isStreaming: true, runStatus: "running", }, ]); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); expect(result.current.isStreaming).toBe(true); expect(result.current.activeSessionId).toBe("session-loaded"); expect(resumeAgentChatStream).toHaveBeenCalledWith( expect.objectContaining({ sessionId: "session-loaded", }), ); }); it("updates resumed messages from state, token, and done events", async () => { listChatSessions.mockResolvedValue([ { id: "session-streaming", title: "运行中", createdAt: 1, updatedAt: 2, isStreaming: true, }, ]); jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { onEvent({ type: "state", sessionId: "session-loaded", messages: [ { id: "u1", role: "user", content: "继续分析" }, { id: "a1", role: "assistant", content: "已有" }, ], isStreaming: true, runStatus: "running", }); onEvent({ type: "token", sessionId: "session-loaded", content: "输出", }); onEvent({ type: "done", sessionId: "session-loaded", }); }); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); await waitFor(() => expect(result.current.isStreaming).toBe(false)); expect(result.current.messages).toEqual([ expect.objectContaining({ id: "u1", role: "user", content: "继续分析" }), expect.objectContaining({ id: "a1", role: "assistant", content: "已有输出" }), ]); }); it("applies question responses to the message that owns the request", async () => { listChatSessions.mockResolvedValue([ { id: "session-streaming", title: "运行中", createdAt: 1, updatedAt: 2, isStreaming: true, }, ]); jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { onEvent({ type: "state", sessionId: "session-loaded", messages: [ { id: "u1", role: "user", content: "继续分析" }, { id: "a1", role: "assistant", content: "需要确认", questions: [ { requestId: "q-1", sessionId: "session-loaded", questions: [ { header: "范围", question: "选择范围", options: [], custom: true, }, ], createdAt: 123, status: "pending", }, ], }, { id: "a2", role: "assistant", content: "后续消息" }, ], isStreaming: true, runStatus: "running", }); onEvent({ type: "question_response", sessionId: "session-loaded", requestId: "q-1", answers: [["城区"]], rejected: false, }); }); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); expect(result.current.messages[1].questions?.[0]).toEqual( expect.objectContaining({ requestId: "q-1", status: "answered", answers: [["城区"]], }), ); expect(result.current.messages[2].questions).toBeUndefined(); }); it("deduplicates question requests across assistant messages", async () => { listChatSessions.mockResolvedValue([ { id: "session-streaming", title: "运行中", createdAt: 1, updatedAt: 2, isStreaming: true, }, ]); jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { onEvent({ type: "state", sessionId: "session-loaded", messages: [ { id: "u1", role: "user", content: "继续分析" }, { id: "a1", role: "assistant", content: "需要确认", questions: [ { requestId: "question-1", sessionId: "session-loaded", questions: [ { header: "测试问题", question: "你觉得这个 question 工具好用吗?", options: [ { label: "非常好用", description: "交互清晰,选项方便", }, ], }, ], tool: { messageID: "message-1", callID: "call-1", }, createdAt: 123, status: "pending", }, ], }, { id: "a2", role: "assistant", content: "后续消息" }, ], isStreaming: true, runStatus: "running", }); onEvent({ type: "question_request", sessionId: "session-loaded", requestId: "call-1", questions: [ { header: "测试问题", question: "你觉得这个 question 工具好用吗?", options: [ { label: "非常好用", description: "交互清晰,选项方便", }, ], }, ], tool: { messageID: "message-1", callID: "call-1", }, createdAt: 456, }); }); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); const allQuestions = result.current.messages.flatMap( (message) => message.questions ?? [], ); expect(allQuestions).toHaveLength(1); expect(result.current.messages[1].questions?.[0]).toEqual( expect.objectContaining({ requestId: "question-1", tool: expect.objectContaining({ callID: "call-1" }), }), ); expect(result.current.messages[2].questions).toBeUndefined(); }); it("keeps the actionable question request id when a tool-part duplicate arrives later", async () => { listChatSessions.mockResolvedValue([ { id: "session-streaming", title: "运行中", createdAt: 1, updatedAt: 2, isStreaming: true, }, ]); jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { onEvent({ type: "state", sessionId: "session-loaded", messages: [ { id: "u1", role: "user", content: "继续分析" }, { id: "a1", role: "assistant", content: "需要确认", questions: [ { requestId: "question-1", sessionId: "session-loaded", questions: [ { header: "测试问题", question: "你觉得这个 question 工具好用吗?", options: [ { label: "非常好用", description: "交互清晰,选项方便", }, ], }, ], tool: { messageID: "message-1", callID: "call-1", }, createdAt: 123, status: "pending", }, ], }, ], isStreaming: true, runStatus: "running", }); onEvent({ type: "question_request", sessionId: "session-loaded", requestId: "call-1", questions: [ { header: "测试问题", question: "你觉得这个 question 工具好用吗?", options: [ { label: "非常好用", description: "交互清晰,选项方便", }, ], }, ], tool: { messageID: "message-1", callID: "call-1", }, createdAt: 456, }); }); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); const allQuestions = result.current.messages.flatMap( (message) => message.questions ?? [], ); expect(allQuestions).toHaveLength(1); expect(allQuestions[0]).toEqual( expect.objectContaining({ requestId: "question-1", tool: expect.objectContaining({ callID: "call-1" }), }), ); }); it("deduplicates persisted duplicate questions from state events", async () => { listChatSessions.mockResolvedValue([ { id: "session-streaming", title: "运行中", createdAt: 1, updatedAt: 2, isStreaming: true, }, ]); const duplicateQuestion = { sessionId: "session-loaded", questions: [ { header: "测试问题", question: "你觉得这个 question 工具好用吗?", options: [ { label: "非常好用", description: "交互清晰,选项方便", }, ], }, ], tool: { messageID: "message-1", callID: "call-1", }, createdAt: 123, status: "pending" as const, }; jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => { onEvent({ type: "state", sessionId: "session-loaded", messages: [ { id: "u1", role: "user", content: "继续分析" }, { id: "a1", role: "assistant", content: "需要确认", questions: [{ ...duplicateQuestion, requestId: "question-1" }], }, { id: "a2", role: "assistant", content: "后续消息", questions: [{ ...duplicateQuestion, requestId: "call-1" }], }, ], isStreaming: true, runStatus: "running", }); }); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isHydrating).toBe(false)); expect( result.current.messages.flatMap((message) => message.questions ?? []), ).toHaveLength(1); expect(result.current.messages[1].questions).toHaveLength(1); expect(result.current.messages[2].questions).toBeUndefined(); }); it("aborts a resumed streaming session through the backend abort endpoint", async () => { listChatSessions.mockResolvedValue([ { id: "session-streaming", title: "运行中", createdAt: 1, updatedAt: 2, isStreaming: true, }, ]); jest.mocked(resumeAgentChatStream).mockImplementationOnce(async () => { await new Promise(() => undefined); }); const { result } = renderHook(() => useAgentChatSession({ projectId: "project-1", onToolCall: jest.fn(), }), ); await waitFor(() => expect(result.current.isStreaming).toBe(true)); act(() => { result.current.abort(); }); expect(abortAgentChat).toHaveBeenCalledWith("session-loaded"); }); }); });