refactor(chat): split oversized chat modules
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
import type {
|
||||
AgentQuestionRequest,
|
||||
AgentTodoUpdate,
|
||||
PermissionReply,
|
||||
StreamEvent,
|
||||
} from "@/lib/chatStream";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
ChatProgress,
|
||||
LoadedChatState,
|
||||
Message,
|
||||
} from "../GlobalChatbox.types";
|
||||
import { createId } from "../GlobalChatbox.utils";
|
||||
|
||||
export const createPersistedStateKey = (state: LoadedChatState) =>
|
||||
JSON.stringify({
|
||||
title: state.title ?? null,
|
||||
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
|
||||
sessionId: state.sessionId ?? null,
|
||||
messages: state.messages,
|
||||
});
|
||||
|
||||
export const upsertProgress = (
|
||||
progress: ChatProgress[] | undefined,
|
||||
event: StreamEvent & { type: "progress" },
|
||||
) => {
|
||||
const next = [...(progress ?? [])];
|
||||
const index = next.findIndex((item) => item.id === event.id);
|
||||
const existing = index >= 0 ? next[index] : undefined;
|
||||
const now = Date.now();
|
||||
const startedAt = event.startedAt ?? existing?.startedAt;
|
||||
const isRunning = event.status === "running";
|
||||
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
|
||||
const elapsedMs = isRunning
|
||||
? event.elapsedMs ??
|
||||
existing?.elapsedMs ??
|
||||
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
|
||||
: undefined;
|
||||
const elapsedSnapshotAt = isRunning
|
||||
? event.elapsedMs !== undefined
|
||||
? now
|
||||
: existing?.elapsedSnapshotAt ?? now
|
||||
: undefined;
|
||||
const durationMs = !isRunning
|
||||
? event.durationMs ??
|
||||
existing?.durationMs ??
|
||||
(startedAt !== undefined && endedAt !== undefined
|
||||
? Math.max(0, endedAt - startedAt)
|
||||
: undefined)
|
||||
: undefined;
|
||||
const nextItem: ChatProgress = {
|
||||
id: event.id,
|
||||
phase: event.phase,
|
||||
status: event.status,
|
||||
title: event.title,
|
||||
detail: event.detail,
|
||||
startedAt,
|
||||
endedAt,
|
||||
elapsedMs,
|
||||
elapsedSnapshotAt,
|
||||
durationMs,
|
||||
};
|
||||
if (index >= 0) {
|
||||
next[index] = nextItem;
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
||||
progress?.map((item) => {
|
||||
if (item.status !== "running") {
|
||||
return item;
|
||||
}
|
||||
const endedAt = Date.now();
|
||||
return {
|
||||
...item,
|
||||
status: "completed" as const,
|
||||
endedAt,
|
||||
elapsedMs: undefined,
|
||||
elapsedSnapshotAt: undefined,
|
||||
durationMs:
|
||||
item.durationMs ??
|
||||
(item.startedAt !== undefined
|
||||
? Math.max(0, endedAt - item.startedAt)
|
||||
: item.elapsedMs),
|
||||
};
|
||||
});
|
||||
|
||||
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
|
||||
todoUpdate
|
||||
? {
|
||||
...todoUpdate,
|
||||
todos: todoUpdate.todos.map((todo) =>
|
||||
todo.status === "pending" || todo.status === "in_progress"
|
||||
? {
|
||||
...todo,
|
||||
status: "cancelled" as const,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: todo,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
export const upsertPermission = (
|
||||
permissions: AgentPermissionRequest[] | undefined,
|
||||
event: StreamEvent & { type: "permission_request" },
|
||||
) => {
|
||||
const next = [...(permissions ?? [])];
|
||||
const index = next.findIndex((item) => item.requestId === event.requestId);
|
||||
const nextItem: AgentPermissionRequest = {
|
||||
requestId: event.requestId,
|
||||
sessionId: event.sessionId,
|
||||
permission: event.permission,
|
||||
patterns: event.patterns,
|
||||
metadata: event.metadata,
|
||||
always: event.always,
|
||||
tool: event.tool,
|
||||
createdAt: event.createdAt,
|
||||
status: "pending",
|
||||
};
|
||||
if (index >= 0) {
|
||||
next[index] = {
|
||||
...next[index],
|
||||
...nextItem,
|
||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
|
||||
};
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
|
||||
if (reply === "always") return "approved_always";
|
||||
if (reply === "once") return "approved_once";
|
||||
return "rejected";
|
||||
};
|
||||
|
||||
export const isActionableQuestionRequest = (question: {
|
||||
requestId: string;
|
||||
tool?: AgentQuestionRequest["tool"];
|
||||
}) => Boolean(question.requestId && question.requestId !== question.tool?.callID);
|
||||
|
||||
export const toQuestionRequest = (
|
||||
event: StreamEvent & { type: "question_request" },
|
||||
status: AgentQuestionRequest["status"] = "pending",
|
||||
): AgentQuestionRequest => ({
|
||||
requestId: event.requestId,
|
||||
sessionId: event.sessionId,
|
||||
questions: event.questions,
|
||||
tool: event.tool,
|
||||
createdAt: event.createdAt,
|
||||
status,
|
||||
});
|
||||
|
||||
export const getQuestionContentSignature = (
|
||||
questions: AgentQuestionRequest["questions"],
|
||||
) =>
|
||||
JSON.stringify(
|
||||
questions.map((question) => ({
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
options: question.options.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
})),
|
||||
multiple: question.multiple ?? false,
|
||||
custom: question.custom !== false,
|
||||
})),
|
||||
);
|
||||
|
||||
export const isSameQuestionRequest = (
|
||||
question: AgentQuestionRequest,
|
||||
event: StreamEvent & { type: "question_request" },
|
||||
) => {
|
||||
if (question.requestId === event.requestId) return true;
|
||||
if (question.tool?.callID && event.tool?.callID) {
|
||||
return question.tool.callID === event.tool.callID;
|
||||
}
|
||||
return (
|
||||
question.status === "pending" &&
|
||||
question.sessionId === event.sessionId &&
|
||||
getQuestionContentSignature(question.questions) ===
|
||||
getQuestionContentSignature(event.questions)
|
||||
);
|
||||
};
|
||||
|
||||
export const isSameQuestionPair = (
|
||||
left: AgentQuestionRequest,
|
||||
right: AgentQuestionRequest,
|
||||
) => {
|
||||
if (left.requestId === right.requestId) return true;
|
||||
if (left.tool?.callID && right.tool?.callID) {
|
||||
return left.tool.callID === right.tool.callID;
|
||||
}
|
||||
return (
|
||||
left.status === "pending" &&
|
||||
right.status === "pending" &&
|
||||
left.sessionId === right.sessionId &&
|
||||
getQuestionContentSignature(left.questions) ===
|
||||
getQuestionContentSignature(right.questions)
|
||||
);
|
||||
};
|
||||
|
||||
export const dedupeQuestionsAcrossMessages = (messages: Message[]) => {
|
||||
const seen: AgentQuestionRequest[] = [];
|
||||
let changed = false;
|
||||
const nextMessages = messages.map((message) => {
|
||||
if (!message.questions?.length) {
|
||||
return message;
|
||||
}
|
||||
const nextQuestions = message.questions.filter((question) => {
|
||||
if (seen.some((existing) => isSameQuestionPair(existing, question))) {
|
||||
changed = true;
|
||||
return false;
|
||||
}
|
||||
seen.push(question);
|
||||
return true;
|
||||
});
|
||||
if (nextQuestions.length === message.questions.length) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
questions: nextQuestions.length ? nextQuestions : undefined,
|
||||
};
|
||||
});
|
||||
return changed ? nextMessages : messages;
|
||||
};
|
||||
|
||||
export const upsertQuestionAcrossMessages = (
|
||||
messages: Message[],
|
||||
event: StreamEvent & { type: "question_request" },
|
||||
assistantMessageId: string,
|
||||
) => {
|
||||
let existing: AgentQuestionRequest | undefined;
|
||||
for (const message of messages) {
|
||||
const match = message.questions?.find((question) =>
|
||||
isSameQuestionRequest(question, event),
|
||||
);
|
||||
if (match) {
|
||||
existing = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const existingStatus: AgentQuestionRequest["status"] | undefined =
|
||||
existing?.status === "submitting" ? "submitting" : undefined;
|
||||
const nextQuestion =
|
||||
existing &&
|
||||
isActionableQuestionRequest(existing) &&
|
||||
!isActionableQuestionRequest(event)
|
||||
? {
|
||||
...existing,
|
||||
sessionId: event.sessionId,
|
||||
questions: event.questions,
|
||||
tool: event.tool ?? existing.tool,
|
||||
createdAt: event.createdAt,
|
||||
status: existingStatus ?? existing.status,
|
||||
}
|
||||
: toQuestionRequest(event, existingStatus ?? "pending");
|
||||
const targetMessageId = existing
|
||||
? messages.find((message) =>
|
||||
message.questions?.some((question) => isSameQuestionRequest(question, event)),
|
||||
)?.id ?? assistantMessageId
|
||||
: assistantMessageId;
|
||||
|
||||
return messages.map((message) => {
|
||||
const filteredQuestions = message.questions?.filter(
|
||||
(question) => !isSameQuestionRequest(question, event),
|
||||
);
|
||||
if (message.id !== targetMessageId) {
|
||||
return filteredQuestions?.length === message.questions?.length
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: filteredQuestions?.length ? filteredQuestions : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const nextQuestions = [...(filteredQuestions ?? []), nextQuestion];
|
||||
return {
|
||||
...message,
|
||||
questions: nextQuestions,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const applyQuestionResponse = (
|
||||
questions: AgentQuestionRequest[] | undefined,
|
||||
event: StreamEvent & { type: "question_response" },
|
||||
) =>
|
||||
(questions ?? []).map((question) =>
|
||||
question.requestId === event.requestId
|
||||
? {
|
||||
...question,
|
||||
status: event.rejected ? "rejected" as const : "answered" as const,
|
||||
answers: event.answers ?? question.answers,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}
|
||||
: question,
|
||||
);
|
||||
|
||||
export const createTodoUpdateFromEvent = (
|
||||
event: StreamEvent & { type: "todo_update" },
|
||||
): AgentTodoUpdate => ({
|
||||
sessionId: event.sessionId,
|
||||
messageId: event.messageId,
|
||||
todos: event.todos,
|
||||
createdAt: event.createdAt,
|
||||
});
|
||||
|
||||
export const normalizeSessionTodos = (
|
||||
messages: Message[],
|
||||
nextTodoUpdate?: AgentTodoUpdate,
|
||||
targetAssistantMessageId?: string,
|
||||
) => {
|
||||
let latestTodoUpdate = nextTodoUpdate;
|
||||
if (!latestTodoUpdate) {
|
||||
for (const message of messages) {
|
||||
if (message.todos) {
|
||||
latestTodoUpdate = message.todos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestTodoUpdate) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const targetMessageId =
|
||||
targetAssistantMessageId ??
|
||||
[...messages].reverse().find((message) => message.role === "assistant")?.id;
|
||||
if (!targetMessageId) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextMessages = messages.map((message) => {
|
||||
if (message.id === targetMessageId) {
|
||||
if (message.todos === latestTodoUpdate) {
|
||||
return message;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...message,
|
||||
todos: latestTodoUpdate,
|
||||
};
|
||||
}
|
||||
if (!message.todos) {
|
||||
return message;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...message,
|
||||
todos: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return changed ? nextMessages : messages;
|
||||
};
|
||||
|
||||
export const rejectOpenPermissionsAfterAbort = (
|
||||
permissions: AgentPermissionRequest[] | undefined,
|
||||
) => {
|
||||
if (!permissions?.length) return permissions;
|
||||
let changed = false;
|
||||
const nextPermissions = permissions.map((permission) => {
|
||||
if (
|
||||
permission.status !== "pending" &&
|
||||
permission.status !== "submitting" &&
|
||||
permission.status !== "error"
|
||||
) {
|
||||
return permission;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...permission,
|
||||
status: "rejected" as const,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
return changed ? nextPermissions : permissions;
|
||||
};
|
||||
|
||||
export const rejectOpenQuestionsAfterAbort = (
|
||||
questions: AgentQuestionRequest[] | undefined,
|
||||
) => {
|
||||
if (!questions?.length) return questions;
|
||||
let changed = false;
|
||||
const nextQuestions = questions.map((question) => {
|
||||
if (
|
||||
question.status !== "pending" &&
|
||||
question.status !== "submitting" &&
|
||||
question.status !== "error"
|
||||
) {
|
||||
return question;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...question,
|
||||
status: "rejected" as const,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
return changed ? nextQuestions : questions;
|
||||
};
|
||||
|
||||
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||
const completedProgress = completeRunningProgress(message.progress);
|
||||
const cancelledTodos = cancelRunningTodos(message.todos);
|
||||
const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions);
|
||||
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
||||
const hasVisibleOutput =
|
||||
message.content.trim().length > 0 ||
|
||||
Boolean(message.artifacts?.length) ||
|
||||
Boolean(rejectedPermissions?.length) ||
|
||||
Boolean(rejectedQuestions?.length) ||
|
||||
Boolean(completedProgress?.length) ||
|
||||
Boolean(cancelledTodos);
|
||||
|
||||
if (!hasVisibleOutput) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: message.content || "⚠️ **请求已中断**",
|
||||
isError: true,
|
||||
progress: completedProgress,
|
||||
permissions: rejectedPermissions,
|
||||
questions: rejectedQuestions,
|
||||
todos: cancelledTodos,
|
||||
};
|
||||
};
|
||||
|
||||
export const createUserMessage = (content: string): Message => {
|
||||
const id = createId();
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
content,
|
||||
};
|
||||
};
|
||||
|
||||
export const createAssistantMessage = (): Message => ({
|
||||
id: createId(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,791 @@
|
||||
"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 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<string | undefined>((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("persists a new conversation only 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<void>(() => 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);
|
||||
});
|
||||
|
||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
emitStreamEvent?.({
|
||||
type: "token",
|
||||
sessionId: "chat-stream-1",
|
||||
content: "收到",
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
emitStreamEvent?.({
|
||||
type: "done",
|
||||
sessionId: "chat-stream-1",
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1));
|
||||
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
|
||||
sessionId: "chat-stream-1",
|
||||
messages: [
|
||||
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
||||
expect.objectContaining({ role: "assistant", content: "收到" }),
|
||||
],
|
||||
});
|
||||
} 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<void>(() => 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");
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,509 +2,13 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
abortAgentChat,
|
||||
forkAgentChat,
|
||||
rejectAgentQuestion,
|
||||
replyAgentPermission,
|
||||
replyAgentQuestion,
|
||||
resumeAgentChatStream,
|
||||
streamAgentChat,
|
||||
} from "@/lib/chatStream";
|
||||
import type {
|
||||
AgentApprovalMode,
|
||||
AgentModel,
|
||||
AgentQuestionRequest,
|
||||
AgentTodoUpdate,
|
||||
PermissionReply,
|
||||
StreamEvent,
|
||||
} from "@/lib/chatStream";
|
||||
import type {
|
||||
AgentArtifact,
|
||||
AgentPermissionRequest,
|
||||
ChatProgress,
|
||||
ChatSessionSummary,
|
||||
LoadedChatState,
|
||||
Message,
|
||||
} from "../GlobalChatbox.types";
|
||||
import {
|
||||
cloneMessages,
|
||||
createId,
|
||||
} from "../GlobalChatbox.utils";
|
||||
import {
|
||||
createEmptyChatState,
|
||||
deleteChatSession,
|
||||
listChatSessions,
|
||||
loadChatSessionById,
|
||||
saveActiveChatState,
|
||||
updateChatSessionTitle,
|
||||
} from "../chatStorage";
|
||||
|
||||
type UseAgentChatSessionOptions = {
|
||||
projectId?: string | null;
|
||||
onToolCall: (
|
||||
event: StreamEvent & { type: "tool_call" },
|
||||
options: {
|
||||
assistantMessageId: string;
|
||||
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
|
||||
},
|
||||
) => void;
|
||||
onBeforeSend?: () => void;
|
||||
getModel?: () => AgentModel;
|
||||
getApprovalMode?: () => AgentApprovalMode;
|
||||
};
|
||||
|
||||
type PromptRunOptions = {
|
||||
prompt: string;
|
||||
sessionIdOverride?: string;
|
||||
regenerateFromMessageIndex?: number;
|
||||
preparedMessages?: Message[];
|
||||
userMessage?: Message;
|
||||
assistantMessage?: Message;
|
||||
};
|
||||
|
||||
const createPersistedStateKey = (state: LoadedChatState) =>
|
||||
JSON.stringify({
|
||||
title: state.title ?? null,
|
||||
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
|
||||
sessionId: state.sessionId ?? null,
|
||||
messages: state.messages,
|
||||
});
|
||||
|
||||
const upsertProgress = (
|
||||
progress: ChatProgress[] | undefined,
|
||||
event: StreamEvent & { type: "progress" },
|
||||
) => {
|
||||
const next = [...(progress ?? [])];
|
||||
const index = next.findIndex((item) => item.id === event.id);
|
||||
const existing = index >= 0 ? next[index] : undefined;
|
||||
const now = Date.now();
|
||||
const startedAt = event.startedAt ?? existing?.startedAt;
|
||||
const isRunning = event.status === "running";
|
||||
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
|
||||
const elapsedMs = isRunning
|
||||
? event.elapsedMs ??
|
||||
existing?.elapsedMs ??
|
||||
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
|
||||
: undefined;
|
||||
const elapsedSnapshotAt = isRunning
|
||||
? event.elapsedMs !== undefined
|
||||
? now
|
||||
: existing?.elapsedSnapshotAt ?? now
|
||||
: undefined;
|
||||
const durationMs = !isRunning
|
||||
? event.durationMs ??
|
||||
existing?.durationMs ??
|
||||
(startedAt !== undefined && endedAt !== undefined
|
||||
? Math.max(0, endedAt - startedAt)
|
||||
: undefined)
|
||||
: undefined;
|
||||
const nextItem: ChatProgress = {
|
||||
id: event.id,
|
||||
phase: event.phase,
|
||||
status: event.status,
|
||||
title: event.title,
|
||||
detail: event.detail,
|
||||
startedAt,
|
||||
endedAt,
|
||||
elapsedMs,
|
||||
elapsedSnapshotAt,
|
||||
durationMs,
|
||||
};
|
||||
if (index >= 0) {
|
||||
next[index] = nextItem;
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
||||
progress?.map((item) => {
|
||||
if (item.status !== "running") {
|
||||
return item;
|
||||
}
|
||||
const endedAt = Date.now();
|
||||
return {
|
||||
...item,
|
||||
status: "completed" as const,
|
||||
endedAt,
|
||||
elapsedMs: undefined,
|
||||
elapsedSnapshotAt: undefined,
|
||||
durationMs:
|
||||
item.durationMs ??
|
||||
(item.startedAt !== undefined
|
||||
? Math.max(0, endedAt - item.startedAt)
|
||||
: item.elapsedMs),
|
||||
};
|
||||
});
|
||||
|
||||
const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
|
||||
todoUpdate
|
||||
? {
|
||||
...todoUpdate,
|
||||
todos: todoUpdate.todos.map((todo) =>
|
||||
todo.status === "pending" || todo.status === "in_progress"
|
||||
? {
|
||||
...todo,
|
||||
status: "cancelled" as const,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: todo,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const upsertPermission = (
|
||||
permissions: AgentPermissionRequest[] | undefined,
|
||||
event: StreamEvent & { type: "permission_request" },
|
||||
) => {
|
||||
const next = [...(permissions ?? [])];
|
||||
const index = next.findIndex((item) => item.requestId === event.requestId);
|
||||
const nextItem: AgentPermissionRequest = {
|
||||
requestId: event.requestId,
|
||||
sessionId: event.sessionId,
|
||||
permission: event.permission,
|
||||
patterns: event.patterns,
|
||||
metadata: event.metadata,
|
||||
always: event.always,
|
||||
tool: event.tool,
|
||||
createdAt: event.createdAt,
|
||||
status: "pending",
|
||||
};
|
||||
if (index >= 0) {
|
||||
next[index] = {
|
||||
...next[index],
|
||||
...nextItem,
|
||||
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
|
||||
};
|
||||
} else {
|
||||
next.push(nextItem);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
|
||||
if (reply === "always") return "approved_always";
|
||||
if (reply === "once") return "approved_once";
|
||||
return "rejected";
|
||||
};
|
||||
|
||||
const isActionableQuestionRequest = (question: {
|
||||
requestId: string;
|
||||
tool?: AgentQuestionRequest["tool"];
|
||||
}) => Boolean(question.requestId && question.requestId !== question.tool?.callID);
|
||||
|
||||
const toQuestionRequest = (
|
||||
event: StreamEvent & { type: "question_request" },
|
||||
status: AgentQuestionRequest["status"] = "pending",
|
||||
): AgentQuestionRequest => ({
|
||||
requestId: event.requestId,
|
||||
sessionId: event.sessionId,
|
||||
questions: event.questions,
|
||||
tool: event.tool,
|
||||
createdAt: event.createdAt,
|
||||
status,
|
||||
});
|
||||
|
||||
const getQuestionContentSignature = (
|
||||
questions: AgentQuestionRequest["questions"],
|
||||
) =>
|
||||
JSON.stringify(
|
||||
questions.map((question) => ({
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
options: question.options.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
})),
|
||||
multiple: question.multiple ?? false,
|
||||
custom: question.custom !== false,
|
||||
})),
|
||||
);
|
||||
|
||||
const isSameQuestionRequest = (
|
||||
question: AgentQuestionRequest,
|
||||
event: StreamEvent & { type: "question_request" },
|
||||
) => {
|
||||
if (question.requestId === event.requestId) return true;
|
||||
if (question.tool?.callID && event.tool?.callID) {
|
||||
return question.tool.callID === event.tool.callID;
|
||||
}
|
||||
return (
|
||||
question.status === "pending" &&
|
||||
question.sessionId === event.sessionId &&
|
||||
getQuestionContentSignature(question.questions) ===
|
||||
getQuestionContentSignature(event.questions)
|
||||
);
|
||||
};
|
||||
|
||||
const isSameQuestionPair = (
|
||||
left: AgentQuestionRequest,
|
||||
right: AgentQuestionRequest,
|
||||
) => {
|
||||
if (left.requestId === right.requestId) return true;
|
||||
if (left.tool?.callID && right.tool?.callID) {
|
||||
return left.tool.callID === right.tool.callID;
|
||||
}
|
||||
return (
|
||||
left.status === "pending" &&
|
||||
right.status === "pending" &&
|
||||
left.sessionId === right.sessionId &&
|
||||
getQuestionContentSignature(left.questions) ===
|
||||
getQuestionContentSignature(right.questions)
|
||||
);
|
||||
};
|
||||
|
||||
const dedupeQuestionsAcrossMessages = (messages: Message[]) => {
|
||||
const seen: AgentQuestionRequest[] = [];
|
||||
let changed = false;
|
||||
const nextMessages = messages.map((message) => {
|
||||
if (!message.questions?.length) {
|
||||
return message;
|
||||
}
|
||||
const nextQuestions = message.questions.filter((question) => {
|
||||
if (seen.some((existing) => isSameQuestionPair(existing, question))) {
|
||||
changed = true;
|
||||
return false;
|
||||
}
|
||||
seen.push(question);
|
||||
return true;
|
||||
});
|
||||
if (nextQuestions.length === message.questions.length) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
questions: nextQuestions.length ? nextQuestions : undefined,
|
||||
};
|
||||
});
|
||||
return changed ? nextMessages : messages;
|
||||
};
|
||||
|
||||
const upsertQuestionAcrossMessages = (
|
||||
messages: Message[],
|
||||
event: StreamEvent & { type: "question_request" },
|
||||
assistantMessageId: string,
|
||||
) => {
|
||||
let existing: AgentQuestionRequest | undefined;
|
||||
for (const message of messages) {
|
||||
const match = message.questions?.find((question) =>
|
||||
isSameQuestionRequest(question, event),
|
||||
);
|
||||
if (match) {
|
||||
existing = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const existingStatus: AgentQuestionRequest["status"] | undefined =
|
||||
existing?.status === "submitting" ? "submitting" : undefined;
|
||||
const nextQuestion =
|
||||
existing &&
|
||||
isActionableQuestionRequest(existing) &&
|
||||
!isActionableQuestionRequest(event)
|
||||
? {
|
||||
...existing,
|
||||
sessionId: event.sessionId,
|
||||
questions: event.questions,
|
||||
tool: event.tool ?? existing.tool,
|
||||
createdAt: event.createdAt,
|
||||
status: existingStatus ?? existing.status,
|
||||
}
|
||||
: toQuestionRequest(event, existingStatus ?? "pending");
|
||||
const targetMessageId = existing
|
||||
? messages.find((message) =>
|
||||
message.questions?.some((question) => isSameQuestionRequest(question, event)),
|
||||
)?.id ?? assistantMessageId
|
||||
: assistantMessageId;
|
||||
|
||||
return messages.map((message) => {
|
||||
const filteredQuestions = message.questions?.filter(
|
||||
(question) => !isSameQuestionRequest(question, event),
|
||||
);
|
||||
if (message.id !== targetMessageId) {
|
||||
return filteredQuestions?.length === message.questions?.length
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
questions: filteredQuestions?.length ? filteredQuestions : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const nextQuestions = [...(filteredQuestions ?? []), nextQuestion];
|
||||
return {
|
||||
...message,
|
||||
questions: nextQuestions,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const applyQuestionResponse = (
|
||||
questions: AgentQuestionRequest[] | undefined,
|
||||
event: StreamEvent & { type: "question_response" },
|
||||
) =>
|
||||
(questions ?? []).map((question) =>
|
||||
question.requestId === event.requestId
|
||||
? {
|
||||
...question,
|
||||
status: event.rejected ? "rejected" as const : "answered" as const,
|
||||
answers: event.answers ?? question.answers,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
}
|
||||
: question,
|
||||
);
|
||||
|
||||
const createTodoUpdateFromEvent = (
|
||||
event: StreamEvent & { type: "todo_update" },
|
||||
): AgentTodoUpdate => ({
|
||||
sessionId: event.sessionId,
|
||||
messageId: event.messageId,
|
||||
todos: event.todos,
|
||||
createdAt: event.createdAt,
|
||||
});
|
||||
|
||||
const normalizeSessionTodos = (
|
||||
messages: Message[],
|
||||
nextTodoUpdate?: AgentTodoUpdate,
|
||||
targetAssistantMessageId?: string,
|
||||
) => {
|
||||
let latestTodoUpdate = nextTodoUpdate;
|
||||
if (!latestTodoUpdate) {
|
||||
for (const message of messages) {
|
||||
if (message.todos) {
|
||||
latestTodoUpdate = message.todos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestTodoUpdate) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const targetMessageId =
|
||||
targetAssistantMessageId ??
|
||||
[...messages].reverse().find((message) => message.role === "assistant")?.id;
|
||||
if (!targetMessageId) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextMessages = messages.map((message) => {
|
||||
if (message.id === targetMessageId) {
|
||||
if (message.todos === latestTodoUpdate) {
|
||||
return message;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...message,
|
||||
todos: latestTodoUpdate,
|
||||
};
|
||||
}
|
||||
if (!message.todos) {
|
||||
return message;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...message,
|
||||
todos: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return changed ? nextMessages : messages;
|
||||
};
|
||||
|
||||
const rejectOpenPermissionsAfterAbort = (
|
||||
permissions: AgentPermissionRequest[] | undefined,
|
||||
) => {
|
||||
if (!permissions?.length) return permissions;
|
||||
let changed = false;
|
||||
const nextPermissions = permissions.map((permission) => {
|
||||
if (
|
||||
permission.status !== "pending" &&
|
||||
permission.status !== "submitting" &&
|
||||
permission.status !== "error"
|
||||
) {
|
||||
return permission;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...permission,
|
||||
status: "rejected" as const,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
return changed ? nextPermissions : permissions;
|
||||
};
|
||||
|
||||
const rejectOpenQuestionsAfterAbort = (
|
||||
questions: AgentQuestionRequest[] | undefined,
|
||||
) => {
|
||||
if (!questions?.length) return questions;
|
||||
let changed = false;
|
||||
const nextQuestions = questions.map((question) => {
|
||||
if (
|
||||
question.status !== "pending" &&
|
||||
question.status !== "submitting" &&
|
||||
question.status !== "error"
|
||||
) {
|
||||
return question;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...question,
|
||||
status: "rejected" as const,
|
||||
repliedAt: Date.now(),
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
return changed ? nextQuestions : questions;
|
||||
};
|
||||
|
||||
const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||
const completedProgress = completeRunningProgress(message.progress);
|
||||
const cancelledTodos = cancelRunningTodos(message.todos);
|
||||
const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions);
|
||||
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
||||
const hasVisibleOutput =
|
||||
message.content.trim().length > 0 ||
|
||||
Boolean(message.artifacts?.length) ||
|
||||
Boolean(rejectedPermissions?.length) ||
|
||||
Boolean(rejectedQuestions?.length) ||
|
||||
Boolean(completedProgress?.length) ||
|
||||
Boolean(cancelledTodos);
|
||||
|
||||
if (!hasVisibleOutput) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: message.content || "⚠️ **请求已中断**",
|
||||
isError: true,
|
||||
progress: completedProgress,
|
||||
permissions: rejectedPermissions,
|
||||
questions: rejectedQuestions,
|
||||
todos: cancelledTodos,
|
||||
};
|
||||
};
|
||||
|
||||
const createUserMessage = (content: string): Message => {
|
||||
const id = createId();
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
content,
|
||||
};
|
||||
};
|
||||
|
||||
const createAssistantMessage = (): Message => ({
|
||||
id: createId(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
});
|
||||
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
||||
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||
import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types";
|
||||
import { cloneMessages } from "../GlobalChatbox.utils";
|
||||
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage";
|
||||
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||||
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
||||
|
||||
export const useAgentChatSession = ({
|
||||
projectId,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AgentApprovalMode, AgentModel, StreamEvent } from "@/lib/chatStream";
|
||||
import type { AgentArtifact, Message } from "../GlobalChatbox.types";
|
||||
|
||||
export type UseAgentChatSessionOptions = {
|
||||
projectId?: string | null;
|
||||
onToolCall: (
|
||||
event: StreamEvent & { type: "tool_call" },
|
||||
options: {
|
||||
assistantMessageId: string;
|
||||
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
|
||||
},
|
||||
) => void;
|
||||
onBeforeSend?: () => void;
|
||||
getModel?: () => AgentModel;
|
||||
getApprovalMode?: () => AgentApprovalMode;
|
||||
};
|
||||
|
||||
export type PromptRunOptions = {
|
||||
prompt: string;
|
||||
sessionIdOverride?: string;
|
||||
regenerateFromMessageIndex?: number;
|
||||
preparedMessages?: Message[];
|
||||
userMessage?: Message;
|
||||
assistantMessage?: Message;
|
||||
};
|
||||
Reference in New Issue
Block a user