refactor(chat): split oversized chat modules

This commit is contained in:
2026-06-08 19:23:46 +08:00
parent 865e425748
commit 36cdb1df8d
11 changed files with 3282 additions and 3150 deletions
@@ -0,0 +1,479 @@
"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 saveActiveChatState = 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",
})),
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
}));
describe("useAgentChatSession", () => {
beforeEach(() => {
listChatSessions.mockReset();
deleteChatSession.mockReset();
saveActiveChatState.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);
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
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<void>(() => 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 *"],
metadata: { command: "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<void>((_, 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"],
metadata: { command: "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: "rejected",
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<void>((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("asks the backend to undo the previous user turn before regenerating", 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 assistantMessageId = result.current.messages[1]?.id ?? "";
await act(async () => {
await result.current.regenerate(assistantMessageId);
});
expect(streamAgentChat).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
message: "重新分析压力异常",
regenerateFromMessageIndex: 0,
}),
);
});
it("replaces the current chain when regenerating a middle 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("第一轮");
});
await act(async () => {
await result.current.sendPrompt("第二轮");
});
const firstAssistantMessageId = result.current.messages[1]?.id ?? "";
await act(async () => {
await result.current.regenerate(firstAssistantMessageId);
});
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",
content: "",
}),
);
expect(streamAgentChat).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
message: "第一轮",
regenerateFromMessageIndex: 0,
}),
);
});
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);
});
});
});