398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
"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<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 *"],
|
|
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<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"],
|
|
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<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("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);
|
|
});
|
|
});
|
|
});
|