diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts index 1575f86..7ebe12a 100644 --- a/src/components/chat/chatStorage.test.ts +++ b/src/components/chat/chatStorage.test.ts @@ -1,6 +1,9 @@ import { createEmptyChatState, - saveActiveChatState, + deleteChatSession, + listChatSessions, + loadChatSessionById, + updateChatSessionTitle, } from "./chatStorage"; const apiFetch = jest.fn(); @@ -9,7 +12,7 @@ jest.mock("@/lib/apiFetch", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args), })); -describe("chatStorage backend-only persistence", () => { +describe("chatStorage backend session operations", () => { beforeEach(() => { apiFetch.mockReset(); }); @@ -25,46 +28,106 @@ describe("chatStorage backend-only persistence", () => { expect(apiFetch).not.toHaveBeenCalled(); }); - it("creates a backend conversation when saving the first non-empty state", async () => { - apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { - if (url.endsWith("/api/v1/agent/chat/session")) { - expect(init?.method).toBe("POST"); - return { - ok: true, - json: async () => ({ session_id: "chat-new-1" }), - } as Response; - } - - if (url.endsWith("/api/v1/agent/chat/session/chat-new-1")) { - expect(init?.method).toBe("PUT"); - expect(JSON.parse(String(init?.body))).toMatchObject({ - title: "新对话", - is_title_manually_edited: false, - }); - return { - ok: true, - json: async () => ({ id: "chat-new-1", session_id: "chat-new-1" }), - } as Response; - } - - throw new Error(`Unexpected request ${url}`); - }); - - const savedSessionId = await saveActiveChatState( - { - title: "新对话", - isTitleManuallyEdited: false, - messages: [ + it("lists backend sessions sorted by created time", async () => { + apiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sessions: [ { - id: "message-2", - role: "user", - content: "第一条消息", + id: "session-old", + title: "旧会话", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-02T00:00:00.000Z", + }, + { + id: "session-new", + title: "新会话", + created_at: "2026-01-03T00:00:00.000Z", + updated_at: "2026-01-03T00:00:00.000Z", + is_streaming: true, + run_status: "running", }, ], - sessionId: undefined, - }, - ); + }), + }); - expect(savedSessionId).toBe("chat-new-1"); + await expect(listChatSessions()).resolves.toEqual([ + expect.objectContaining({ + id: "session-new", + title: "新会话", + isStreaming: true, + runStatus: "running", + }), + expect.objectContaining({ + id: "session-old", + title: "旧会话", + }), + ]); + expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" }); + }); + + it("loads a backend session state", async () => { + apiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "session-1", + title: "管网分析", + is_title_manually_edited: true, + messages: [{ id: "message-1", role: "user", content: "查压力" }], + is_streaming: false, + }), + }); + + await expect(loadChatSessionById("session-1")).resolves.toMatchObject({ + title: "管网分析", + isTitleManuallyEdited: true, + sessionId: "session-1", + messages: [{ id: "message-1", role: "user", content: "查压力" }], + }); + expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1"); + expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" }); + }); + + it("updates a backend session title through the title endpoint", async () => { + apiFetch.mockResolvedValueOnce({ + ok: true, + text: async () => "", + }); + + await updateChatSessionTitle("session-1", " 新标题 ", { + isTitleManuallyEdited: true, + }); + + expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title"); + expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" }); + expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({ + title: "新标题", + is_title_manually_edited: true, + }); + }); + + it("deletes a backend session and returns the next active session id", async () => { + apiFetch + .mockResolvedValueOnce({ + ok: true, + text: async () => "", + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sessions: [ + { + id: "session-next", + title: "下一会话", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }, + ], + }), + }); + + await expect(deleteChatSession("session-1")).resolves.toBe("session-next"); + expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "DELETE" }); + expect(apiFetch.mock.calls[1][1]).toMatchObject({ method: "GET" }); }); }); diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 30ee6ff..dc50618 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -27,13 +27,6 @@ export const createEmptyChatState = (): LoadedChatState => ({ const sanitizeMessages = (messages: Message[] | undefined) => Array.isArray(messages) ? cloneMessages(messages) : []; -const hasChatContent = (state: { - messages: Message[]; - sessionId?: string; -}) => - state.messages.length > 0 || - Boolean(state.sessionId); - const compareSessionsByAnchorTime = ( left: Pick, right: Pick, @@ -113,64 +106,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise { - const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/session`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - session_id: payload?.sessionId, - parent_session_id: payload?.parentSessionId, - }), - projectHeaderMode: "include", - userHeaderMode: "include", - skipAuthRedirect: true, - }); - if (!response.ok) { - throw new Error(await response.text()); - } - const body = (await response.json()) as { - session_id?: string; - }; - const sessionId = body.session_id?.trim(); - if (!sessionId) { - throw new Error("backend did not return session_id"); - } - return sessionId; -}; - -const saveBackendChatState = async ( - sessionId: string, - state: LoadedChatState, -): Promise => { - const response = await apiFetch( - `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - title: normalizeTitle(state.title), - is_title_manually_edited: state.isTitleManuallyEdited ?? false, - messages: sanitizeMessages(state.messages), - }), - projectHeaderMode: "include", - userHeaderMode: "include", - skipAuthRedirect: true, - }, - ); - if (!response.ok) { - throw new Error(await response.text()); - } - const payload = (await response.json()) as { id?: string; session_id?: string }; - return payload.id ?? payload.session_id ?? sessionId; -}; - const updateBackendChatSessionTitle = async ( sessionId: string, title: string, @@ -212,27 +147,6 @@ const deleteBackendChatSession = async (sessionId: string) => { } }; -export const saveActiveChatState = async ( - state: LoadedChatState, -): Promise => { - if (typeof window === "undefined") return state.sessionId; - - if (!hasChatContent(state)) { - return undefined; - } - - let backendSessionId = state.sessionId; - if (!backendSessionId) { - backendSessionId = await createBackendChatSession(); - } - - const savedSessionId = await saveBackendChatState(backendSessionId, { - ...state, - sessionId: backendSessionId, - }); - return savedSessionId; -}; - export const listChatSessions = async (): Promise => { if (typeof window === "undefined") return []; return await fetchBackendChatSessions(); diff --git a/src/components/chat/hooks/agentChatSessionState.ts b/src/components/chat/hooks/agentChatSessionState.ts index 2961f9e..7393b4f 100644 --- a/src/components/chat/hooks/agentChatSessionState.ts +++ b/src/components/chat/hooks/agentChatSessionState.ts @@ -7,19 +7,10 @@ import type { 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" }, diff --git a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx index 5b7e868..d7a5145 100644 --- a/src/components/chat/hooks/useAgentChatSession.actions.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.actions.test.tsx @@ -24,7 +24,6 @@ jest.mock("@/lib/chatStream", () => ({ const listChatSessions = jest.fn(); const deleteChatSession = jest.fn(); -const saveActiveChatState = jest.fn(); const updateChatSessionTitle = jest.fn(); jest.mock("../chatStorage", () => ({ @@ -42,7 +41,6 @@ jest.mock("../chatStorage", () => ({ messages: [], sessionId: "session-loaded", })), - saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), })); @@ -50,7 +48,6 @@ describe("useAgentChatSession", () => { beforeEach(() => { listChatSessions.mockReset(); deleteChatSession.mockReset(); - saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(abortAgentChat).mockReset(); jest.mocked(forkAgentChat).mockReset(); @@ -65,7 +62,6 @@ describe("useAgentChatSession", () => { 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); }); diff --git a/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx index 4601ec0..b204bb2 100644 --- a/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx @@ -24,7 +24,6 @@ jest.mock("@/lib/chatStream", () => ({ const listChatSessions = jest.fn(); const deleteChatSession = jest.fn(); -const saveActiveChatState = jest.fn(); const updateChatSessionTitle = jest.fn(); jest.mock("../chatStorage", () => ({ @@ -42,7 +41,6 @@ jest.mock("../chatStorage", () => ({ messages: [], sessionId: "session-loaded", })), - saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), })); @@ -50,7 +48,6 @@ describe("useAgentChatSession", () => { beforeEach(() => { listChatSessions.mockReset(); deleteChatSession.mockReset(); - saveActiveChatState.mockReset(); updateChatSessionTitle.mockReset(); jest.mocked(abortAgentChat).mockReset(); jest.mocked(forkAgentChat).mockReset(); @@ -65,7 +62,6 @@ describe("useAgentChatSession", () => { 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); }); @@ -190,7 +186,7 @@ describe("useAgentChatSession lifecycle and resume", () => { ); }); - it("persists a new conversation only after the stream is done", async () => { + 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 }) => { @@ -220,8 +216,6 @@ describe("useAgentChatSession lifecycle and resume", () => { jest.advanceTimersByTime(200); }); - expect(saveActiveChatState).not.toHaveBeenCalled(); - act(() => { emitStreamEvent?.({ type: "token", @@ -234,8 +228,6 @@ describe("useAgentChatSession lifecycle and resume", () => { jest.advanceTimersByTime(200); }); - expect(saveActiveChatState).not.toHaveBeenCalled(); - act(() => { emitStreamEvent?.({ type: "done", @@ -247,14 +239,11 @@ describe("useAgentChatSession lifecycle and resume", () => { 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: "收到" }), - ], - }); + 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(); } diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index b99fad9..0c5b9d9 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -4,10 +4,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; 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 type { AgentArtifact, ChatSessionSummary, 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 { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage"; +import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState"; import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types"; export const useAgentChatSession = ({ @@ -17,7 +17,6 @@ export const useAgentChatSession = ({ getModel, getApprovalMode, }: UseAgentChatSessionOptions) => { - const hydrationCompletedRef = useRef(false); const hydrationNonceRef = useRef(0); const [messages, setMessages] = useState([]); @@ -35,14 +34,6 @@ export const useAgentChatSession = ({ const isSessionTitleManuallyEditedRef = useRef(false); const cancelPromiseRef = useRef | null>(null); const titleUpdateNonceRef = useRef(0); - const lastPersistedStateKeyRef = useRef( - createPersistedStateKey({ - sessionId: undefined, - title: undefined, - isTitleManuallyEdited: false, - messages: [], - }), - ); useEffect(() => { sessionIdRef.current = sessionId; @@ -62,17 +53,9 @@ export const useAgentChatSession = ({ const hydrate = async () => { setIsHydrating(true); - hydrationCompletedRef.current = false; if (!projectId) { sessionIdRef.current = undefined; - lastPersistedStateKeyRef.current = createPersistedStateKey({ - title: undefined, - isTitleManuallyEdited: false, - messages: [], - sessionId: undefined, - }); - hydrationCompletedRef.current = true; hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; setMessages([]); @@ -93,8 +76,6 @@ export const useAgentChatSession = ({ if (cancelled) return; sessionIdRef.current = loadedState.sessionId; - lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState); - hydrationCompletedRef.current = true; hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; @@ -127,51 +108,6 @@ export const useAgentChatSession = ({ }; }, [projectId]); - useEffect(() => { - if (!projectId || isHydrating || !hydrationCompletedRef.current) return; - - const currentHydrationNonce = hydrationNonceRef.current; - const persistTimer = window.setTimeout(() => { - if (isStreaming) { - return; - } - - const state: LoadedChatState = { - title: sessionTitle, - isTitleManuallyEdited: isSessionTitleManuallyEdited, - messages, - sessionId, - }; - - const currentStateKey = createPersistedStateKey(state); - if (currentStateKey === lastPersistedStateKeyRef.current) { - return; - } - - void saveActiveChatState(state) - .then((sessionId) => { - if (hydrationNonceRef.current !== currentHydrationNonce) return; - sessionIdRef.current = sessionId; - lastPersistedStateKeyRef.current = createPersistedStateKey({ - ...state, - sessionId, - }); - return listChatSessions(); - }) - .then((sessions) => { - if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return; - setChatSessions(sessions); - }) - .catch((error) => { - console.error("[GlobalChatbox] Failed to persist chat state:", error); - }); - }, 150); - - return () => { - window.clearTimeout(persistTimer); - }; - }, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]); - const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => { setMessages((prev) => prev.map((message) => @@ -226,12 +162,6 @@ export const useAgentChatSession = ({ const targetSessionId = event.sessionId || currentSessionId; if (targetSessionId === currentSessionId) { setSessionTitle(nextTitle); - lastPersistedStateKeyRef.current = createPersistedStateKey({ - sessionId: targetSessionId, - title: nextTitle, - isTitleManuallyEdited: false, - messages: messagesRef.current, - }); } if (targetSessionId) { const currentNonce = ++titleUpdateNonceRef.current; @@ -743,12 +673,6 @@ export const useAgentChatSession = ({ hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; sessionIdRef.current = undefined; - lastPersistedStateKeyRef.current = createPersistedStateKey({ - title: "新对话", - isTitleManuallyEdited: false, - messages: [], - sessionId: undefined, - }); setMessages([]); setSessionTitle("新对话"); setIsSessionTitleManuallyEdited(false); @@ -777,7 +701,6 @@ export const useAgentChatSession = ({ hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; sessionIdRef.current = nextState.sessionId; - lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); setMessages(nextState.messages); setSessionTitle(nextState.title); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); @@ -821,12 +744,6 @@ export const useAgentChatSession = ({ hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; sessionIdRef.current = undefined; - lastPersistedStateKeyRef.current = createPersistedStateKey({ - title: undefined, - isTitleManuallyEdited: false, - messages: [], - sessionId: undefined, - }); setMessages([]); setSessionTitle(undefined); setIsSessionTitleManuallyEdited(false); @@ -842,7 +759,6 @@ export const useAgentChatSession = ({ hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; sessionIdRef.current = nextState.sessionId; - lastPersistedStateKeyRef.current = createPersistedStateKey(nextState); setMessages(nextState.messages); setSessionTitle(nextState.title); setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false); @@ -884,18 +800,12 @@ export const useAgentChatSession = ({ if (sessionIdRef.current === targetSessionId) { setSessionTitle(normalizedTitle); setIsSessionTitleManuallyEdited(true); - lastPersistedStateKeyRef.current = createPersistedStateKey({ - sessionId: targetSessionId, - title: normalizedTitle, - isTitleManuallyEdited: true, - messages, - }); } } catch (error) { console.error("[GlobalChatbox] Failed to rename chat session:", error); } }, - [isHydrating, messages], + [isHydrating], ); const createBranch = useCallback( @@ -920,12 +830,6 @@ export const useAgentChatSession = ({ const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本"; setSessionTitle(forkTitle); try { - await saveActiveChatState({ - title: forkTitle, - isTitleManuallyEdited: false, - messages: copiedMessages, - sessionId: forkedSessionId, - }); setChatSessions(await listChatSessions()); } catch (error) { console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);