From cf0957c72253864d1b2998f482a14bce949a4f2c Mon Sep 17 00:00:00 2001 From: JiuXiang <904085642@qq.com> Date: Sun, 25 Oct 2026 21:50:17 +0800 Subject: [PATCH 1/2] feat(desktop): add session archiving --- CHANGELOG.md | 1 + README.md | 2 + README.zh-CN.md | 2 + .../desktop/src/renderer/App.archive.test.tsx | 243 +++++++++++ apps/desktop/src/renderer/App.tsx | 157 +++++-- .../src/renderer/components/SessionList.tsx | 77 ++++ apps/desktop/src/renderer/styles.css | 23 + apps/desktop/src/shared/types.ts | 3 + apps/desktop/tests/archive.preload.cjs | 67 +++ apps/desktop/tests/archive.smoke.cjs | 149 +++++++ docs/specs/session-archive.md | 26 ++ .../app/src/coding_agent/desktop/runtime.py | 65 ++- .../app/tests/test_desktop_session_archive.py | 287 +++++++++++++ .../src/agent_core/session/session_manager.py | 46 +- .../core/src/agent_core/session/storage.py | 55 ++- packages/core/src/agent_core/session/types.py | 2 + packages/core/tests/test_session_archive.py | 392 ++++++++++++++++++ 17 files changed, 1540 insertions(+), 57 deletions(-) create mode 100644 apps/desktop/src/renderer/App.archive.test.tsx create mode 100644 apps/desktop/src/renderer/components/SessionList.tsx create mode 100644 apps/desktop/tests/archive.preload.cjs create mode 100644 apps/desktop/tests/archive.smoke.cjs create mode 100644 docs/specs/session-archive.md create mode 100644 packages/app/tests/test_desktop_session_archive.py create mode 100644 packages/core/tests/test_session_archive.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fddeb39..8bf049b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ ### 新增 +- 桌面侧栏增加会话归档、归档列表和恢复入口;已归档会话可阅读,恢复后继续对话,重启后仍保留归档状态。 - 桌面输入框和 CLI/TUI 显示缓存命中率、上下文用量/容量、累计输入输出和费用估算;共享 Core 计算,区分缺失统计与零命中,支持容量预警、会话恢复与切换。非交互文本摘要写入 stderr,不污染 JSON 输出。 - 桌面右侧工作栏增加侧边对话、变更、交互终端、浏览器和文件预览;支持多标签、拖动宽度与窄窗口。侧边 Agent 具有独立上下文和审批,变更支持会话及上一轮快照,切换会话时清理原生资源,并补充真实 Runtime/PTY/浏览器冒烟测试。 diff --git a/README.md b/README.md index 875a2df..fed0e58 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ Exit codes: ### Session restoration +In the desktop sidebar, hover a session to archive it. The archive button beside the sidebar heading opens archived sessions, where you can read or restore them. Archiving preserves history; restore a session to continue chatting. Automatic resume skips archived sessions. See [session archiving](docs/specs/session-archive.md). + ```powershell # Continue the latest session for the current project uv run coding-agent --continue diff --git a/README.zh-CN.md b/README.zh-CN.md index 73f9841..9413a15 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -144,6 +144,8 @@ uv run coding-agent --mode json "分析 packages/core" ### 会话恢复 +桌面侧栏悬停会话可点击归档图标;标题旁的归档入口可查看和恢复已归档会话。归档保留聊天记录,恢复后可继续对话;自动恢复最近会话会跳过归档记录。详见[会话归档](docs/specs/session-archive.md)。 + ```powershell # 恢复当前项目最近的会话 uv run coding-agent --continue diff --git a/apps/desktop/src/renderer/App.archive.test.tsx b/apps/desktop/src/renderer/App.archive.test.tsx new file mode 100644 index 0000000..899aace --- /dev/null +++ b/apps/desktop/src/renderer/App.archive.test.tsx @@ -0,0 +1,243 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { VirtuosoMockContext } from "react-virtuoso"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEvent, SessionInfo, WorkspacePayload } from "../shared/types"; +import { App } from "./App"; + +type Record = { id: string; name: string; archived: boolean }; +const project = "G:\\Coding-agent"; +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +}; + +describe("desktop session archive", () => { + const request = vi.fn(); + let records: Map; + let listener: ((event: RuntimeEvent) => void) | undefined; + let sequence = 0; + let selected = "session-1"; + + const payload = (id = selected, path = project): WorkspacePayload => ({ + path, sessionId: id, archived: records.get(id)?.archived ?? false, + model: { id: "offline", name: "Offline", provider: "offline", input: ["text"] }, + thinkingLevel: null, tools: [], collaborationMode: "default", + messages: [ + { role: "user", content: `Question in ${id}` }, + { role: "assistant", content: [{ type: "text", text: `Answer in ${id}` }] }, + ], + planState: { phase: "idle", activePlanId: null, latestRevision: null, pendingQuestion: null }, + memory: { enabled: false, userId: null, projectId: null }, + }); + const list = (archived: boolean): SessionInfo[] => [...records.values()] + .filter((record) => record.archived === archived) + .map((record) => ({ ...record, first_message: "", message_count: 2, modified: 1 })); + const mount = () => render( + + ); + const rowAction = (name: string, archived = false) => within(screen.getByRole("group", { name })) + .getByRole("button", { name: archived ? "恢复会话" : "归档会话" }); + const findRowAction = async (name: string, archived = false) => { + await screen.findByRole("group", { name }); + return rowAction(name, archived); + }; + const emit = async (type: string, body: object, sessionId = selected) => { + await act(async () => { + listener?.({ v: 1, type: "event", seq: ++sequence, timestamp: sequence, + sessionId, runId: null, event: { type, payload: body as { [key: string]: unknown } } }); + await new Promise(requestAnimationFrame); + }); + }; + + beforeEach(() => { + sequence = 0; + selected = "session-1"; + listener = undefined; + records = new Map([ + ["session-1", { id: "session-1", name: "Current conversation", archived: false }], + ["session-2", { id: "session-2", name: "Other conversation", archived: false }], + ["session-3", { id: "session-3", name: "Archived conversation", archived: true }], + ]); + request.mockReset(); + request.mockImplementation(async (method: string, params?: { sessionId?: string; archived?: boolean; path?: string }) => { + if (method === "workspace.open") return payload(selected, params?.path ?? project); + if (method === "session.open") { + selected = params!.sessionId!; + return payload(); + } + if (method === "session.list") return list(params?.archived === true); + if (method === "session.archive" || method === "session.unarchive") { + const record = records.get(params!.sessionId!)!; + record.archived = method === "session.archive"; + return { sessionId: record.id, archived: record.archived }; + } + if (method === "session.snapshot") return payload(); + if (method === "command.list") return []; + return {}; + }); + window.agent = { request, onEvent: (cb) => { listener = cb; return () => undefined; }, onStatus: () => () => undefined }; + window.desktop = { chooseWorkspace: async () => null, + getBootstrap: async () => ({ defaultWorkspace: project, platform: "win32" }) }; + }); + afterEach(() => { cleanup(); vi.clearAllMocks(); }); + + it("archives another conversation without opening it or replacing the current transcript", async () => { + mount(); + fireEvent.click(await findRowAction("Other conversation")); + await waitFor(() => expect(screen.queryByRole("group", { name: "Other conversation" })).not.toBeInTheDocument()); + expect(request).toHaveBeenCalledWith("session.archive", { sessionId: "session-2" }); + expect(request).not.toHaveBeenCalledWith("session.open", expect.anything()); + expect(screen.getByText("Answer in session-1")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("描述你想完成的任务…")).toBeEnabled(); + expect(screen.queryByText("此会话已归档")).not.toBeInTheDocument(); + }); + + it("archives the current conversation while preserving its transcript and draft", async () => { + mount(); + const composer = await screen.findByPlaceholderText("描述你想完成的任务…"); + fireEvent.change(composer, { target: { value: "preserved draft" } }); + fireEvent.click(await findRowAction("Current conversation")); + expect(await screen.findByText("此会话已归档")).toBeInTheDocument(); + expect(screen.getByText("Answer in session-1")).toBeInTheDocument(); + expect(composer).toBeDisabled(); + expect(composer).toHaveValue("preserved draft"); + expect(screen.getByRole("button", { name: "Plan" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Default" })).toBeDisabled(); + expect(request).not.toHaveBeenCalledWith("session.new"); + expect(request).not.toHaveBeenCalledWith("run.start", expect.anything()); + }); + + it("restores the selected conversation and returns focus to its enabled composer", async () => { + records.get(selected)!.archived = true; + mount(); + expect(await screen.findByText("此会话已归档")).toBeInTheDocument(); + const composer = screen.getByPlaceholderText("描述你想完成的任务…"); + expect(composer).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "恢复会话" })); + await waitFor(() => expect(composer).toBeEnabled()); + expect(request).toHaveBeenCalledWith("session.unarchive", { sessionId: "session-1" }); + expect(screen.queryByText("此会话已归档")).not.toBeInTheDocument(); + expect(screen.getByText("Answer in session-1")).toBeInTheDocument(); + await waitFor(() => expect(composer).toHaveFocus()); + }); + + it("opens the archived list and reads a conversation without silently restoring it", async () => { + mount(); + fireEvent.click(await screen.findByRole("button", { name: "查看已归档会话" })); + const item = await screen.findByRole("button", { name: /^Archived conversation/ }); + expect(request).toHaveBeenCalledWith("session.list", { archived: true }); + fireEvent.click(item); + expect(await screen.findByText("Answer in session-3")).toBeInTheDocument(); + expect(screen.getByText("此会话已归档")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("描述你想完成的任务…")).toBeDisabled(); + expect(request).toHaveBeenCalledWith("session.open", { sessionId: "session-3" }); + expect(request).not.toHaveBeenCalledWith("session.unarchive", expect.anything()); + }); + + it("restores another archived conversation without changing the selected conversation", async () => { + mount(); + fireEvent.click(await screen.findByRole("button", { name: "查看已归档会话" })); + fireEvent.click(await findRowAction("Archived conversation", true)); + await waitFor(() => expect(screen.queryByRole("group", { name: "Archived conversation" })).not.toBeInTheDocument()); + expect(request).toHaveBeenCalledWith("session.unarchive", { sessionId: "session-3" }); + expect(screen.getByText("Answer in session-1")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "返回会话" })); + expect(await findRowAction("Archived conversation")).toBeInTheDocument(); + }); + + it("reads persisted archive metadata from the backend after remounting", async () => { + const first = mount(); + fireEvent.click(await findRowAction("Other conversation")); + await waitFor(() => expect(records.get("session-2")!.archived).toBe(true)); + await waitFor(() => expect(screen.queryByRole("group", { name: "Other conversation" })).not.toBeInTheDocument()); + first.unmount(); + mount(); + await findRowAction("Current conversation"); + expect(screen.queryByRole("group", { name: "Other conversation" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "查看已归档会话" })); + expect(await findRowAction("Other conversation", true)).toBeInTheDocument(); + }); + + it("keeps the conversation and authoritative state when archive RPC fails", async () => { + const fallback = request.getMockImplementation()!; + request.mockImplementation((method: string, params?: object) => method === "session.archive" + ? Promise.reject(new Error("disk write failed")) : fallback(method, params)); + mount(); + fireEvent.click(await findRowAction("Current conversation")); + expect(await screen.findByRole("alert")).toHaveTextContent("归档会话失败:disk write failed"); + expect(rowAction("Current conversation")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("描述你想完成的任务…")).toBeEnabled(); + expect(screen.queryByText("此会话已归档")).not.toBeInTheDocument(); + }); + + it("waits for the RPC before removing the row and suppresses duplicate archive clicks", async () => { + const pending = deferred<{ sessionId: string; archived: boolean }>(); + const fallback = request.getMockImplementation()!; + request.mockImplementation((method: string, params?: object) => method === "session.archive" + ? pending.promise : fallback(method, params)); + mount(); + const archive = await findRowAction("Other conversation"); + fireEvent.click(archive); + fireEvent.click(archive); + expect(archive).toBeDisabled(); + expect(screen.getByRole("button", { name: /^Other conversation/ })).toBeInTheDocument(); + expect(request.mock.calls.filter(([method]) => method === "session.archive")).toHaveLength(1); + await act(async () => { + records.get("session-2")!.archived = true; + pending.resolve({ sessionId: "session-2", archived: true }); + }); + await waitFor(() => expect(screen.queryByRole("group", { name: "Other conversation" })).not.toBeInTheDocument()); + }); + + it("disables archive controls while a run is active", async () => { + mount(); + const archive = await findRowAction("Current conversation"); + await emit("run.started", {}); + expect(archive).toBeDisabled(); + expect(screen.getByRole("button", { name: "查看已归档会话" })).toBeDisabled(); + fireEvent.click(archive); + expect(request).not.toHaveBeenCalledWith("session.archive", expect.anything()); + }); + + it("ignores an archived-list response after returning to the active filter", async () => { + const pending = deferred(); + const fallback = request.getMockImplementation()!; + request.mockImplementation((method: string, params?: { archived?: boolean }) => method === "session.list" && params?.archived + ? pending.promise : fallback(method, params)); + mount(); + await findRowAction("Current conversation"); + fireEvent.click(screen.getByRole("button", { name: "查看已归档会话" })); + await waitFor(() => expect(request).toHaveBeenCalledWith("session.list", { archived: true })); + fireEvent.click(screen.getByRole("button", { name: "返回会话" })); + await findRowAction("Current conversation"); + await act(async () => { pending.resolve(list(true)); }); + expect(rowAction("Current conversation")).toBeInTheDocument(); + expect(screen.queryByRole("group", { name: "Archived conversation" })).not.toBeInTheDocument(); + }); + + it("resets the archived filter when a different project opens", async () => { + window.desktop.chooseWorkspace = async () => "G:\\Other-project"; + mount(); + fireEvent.click(await screen.findByRole("button", { name: "查看已归档会话" })); + await findRowAction("Archived conversation", true); + fireEvent.click(screen.getByRole("button", { name: "打开项目" })); + expect(await screen.findByRole("button", { name: "查看已归档会话" })).toBeInTheDocument(); + expect(request).toHaveBeenCalledWith("workspace.open", { path: "G:\\Other-project", resume: true }); + expect(await findRowAction("Current conversation")).toBeInTheDocument(); + }); + + it("applies authoritative archive and restore events to the selected conversation", async () => { + mount(); + const composer = await screen.findByPlaceholderText("描述你想完成的任务…"); + records.get("session-1")!.archived = true; + await emit("session.archived", { sessionId: "session-1", archived: true }); + expect(screen.getByText("此会话已归档")).toBeInTheDocument(); + expect(composer).toBeDisabled(); + records.get("session-1")!.archived = false; + await emit("session.unarchived", { sessionId: "session-1", archived: false }); + expect(screen.queryByText("此会话已归档")).not.toBeInTheDocument(); + expect(composer).toBeEnabled(); + }); +}); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index c9f8b73..3bab8e9 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -16,6 +16,7 @@ import type { } from "../shared/types"; import { PermissionPicker } from "./components/PermissionPicker"; import { UsageMetrics } from "./components/UsageMetrics"; +import { ArchiveIcon, SessionList } from "./components/SessionList"; import { SubagentPanel } from "./components/SubagentPanel"; import { ToolCard } from "./components/tool-card"; import { Workbench } from "./workbench/Workbench"; @@ -243,6 +244,15 @@ export function App() { const [sidecarStatus, setSidecarStatus] = useState("starting"); const [workspace, setWorkspace] = useState(null); const [sessions, setSessions] = useState([]); + const [showArchivedSessions, setShowArchivedSessions] = useState(false); + const archivedViewRef = useRef(false); + const sessionListRequestRef = useRef(0); + const workspacePathRef = useRef(null); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [sessionListError, setSessionListError] = useState(null); + const [pendingSessionArchive, setPendingSessionArchive] = useState(null); + const sessionArchiveRef = useRef(null); + const restoredSessionFocusRef = useRef(null); const [timelineState, setTimelineState] = useState(() => createTimelineState()); const [commandOptions, setCommandOptions] = useState([]); const [commandMenuOpen, setCommandMenuOpen] = useState(false); @@ -280,13 +290,67 @@ export function App() { const workspaceGenerationRef = useRef(0); const refreshSessions = async () => { + const requestId = ++sessionListRequestRef.current; + const path = workspacePathRef.current; + setSessionsLoading(true); + setSessionListError(null); try { - setSessions(await window.agent.request("session.list")); - } catch { - setSessions([]); + const result = archivedViewRef.current + ? await window.agent.request("session.list", { archived: true }) + : await window.agent.request("session.list"); + if (requestId === sessionListRequestRef.current && path === workspacePathRef.current) setSessions(result); + } catch (reason) { + if (requestId === sessionListRequestRef.current && path === workspacePathRef.current) { + setSessionListError(`加载会话失败:${reason instanceof Error ? reason.message : String(reason)}`); + } + } finally { + if (requestId === sessionListRequestRef.current) setSessionsLoading(false); + } + }; + + const toggleArchivedSessions = () => { + archivedViewRef.current = !archivedViewRef.current; + setShowArchivedSessions(archivedViewRef.current); + setSessions([]); + void refreshSessions(); + }; + + const setSessionArchived = async (sessionId: string, archived: boolean) => { + if (sessionArchiveRef.current || sessionNavigationRef.current || running || compacting) return; + const path = workspacePathRef.current; + sessionArchiveRef.current = sessionId; + setPendingSessionArchive(sessionId); + setError(null); + try { + const result = await window.agent.request<{ sessionId: string; archived: boolean }>( + archived ? "session.archive" : "session.unarchive", { sessionId }, + ); + if (path !== workspacePathRef.current) return; + if (!result.archived) restoredSessionFocusRef.current = result.sessionId; + if (result.archived && workspaceSessionIdRef.current === result.sessionId) { + setCommandMenuOpen(false); + setModelPickerOpen(false); + setPlanCommandMenuOpen(false); + } + setWorkspace((current) => current?.sessionId === result.sessionId + ? { ...current, archived: result.archived } : current); + await refreshSessions(); + } catch (reason) { + if (path !== workspacePathRef.current) return; + setError(`${archived ? "归档" : "恢复"}会话失败:${reason instanceof Error ? reason.message : String(reason)}`); + await refreshSessions(); + } finally { + sessionArchiveRef.current = null; + setPendingSessionArchive(null); } }; + useEffect(() => { + if (pendingSessionArchive || !restoredSessionFocusRef.current) return; + if (workspace?.sessionId === restoredSessionFocusRef.current && !workspace.archived) composerRef.current?.focus(); + restoredSessionFocusRef.current = null; + }, [pendingSessionArchive, workspace?.sessionId, workspace?.archived]); + const refreshCommands = async () => { try { setCommandOptions(await window.agent.request("command.list")); @@ -297,6 +361,12 @@ export function App() { const applyWorkspace = (payload: WorkspacePayload, restoreTimeline = true) => { workspaceGenerationRef.current += 1; + if (workspacePathRef.current !== payload.path) { + archivedViewRef.current = false; + setShowArchivedSessions(false); + setSessions([]); + } + workspacePathRef.current = payload.path; if (restoreTimeline) { // RPC results are already authoritative. A snapshot queued for the old // session must not undo a successful switch in the next animation frame. @@ -339,6 +409,7 @@ export function App() { if (!current) return current; return { ...current, + archived: snapshot.archived ?? current.archived, collaborationMode: snapshot.collaborationMode, planState: snapshot.planState, messages: snapshot.messages, @@ -493,6 +564,12 @@ export function App() { const handleRuntimeSideEffects = (envelope: RuntimeEvent) => { const { type, payload } = envelope.event; + if (type === "session.archived" || type === "session.unarchived") { + setWorkspace((current) => current && current.sessionId === payload.sessionId + ? { ...current, archived: payload.archived === true } : current); + void refreshSessions(); + return; + } if (type !== "session.changed" && envelope.sessionId && envelope.sessionId !== workspaceSessionIdRef.current) return; const activeSubmission = planSubmissionRef.current; @@ -910,7 +987,8 @@ export function App() { const submit = async (event?: FormEvent) => { event?.preventDefault(); const text = input.trim(); - if (!text || !workspace || running || compacting || planSubmissionRef.current || sessionNavigationRef.current) return; + if (!text || !workspace || running || compacting || planSubmissionRef.current + || sessionNavigationRef.current || sessionArchiveRef.current || workspace.archived) return; if (text.startsWith("/")) { const [rawName, ...rest] = text.slice(1).split(/\s+/); const command = commandOptions.find((item) => item.name === rawName.toLocaleLowerCase()); @@ -1274,7 +1352,7 @@ export function App() { }, []); const navigateSession = async (sessionId?: string) => { - if (running || compacting || !workspace || sessionNavigationRef.current + if (running || compacting || !workspace || sessionNavigationRef.current || sessionArchiveRef.current || (sessionId && sessionId === workspaceSessionIdRef.current)) return; sessionNavigationRef.current = true; workspaceGenerationRef.current += 1; @@ -1284,6 +1362,10 @@ export function App() { const result = sessionId ? await window.agent.request("session.open", { sessionId }) : await window.agent.request("session.new"); + if (!sessionId) { + archivedViewRef.current = false; + setShowArchivedSessions(false); + } applyWorkspace(result); requestAnimationFrame(() => composerRef.current?.focus()); } catch (reason) { @@ -1325,6 +1407,9 @@ export function App() { const planBadgeLabel = planStatusPhase === "ready" ? "PLAN READY" : planStatusPhase === "uncertain" ? "PLAN UNCERTAIN" : planStatusPhase === "recovery_error" ? "PLAN RECOVERY" : "PLAN"; + const conversationReadOnly = Boolean(workspace?.archived) || Boolean(pendingSessionArchive); + const composerBlocked = !workspace || awaitingPlanDecision || planCommandMenuOpen + || compacting || sessionNavigating || Boolean(planStatusMessage) || conversationReadOnly; const timelineItems = useMemo(() => selectTimelineItems(timelineState), [timelineState]); const renderedPlan = ["ready", "uncertain", "recovery_error"].includes(planStatusPhase ?? "") @@ -1425,40 +1510,16 @@ export function App() {
diff --git a/apps/desktop/src/renderer/components/SessionList.tsx b/apps/desktop/src/renderer/components/SessionList.tsx new file mode 100644 index 0000000..09926e5 --- /dev/null +++ b/apps/desktop/src/renderer/components/SessionList.tsx @@ -0,0 +1,77 @@ +import type { SessionInfo } from "../../shared/types"; + +export function ArchiveIcon({ restore = false }: { restore?: boolean }) { + return ; +} + +interface Props { + sessions: SessionInfo[]; + archived: boolean; + activeId?: string; + disabled: boolean; + loading: boolean; + error: string | null; + pendingNavigation: string | null; + pendingArchive: string | null; + onToggleArchive: () => void; + onNew: () => void; + onOpen: (id: string) => void; + onArchive: (id: string, archived: boolean) => void; + onRetry: () => void; +} + +export function SessionList(props: Props) { + return <> +
+ {props.archived ? "已归档" : "会话"} +
+ + +
+
+ {props.pendingNavigation &&
+
} +
+ {props.error &&
{props.error} + +
} + {props.sessions.map((session) => { + const label = session.name || session.first_message || "新会话"; + return
+ + +
; + })} + {!props.error && !props.sessions.length &&
+ {props.loading ? "正在加载会话…" : props.archived ? "暂无已归档会话。" : "发送第一条消息后,会话会保存到 JSONL。"} +
} +
+ ; +} diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 955b844..94fca51 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -119,6 +119,29 @@ button:focus-visible, textarea:focus-visible { outline: 2px solid #8e8a82; outli .session-item.active { background: var(--sidebar-active); box-shadow: none; } .session-item strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; font-weight: 590; } .session-item span, .empty-sidebar { color: var(--muted); font-size: 10px; } +.session-heading-actions { display: flex; align-items: center; gap: 3px; } +.session-heading-actions .icon-button { display: grid; place-items: center; } +.session-row { position: relative; } +.session-row .session-item { padding-right: 40px; } +.session-row .session-item strong { max-width: 100%; } +.session-archive-action { + position: absolute; right: 5px; top: 10px; display: grid; place-items: center; + width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; + color: var(--muted); background: var(--sidebar); cursor: pointer; opacity: 0; +} +.session-row:hover .session-archive-action, .session-row:focus-within .session-archive-action, +.session-archive-action:focus-visible { opacity: 1; } +.session-archive-action:hover { color: var(--text); background: var(--sidebar-hover); } +.session-archive-action:disabled { cursor: wait; } +.session-retry { margin-left: 8px; border: 0; background: transparent; color: inherit; text-decoration: underline; cursor: pointer; } +.session-archive-banner { + display: flex; align-items: center; gap: 10px; padding: 12px 14px; + border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--muted); +} +.session-archive-banner div { display: flex; flex: 1; flex-direction: column; gap: 3px; font-size: 12px; } +.session-archive-banner strong { color: var(--text); font-weight: 550; } +.session-plan-controls { display: contents; } +@media (hover: none) { .session-archive-action { opacity: 1; } } .empty-sidebar { padding: 12px 10px; line-height: 1.6; } .workspace-meta { margin: 8px; diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index 879ecb8..2f2bc2f 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -26,6 +26,7 @@ export interface RpcErrorPayload { } export interface WorkspacePayload { + archived?: boolean; permissionMode?: PermissionMode; path: string; sessionId: string; @@ -154,6 +155,7 @@ export interface PlanStatePayload { } export interface SessionSnapshotPayload { + archived?: boolean; permissionMode?: PermissionMode; sessionId: string; messages: AgentMessage[]; @@ -196,6 +198,7 @@ export interface ContentBlock { } export interface SessionInfo { + archived?: boolean; id: string; name: string | null; first_message: string; diff --git a/apps/desktop/tests/archive.preload.cjs b/apps/desktop/tests/archive.preload.cjs new file mode 100644 index 0000000..3acbe92 --- /dev/null +++ b/apps/desktop/tests/archive.preload.cjs @@ -0,0 +1,67 @@ +// Isolated archive fixture: no real sessions, providers, filesystem writes, or sidecar. +const { contextBridge } = require("electron"); +const listeners = new Set(); +const clone = (value) => JSON.parse(JSON.stringify(value)); +const calls = []; +let sequence = 0; +let selectedId = "archive-demo-current"; +const records = [ + { id: selectedId, name: "整理会话归档", archived: false }, + { id: "archive-demo-other", name: "优化缓存指标", archived: false }, + { id: "archive-demo-old", name: "旧版界面方案", archived: true }, +].map((record) => ({ + ...record, first_message: record.name, message_count: 2, modified: 1, + messages: [ + { role: "user", content: `请继续${record.name},保留之前讨论的内容。`, timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: `${record.name}的讨论记录已保留。归档后仍然可以查看这段记录。` }], + model: "offline", provider: "fixture", api: "openai-completions", stop_reason: "stop", timestamp: 2, + usage: { input: 100, output: 40, cache_read: 0, cache_write: 0, total_tokens: 140 } }, + ], +})); +const payload = () => { + const record = records.find((item) => item.id === selectedId); + return clone({ + path: "G:\\offline-archive-fixture", sessionId: record.id, archived: record.archived, permissionMode: "ask", + model: { id: "offline", name: "Offline archive fixture", provider: "fixture", input: ["text"] }, + thinkingLevel: null, tools: [], messages: record.messages, collaborationMode: "default", + planState: { phase: "idle", activePlanId: null, latestRevision: null, pendingQuestion: null }, + memory: { enabled: false, userId: null, projectId: null }, + }); +}; +const emit = (type, value) => { + const event = { v: 1, type: "event", seq: ++sequence, timestamp: Date.now(), sessionId: selectedId, + runId: null, event: { type, payload: clone(value) } }; + for (const listener of listeners) listener(event); +}; +contextBridge.exposeInMainWorld("desktop", { + getBootstrap: async () => ({ defaultWorkspace: "G:\\offline-archive-fixture", platform: "offline-fixture" }), + chooseWorkspace: async () => null, +}); +contextBridge.exposeInMainWorld("agent", { + onEvent: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, + onStatus: () => () => undefined, + request: async (method, params = {}) => { + calls.push({ method, params: clone(params) }); + if (method === "workspace.open" || method === "session.snapshot") return payload(); + if (method === "session.list") return clone(records.filter((record) => record.archived === Boolean(params.archived)) + .map(({ messages: _messages, ...info }) => info)); + if (method === "command.list") return []; + if (method === "session.open") { + if (!records.some((record) => record.id === params.sessionId)) throw Error("Unknown fixture session"); + selectedId = params.sessionId; + return payload(); + } + if (method === "session.archive" || method === "session.unarchive") { + const record = records.find((item) => item.id === params.sessionId); + if (!record) throw Error("Unknown fixture session"); + record.archived = method === "session.archive"; + const result = { sessionId: record.id, archived: record.archived }; + emit(record.archived ? "session.archived" : "session.unarchived", result); + return clone(result); + } + throw Error(`Unexpected archive smoke RPC: ${method}`); + }, +}); +contextBridge.exposeInMainWorld("archiveSmoke", { + state: () => clone({ records, calls, selectedId }), +}); diff --git a/apps/desktop/tests/archive.smoke.cjs b/apps/desktop/tests/archive.smoke.cjs new file mode 100644 index 0000000..6c0ccb5 --- /dev/null +++ b/apps/desktop/tests/archive.smoke.cjs @@ -0,0 +1,149 @@ +// Real Chromium archive smoke: hidden window, temporary profile, offline bridge. +// Run after pnpm build: pnpm exec electron tests/archive.smoke.cjs +const { app, BrowserWindow, session } = require("electron"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const assert = require("node:assert/strict"); + +async function main() { + app.disableHardwareAcceleration(); + const output = await fs.mkdtemp(path.join(os.tmpdir(), "coding-agent-archive-")); + app.setPath("userData", path.join(output, "profile")); + await app.whenReady(); + session.defaultSession.webRequest.onBeforeRequest( + { urls: ["http://*/*", "https://*/*", "ws://*/*", "wss://*/*"] }, + (_details, callback) => callback({ cancel: true }), + ); + session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + const window = new BrowserWindow({ + show: false, width: 1280, height: 900, + webPreferences: { + preload: path.join(__dirname, "archive.preload.cjs"), contextIsolation: true, + nodeIntegration: false, backgroundThrottling: false, offscreen: true, + }, + }); + const errors = []; + window.webContents.on("console-message", (_event, details) => { + if (typeof details === "object" && details.level === "error") errors.push(details.message); + }); + const evaluate = (source) => window.webContents.executeJavaScript(source); + const waitFor = async (source) => { + const deadline = Date.now() + 6000; + while (Date.now() < deadline) { + if (await evaluate(source)) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const layout = await evaluate(`(() => { + const timeline = document.querySelector('.timeline'); + return { viewport: { width: innerWidth, height: innerHeight }, + timelineBounds: timeline?.getBoundingClientRect().toJSON(), + timeline: timeline?.outerHTML.slice(0, 4000) }; + })()`); + throw Error(`UI assertion timed out: ${source}\n${await evaluate("document.body.innerText")}\n${JSON.stringify(layout)}`); + }; + const paint = () => evaluate("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))"); + const capture = async (name) => { + await paint(); + await fs.writeFile(path.join(output, name), (await window.webContents.capturePage()).toPNG()); + }; + const moveTo = async (selector) => { + const point = await evaluate(`(() => { + const element = document.querySelector(${JSON.stringify(selector)}); + if (!element) throw Error('Missing mouse target'); + const bounds = element.getBoundingClientRect(); + return { x: Math.round(bounds.left + bounds.width / 2), y: Math.round(bounds.top + bounds.height / 2) }; + })()`); + window.webContents.sendInputEvent({ type: "mouseMove", ...point }); + await paint(); + return point; + }; + const click = async (selector) => { + const point = await moveTo(selector); + window.webContents.sendInputEvent({ type: "mouseDown", button: "left", clickCount: 1, ...point }); + window.webContents.sendInputEvent({ type: "mouseUp", button: "left", clickCount: 1, ...point }); + }; + const assertLayout = async () => { + assert(await evaluate(`(() => { + const banner = document.querySelector('.session-archive-banner'); + return document.documentElement.scrollWidth <= innerWidth + && !document.querySelector('button button') + && [...document.querySelectorAll('.session-row')].every(row => { + const bounds = row.getBoundingClientRect(); + const action = row.querySelector('.session-archive-action').getBoundingClientRect(); + return bounds.width > 0 && bounds.left >= 0 && action.right <= bounds.right + 1; + }) && (!banner || (banner.getBoundingClientRect().right <= innerWidth + && banner.scrollWidth <= banner.clientWidth + 1)); + })()`), "Archive controls must fit without horizontal overflow or nested buttons"); + }; + const currentAction = '.session-row[aria-label="整理会话归档"] .session-archive-action'; + const retainedMessageVisible = "document.body.innerText.includes('整理会话归档的讨论记录已保留。')"; + + await window.loadFile(path.join(__dirname, "../dist-renderer/index.html")); + await waitFor(`document.querySelector(${JSON.stringify(currentAction)}) !== null`); + const initialTimelineBounds = await evaluate("document.querySelector('.timeline')?.getBoundingClientRect().toJSON()"); + // Initialize ResizeObserver with a real native resize in this hidden fixture. + window.setSize(1281, 900); + await paint(); + window.setSize(1280, 900); + await paint(); + await waitFor(retainedMessageVisible); + await moveTo(currentAction); + assert.equal(await evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(currentAction)})).opacity`), "1"); + await assertLayout(); + await capture("archive-active-wide.png"); + const original = await evaluate("window.archiveSmoke.state().records.map(record => record.messages)"); + + await click(currentAction); + await waitFor("document.querySelector('.session-archive-banner') && document.querySelector('textarea')?.disabled"); + await waitFor("!document.querySelector('.session-row[aria-label=\"整理会话归档\"]')"); + // The archive banner resizes Virtuoso's viewport; wait for its measured rows. + await waitFor(retainedMessageVisible); + assert.equal(await evaluate("window.archiveSmoke.state().calls.filter(call => call.method === 'session.archive').length"), 1); + await capture("archive-readonly-wide.png"); + + await click('.session-item'); + await waitFor("window.archiveSmoke.state().selectedId === 'archive-demo-other' && !document.querySelector('.session-archive-banner')"); + await click('[aria-label="查看已归档会话"]'); + await waitFor("document.querySelector('[aria-label=\"已归档会话\"]')?.innerText.includes('整理会话归档')"); + await evaluate("[...document.querySelectorAll('.session-item')].find(item => item.innerText.includes('整理会话归档')).click()"); + await waitFor("window.archiveSmoke.state().selectedId === 'archive-demo-current' && document.querySelector('.session-archive-banner') && document.querySelector('textarea')?.disabled"); + await waitFor(retainedMessageVisible); + await assertLayout(); + await capture("archive-list-wide.png"); + + window.setSize(820, 680); + await paint(); + await assertLayout(); + await capture("archive-readonly-narrow.png"); + await click('.session-archive-banner button'); + await waitFor("!document.querySelector('.session-archive-banner') && !document.querySelector('textarea')?.disabled"); + await waitFor("!document.querySelector('.session-row[aria-label=\"整理会话归档\"]')"); + await assertLayout(); + await click('[aria-label="返回会话"]'); + await waitFor("document.querySelector('.session-row[aria-label=\"整理会话归档\"]') !== null"); + window.setSize(1280, 900); + await paint(); + await assertLayout(); + await capture("archive-restored-wide.png"); + + await click('[aria-label="查看已归档会话"]'); + await waitFor("document.querySelector('.session-row[aria-label=\"旧版界面方案\"]') !== null"); + await click('.session-row[aria-label="旧版界面方案"] .session-archive-action'); + await waitFor("document.querySelector('.empty-sidebar')?.innerText === '暂无已归档会话。'"); + assert.equal(await evaluate("window.archiveSmoke.state().selectedId"), "archive-demo-current"); + const finalState = await evaluate("window.archiveSmoke.state()"); + assert.deepEqual(finalState.records.map((record) => record.messages), original); + assert.equal(finalState.calls.filter((call) => call.method === "session.archive").length, 1); + assert.equal(finalState.calls.filter((call) => call.method === "session.unarchive").length, 2); + assert.equal(finalState.calls.filter((call) => call.method === "run.start").length, 0); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ passed: true, output, initialTimelineBounds, screenshots: [ + "archive-active-wide.png", "archive-readonly-wide.png", "archive-list-wide.png", + "archive-readonly-narrow.png", "archive-restored-wide.png", + ].map((name) => path.join(output, name)) })); + window.destroy(); + app.quit(); +} + +main().catch((error) => { console.error(error); app.exit(1); }); diff --git a/docs/specs/session-archive.md b/docs/specs/session-archive.md new file mode 100644 index 0000000..a549030 --- /dev/null +++ b/docs/specs/session-archive.md @@ -0,0 +1,26 @@ +# 桌面会话归档 + +侧栏会话行在悬停或键盘聚焦时显示归档图标。点击后,会话从普通列表移入“已归档”。侧栏标题旁的归档图标打开归档列表,返回按钮切回普通列表;归档列表中的恢复图标可将会话放回普通列表。 + +归档当前会话时保留聊天视图,输入框停用,并显示“恢复会话”入口。打开其他已归档会话也只显示历史;恢复成功后可继续对话。归档、恢复与列表刷新都有等待或失败反馈,失败不提前移除列表项。新建会话返回普通列表。 + +## 存储与边界 + +- 归档状态属于整个会话,与活动分支及 Plan 状态无关。旧会话默认未归档,也兼容读取 JSONL header 的可选 `archived` 字段。 +- 归档状态通过会话旁的独立小型元数据文件原子保存,不重写聊天 JSONL,避免覆盖其他 CLI 进程同时追加的消息。元数据文件名取原会话文件名的稳定散列,复制或备份会话时需保留同目录归档元数据。 +- 会话路径、ID、父会话、历史消息、工具结果和活动分支均保留。归档不会删除附件或回滚项目文件、Git 操作及其他副作用。 +- 普通列表和自动恢复最近会话跳过已归档记录。已归档记录仍可按 ID 打开;桌面后端在恢复前拒绝发消息、执行计划、压缩或启动子代理。 +- 运行、压缩、子代理或侧边对话尚未结束时,拒绝改变归档状态。保存失败时,内存中的归档状态也不推进。 +- 归档接口只接受当前工作区内的会话 ID。切换工作区重置归档列表;迟到的列表响应不能覆盖新工作区或当前筛选结果。 + +## 桌面接口 + +| 接口 | 行为 | +| --- | --- | +| `session.list` | 默认返回普通会话;`{archived: true}` 只返回归档会话 | +| `session.archive({sessionId})` | 保存归档状态,返回 `{sessionId, archived: true}` | +| `session.unarchive({sessionId})` | 保存恢复状态,返回 `{sessionId, archived: false}` | +| workspace / `session.snapshot` | 包含当前会话的 `archived` 状态 | +| `session.archived` / `session.unarchived` | 通知前端刷新列表及当前会话的归档状态 | + +不增加数据库、依赖或常驻后台任务,也不移动会话文件。 diff --git a/packages/app/src/coding_agent/desktop/runtime.py b/packages/app/src/coding_agent/desktop/runtime.py index eef7849..f02dbe9 100644 --- a/packages/app/src/coding_agent/desktop/runtime.py +++ b/packages/app/src/coding_agent/desktop/runtime.py @@ -10,6 +10,7 @@ import uuid from agent_core import BeforeToolCallContext, BeforeToolCallResult, SessionManager +from agent_core.session.ids import is_valid_session_id from agent_llm import ThinkingLevel, create_models from coding_agent.cli.main import _resolve_api_key_for @@ -66,6 +67,12 @@ def __init__(self, emit: EventEmitter, *, approval_timeout_seconds: float = 120. self._side_conversations: SideConversations | None = None async def dispatch(self, method: str, params: dict[str, Any]) -> Any: + if method in { + "run.start", "run.steer", "run.followUp", "mode.enterPlan", "plan.answer", + "plan.cancel", "plan.execute", "plan.handoff", "session.clear", "session.compact", + "model.select", "subagent.spawn", "workbench.chat.send", + }: + self._ensure_session_not_archived() if method.startswith("workbench."): return await self._workbench_request(method, params) handlers: dict[str, Callable[[dict[str, Any]], Awaitable[Any]]] = { @@ -74,6 +81,8 @@ async def dispatch(self, method: str, params: dict[str, Any]) -> Any: "session.list": self._session_list, "session.new": self._session_new, "session.open": self._session_open, + "session.archive": self._session_archive, + "session.unarchive": self._session_unarchive, "session.snapshot": self._session_snapshot, "session.clear": self._session_clear, "command.list": self._command_list, @@ -285,7 +294,9 @@ def _select_session_manager( ) -> SessionManager: sessions_dir = get_sessions_dir() if session_id: - available = SessionManager.list_sessions(cwd=str(workspace), sessions_dir=sessions_dir) + available = SessionManager.list_sessions( + cwd=str(workspace), sessions_dir=sessions_dir, archived=None, + ) match = next((item for item in available if item.id == session_id), None) if match is None: raise RpcError("SESSION_NOT_FOUND", f"找不到会话:{session_id}") @@ -297,11 +308,52 @@ def _select_session_manager( return SessionManager.create(cwd=str(workspace), sessions_dir=sessions_dir) async def _session_list(self, params: dict[str, Any]) -> list[dict[str, Any]]: - del params + archived = params.get("archived", False) + if not isinstance(archived, bool): + raise RpcError("INVALID_PARAMS", "session.list 的 archived 必须是布尔值") workspace = self._require_workspace() - infos = SessionManager.list_sessions(cwd=str(workspace), sessions_dir=get_sessions_dir()) + infos = SessionManager.list_sessions( + cwd=str(workspace), sessions_dir=get_sessions_dir(), archived=archived, + ) return [to_jsonable(item) for item in infos] + async def _session_archive(self, params: dict[str, Any]) -> dict[str, Any]: + return self._set_session_archived(params, archived=True) + + async def _session_unarchive(self, params: dict[str, Any]) -> dict[str, Any]: + return self._set_session_archived(params, archived=False) + + def _set_session_archived(self, params: dict[str, Any], *, archived: bool) -> dict[str, Any]: + session_id = params.get("sessionId") + if (not isinstance(session_id, str) or session_id != session_id.strip() + or not is_valid_session_id(session_id)): + raise RpcError("INVALID_PARAMS", "归档操作需要有效的 sessionId") + self._ensure_no_active_run("任务运行时不能更改会话归档状态") + session = self._require_session() + if session.subagents.has_active or ( + self._side_conversations is not None + and any(record.task is not None and not record.task.done() + for record in self._side_conversations.records.values()) + ): + raise RpcError("RUN_IN_PROGRESS", "请先停止子代理或侧边对话,再更改会话归档状态") + workspace = self._require_workspace() + sessions_dir = get_sessions_dir() + match = next((item for item in SessionManager.list_sessions( + cwd=str(workspace), sessions_dir=sessions_dir, archived=None, + ) if item.id == session_id), None) + if match is None: + raise RpcError("SESSION_NOT_FOUND", f"找不到会话:{session_id}") + try: + manager = session.session_manager if session.session_manager.header.id == session_id else SessionManager.open( + match.path, sessions_dir=sessions_dir, + ) + manager.set_archived(archived) + except (OSError, ValueError) as exc: + raise RpcError("SESSION_ARCHIVE_FAILED", "无法保存会话归档状态,请重试") from exc + payload = {"sessionId": session_id, "archived": archived} + self._publish("session.archived" if archived else "session.unarchived", payload) + return payload + async def _session_new(self, params: dict[str, Any]) -> dict[str, Any]: del params workspace = self._require_workspace() @@ -325,6 +377,7 @@ async def _session_snapshot(self, params: dict[str, Any]) -> dict[str, Any]: session = self._require_session() return { "sessionId": session.session_manager.header.id, + "archived": getattr(session.session_manager.header, "archived", False), "messages": to_jsonable(session.state.messages), "stats": to_jsonable(session.get_stats()), "collaborationMode": session.collaboration_mode, @@ -1089,6 +1142,7 @@ def _workspace_payload(self) -> dict[str, Any]: return { "path": str(self._require_workspace()), "sessionId": session.session_manager.header.id, + "archived": getattr(session.session_manager.header, "archived", False), "model": to_jsonable(session.model), "thinkingLevel": session.thinking_level, "tools": [tool.name for tool in session.tools], @@ -1123,6 +1177,11 @@ def _ensure_no_active_run(self, message: str) -> None: if self._run_task is not None and not self._run_task.done(): raise RpcError("RUN_IN_PROGRESS", message) + def _ensure_session_not_archived(self) -> None: + manager = getattr(self._session, "session_manager", None) + if getattr(getattr(manager, "header", None), "archived", False) is True: + raise RpcError("SESSION_ARCHIVED", "此会话已归档,请先恢复会话后继续") + def _require_workspace(self) -> Path: if self._workspace is None: raise RpcError("WORKSPACE_REQUIRED", "请先打开工作区") diff --git a/packages/app/tests/test_desktop_session_archive.py b/packages/app/tests/test_desktop_session_archive.py new file mode 100644 index 0000000..cb32b57 --- /dev/null +++ b/packages/app/tests/test_desktop_session_archive.py @@ -0,0 +1,287 @@ +"""Desktop archiving is durable organization and preserves the selected transcript.""" +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from agent_core import SessionManager +from agent_llm import AssistantMessage, Model, ModelCost, TextContent, UserMessage + +from coding_agent.core.agent_session import AgentSession, AgentSessionConfig +from coding_agent.core.config import get_sessions_dir +from coding_agent.cli.args import Args +from coding_agent.cli.main import _create_session_manager +from coding_agent.desktop.protocol import RpcError +from coding_agent.desktop.runtime import DesktopRuntime + + +def _saved(workspace: Path) -> SessionManager: + manager = SessionManager.create(cwd=str(workspace), sessions_dir=get_sessions_dir()) + manager.append_message(UserMessage(content="original question")) + manager.append_message(AssistantMessage(content=[TextContent(text="original answer")])) + return manager + + +def _runtime(workspace: Path, manager: SessionManager, events: list) -> DesktopRuntime: + runtime = DesktopRuntime(events.append) + runtime._workspace = workspace + runtime._session = AgentSession(AgentSessionConfig( + cwd=str(workspace), session_manager=manager, no_builtin_tools=True, + model=Model(id="offline", provider="offline", api="openai-completions", + base_url="https://offline.invalid", cost=ModelCost(0, 0, 0, 0)), + )) + return runtime + + +def test_archive_current_retains_selection_and_messages_then_restores(tmp_path): + async def scenario(): + manager = _saved(tmp_path) + assert manager.path is not None + body = manager.path.read_bytes().split(b"\n", 1)[1] + events = [] + runtime = _runtime(tmp_path, manager, events) + original_session = runtime._session + try: + result = await runtime.dispatch("session.archive", {"sessionId": manager.header.id}) + assert result == {"sessionId": manager.header.id, "archived": True} + assert runtime._session is original_session + assert manager.path.read_bytes().split(b"\n", 1)[1] == body + assert await runtime.dispatch("session.list", {}) == [] + archived = await runtime.dispatch("session.list", {"archived": True}) + assert [(item["id"], item["archived"]) for item in archived] == [(manager.header.id, True)] + snapshot = await runtime.dispatch("session.snapshot", {}) + assert snapshot["archived"] is True + assert snapshot["sessionId"] == manager.header.id + assert len(snapshot["messages"]) == 2 + assert runtime._workspace_payload()["archived"] is True + assert events[-1]["event"] == {"type": "session.archived", "payload": result} + + result = await runtime.dispatch("session.unarchive", {"sessionId": manager.header.id}) + assert result == {"sessionId": manager.header.id, "archived": False} + assert runtime._session is original_session + assert manager.path.read_bytes().split(b"\n", 1)[1] == body + assert await runtime.dispatch("session.list", {"archived": True}) == [] + assert (await runtime.dispatch("session.list", {}))[0]["id"] == manager.header.id + assert events[-1]["event"] == {"type": "session.unarchived", "payload": result} + finally: + await runtime.dispose() + asyncio.run(scenario()) + + +def test_restart_skips_archives_but_explicit_open_keeps_them_readable(tmp_path): + async def scenario(): + saved = _saved(tmp_path) + saved.set_archived(True) + runtime = DesktopRuntime(lambda _event: None) + try: + opened = await runtime.dispatch("workspace.open", {"path": str(tmp_path), "resume": True}) + assert opened["sessionId"] != saved.header.id + assert opened["archived"] is False + opened = await runtime.dispatch("session.open", {"sessionId": saved.header.id}) + assert opened["sessionId"] == saved.header.id + assert opened["archived"] is True + assert len(opened["messages"]) == 2 + finally: + await runtime.dispose() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("selector", ["session_id", "exact", "prefix"]) +def test_cli_explicit_archive_reference_reuses_existing_session(tmp_path, selector): + saved = _saved(tmp_path) + saved.set_archived(True) + assert saved.path is not None + before = list(saved.path.parent.glob("*.jsonl")) + if selector == "session_id": + args = Args(session_id=saved.header.id) + else: + reference = saved.header.id if selector == "exact" else saved.header.id[:12] + args = Args(session=reference) + selected = _create_session_manager(args, str(tmp_path)) + assert selected.path == saved.path + assert selected.header.id == saved.header.id + assert selected.header.archived is True + assert selected.entries == saved.entries + selected.append_message(UserMessage(content="explicitly resumed original session")) + selected.flush() + assert list(saved.path.parent.glob("*.jsonl")) == before + + +def test_archive_another_session_does_not_change_the_selected_session(tmp_path): + async def scenario(): + current, target = _saved(tmp_path), _saved(tmp_path) + runtime = _runtime(tmp_path, current, []) + selected = runtime._session + try: + result = await runtime.dispatch("session.archive", {"sessionId": target.header.id}) + assert result == {"sessionId": target.header.id, "archived": True} + assert runtime._session is selected + snapshot = await runtime.dispatch("session.snapshot", {}) + assert snapshot["sessionId"] == current.header.id + assert snapshot["archived"] is False + assert target.path is not None + assert SessionManager.open(target.path).header.archived is True + assert [item["id"] for item in await runtime.dispatch("session.list", {})] == [current.header.id] + await runtime.dispatch("session.unarchive", {"sessionId": target.header.id}) + assert SessionManager.open(target.path).header.archived is False + assert runtime._session is selected + finally: + await runtime.dispose() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("method", [ + "run.start", "run.steer", "run.followUp", "plan.execute", "plan.handoff", "plan.answer", + "plan.cancel", "mode.enterPlan", "session.clear", "session.compact", "model.select", + "subagent.spawn", "workbench.chat.send", +]) +def test_archived_conversation_rejects_mutations_until_restored(tmp_path, monkeypatch, method): + async def scenario(): + manager = _saved(tmp_path) + manager.set_archived(True) + runtime = _runtime(tmp_path, manager, []) + drive = AsyncMock() + monkeypatch.setattr(runtime, "_drive_run", drive) + try: + with pytest.raises(RpcError) as error: + await runtime.dispatch(method, {"text": "continue"}) + assert error.value.code == "SESSION_ARCHIVED" + assert runtime._run_task is None + await runtime.dispatch("session.unarchive", {"sessionId": manager.header.id}) + result = await runtime.dispatch("run.start", {"text": "continue"}) + assert result["accepted"] is True + task = getattr(runtime, "_run_task") + assert task is not None + await task + drive.assert_awaited_once() + finally: + await runtime.dispose() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("activity,code", [ + ("run", "RUN_IN_PROGRESS"), ("compaction", "COMPACTION_IN_PROGRESS"), + ("subagent", "RUN_IN_PROGRESS"), ("side_chat", "RUN_IN_PROGRESS"), +]) +def test_active_work_cannot_be_archived(tmp_path, monkeypatch, activity, code): + async def scenario(): + manager = _saved(tmp_path) + runtime = _runtime(tmp_path, manager, []) + assert runtime._session is not None + async def hold() -> None: + await asyncio.Event().wait() + task = asyncio.create_task(hold()) + if activity == "run": + runtime._run_task = task + elif activity == "compaction": + monkeypatch.setattr(type(runtime._session), "is_compacting", property(lambda _self: True)) + elif activity == "subagent": + monkeypatch.setattr(type(runtime._session.subagents), "has_active", property(lambda _self: True)) + else: + monkeypatch.setattr(runtime, "_side_conversations", SimpleNamespace(records={"side": SimpleNamespace(task=task)})) + try: + with pytest.raises(RpcError) as error: + await runtime.dispatch("session.archive", {"sessionId": manager.header.id}) + assert error.value.code == code + assert manager.header.archived is False + finally: + runtime._run_task = None + runtime._side_conversations = None + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await runtime.dispose() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("session_id", [None, "", "../escape", "a/b", "id\n", 123, True]) +def test_archive_validates_ids_without_touching_history(tmp_path, session_id): + async def scenario(): + manager = _saved(tmp_path) + runtime = _runtime(tmp_path, manager, []) + try: + with pytest.raises(RpcError) as error: + await runtime.dispatch("session.archive", {"sessionId": session_id}) + assert error.value.code == "INVALID_PARAMS" + assert manager.header.archived is False + finally: + await runtime.dispose() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("archived", [None, "true", 1, [], {}]) +def test_archive_list_rejects_non_boolean_filter(tmp_path, archived): + async def scenario(): + runtime = _runtime(tmp_path, _saved(tmp_path), []) + try: + with pytest.raises(RpcError) as error: + await runtime.dispatch("session.list", {"archived": archived}) + assert error.value.code == "INVALID_PARAMS" + finally: + await runtime.dispose() + asyncio.run(scenario()) + + +def test_archive_cannot_target_another_workspace(tmp_path): + async def scenario(): + other_workspace = tmp_path / "other" + other_workspace.mkdir() + current = _saved(tmp_path) + other = _saved(other_workspace) + runtime = _runtime(tmp_path, current, []) + try: + with pytest.raises(RpcError) as error: + await runtime.dispatch("session.archive", {"sessionId": other.header.id}) + assert error.value.code == "SESSION_NOT_FOUND" + assert other.path is not None + assert SessionManager.open(other.path).header.archived is False + finally: + await runtime.dispose() + asyncio.run(scenario()) + + +def test_archive_cannot_target_another_workspace_with_colliding_directory_encoding(): + async def scenario(): + current_cwd, foreign_cwd = Path("/archive/project/nested"), Path("/archive/project-nested") + current, foreign = _saved(current_cwd), _saved(foreign_cwd) + assert current.path is not None and foreign.path is not None + assert current.path.parent == foreign.path.parent + runtime = _runtime(current_cwd, current, []) + try: + with pytest.raises(RpcError) as error: + await runtime.dispatch("session.archive", {"sessionId": foreign.header.id}) + assert error.value.code == "SESSION_NOT_FOUND" + assert SessionManager.open(foreign.path).header.archived is False + assert [item["id"] for item in await runtime.dispatch("session.list", {})] == [current.header.id] + finally: + await runtime.dispose() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("archived", [True, False]) +def test_failed_archive_leaves_snapshot_and_list_unchanged(tmp_path, monkeypatch, archived): + async def scenario(): + manager = _saved(tmp_path) + manager.set_archived(not archived) + events = [] + runtime = _runtime(tmp_path, manager, events) + assert manager.path is not None + original = manager.path.read_bytes() + def fail(*_args): + raise OSError("disk unavailable") + monkeypatch.setattr("agent_core.session.session_manager.write_archive_state", fail) + try: + with pytest.raises(RpcError) as error: + await runtime.dispatch("session.archive" if archived else "session.unarchive", {"sessionId": manager.header.id}) + assert error.value.code == "SESSION_ARCHIVE_FAILED" + assert manager.header.archived is not archived + assert manager.path.read_bytes() == original + assert (await runtime.dispatch("session.snapshot", {}))["archived"] is not archived + assert (await runtime.dispatch("session.list", {"archived": not archived}))[0]["id"] == manager.header.id + assert events == [] + finally: + await runtime.dispose() + asyncio.run(scenario()) diff --git a/packages/core/src/agent_core/session/session_manager.py b/packages/core/src/agent_core/session/session_manager.py index 6753b07..7050534 100644 --- a/packages/core/src/agent_core/session/session_manager.py +++ b/packages/core/src/agent_core/session/session_manager.py @@ -11,6 +11,7 @@ import copy import os +from dataclasses import replace from pathlib import Path from typing import Any @@ -30,6 +31,7 @@ session_dirs_for_cwd, session_file_path, write_header_line, + write_archive_state, ) from agent_core.session.types import ( CompactionEntry, @@ -174,6 +176,7 @@ def continue_recent( continueRecent. """ cwd = cwd or os.getcwd() + normalized_cwd = os.path.normcase(os.path.realpath(cwd)) files = [ path for directory in session_dirs_for_cwd( @@ -182,11 +185,12 @@ def continue_recent( for path in list_session_files(directory) ] files.sort(key=lambda path: path.stat().st_mtime, reverse=True) - if not files: - return None - return cls.open( - files[0], agent_dir=agent_dir, sessions_dir=sessions_dir, - ) + for path in files: + header = read_header(path) + if (header is not None and not header.archived and isinstance(header.cwd, str) and header.cwd + and os.path.normcase(os.path.realpath(header.cwd)) == normalized_cwd): + return cls.open(path, agent_dir=agent_dir, sessions_dir=sessions_dir) + return None @classmethod def list_sessions( @@ -195,9 +199,16 @@ def list_sessions( cwd: str | None = None, agent_dir: Path | None = None, sessions_dir: Path | None = None, + archived: bool | None = None, ) -> list[SessionInfo]: - """List sessions for this cwd, newest first.""" + """List newest first, defaulting to all sessions for legacy callers. + + Desktop lists explicitly filter active (False) or archived (True). + """ + if archived is not None and not isinstance(archived, bool): + raise ValueError("archived must be a boolean or None") cwd = cwd or os.getcwd() + normalized_cwd = os.path.normcase(os.path.realpath(cwd)) infos: list[SessionInfo] = [] files = [ path @@ -208,7 +219,12 @@ def list_sessions( ] files.sort(key=lambda path: path.stat().st_mtime, reverse=True) for p in files: - info = build_session_info(p) + header = read_header(p) + if (header is None or not isinstance(header.cwd, str) or not header.cwd + or os.path.normcase(os.path.realpath(header.cwd)) != normalized_cwd + or (archived is not None and header.archived != archived)): + continue + info = build_session_info(p, header=header) if info is not None: infos.append(info) return infos @@ -334,6 +350,22 @@ def set_name(self, name: str) -> SessionInfoEntry: self._commit(entry) return entry + def set_archived(self, archived: bool) -> None: + """Update session-wide visibility without changing history or its branch. + + Persisted sessions atomically replace small sibling visibility metadata + before changing live state, preserving concurrent history appends. + Unflushed sessions retain normal first-assistant persistence semantics. + """ + if not isinstance(archived, bool): + raise ValueError("archived must be a boolean") + if self.header.archived == archived: + return + header = replace(self.header, archived=archived) + if not self.in_memory and self.path is not None and self._flushed: + write_archive_state(self.path, archived) + self.header = header + def append_collaboration_mode_change( self, mode: str, *, plan_id: str | None = None, reason: str | None = None, related_session_id: str | None = None, diff --git a/packages/core/src/agent_core/session/storage.py b/packages/core/src/agent_core/session/storage.py index 6d50c4c..234accd 100644 --- a/packages/core/src/agent_core/session/storage.py +++ b/packages/core/src/agent_core/session/storage.py @@ -16,6 +16,7 @@ """ from __future__ import annotations +import hashlib import json import os import tempfile @@ -52,6 +53,7 @@ "filename_for_session", "write_header_line", "rewrite_header_line", + "write_archive_state", "append_entry_line", "read_header", "read_entries", @@ -382,7 +384,7 @@ def line_dict_to_entry(d: dict) -> SessionEntry: def header_to_dict(header: SessionHeader) -> dict: - return { + result = { "type": "session", "version": header.version, "id": header.id, @@ -390,15 +392,24 @@ def header_to_dict(header: SessionHeader) -> dict: "cwd": header.cwd, "parentSession": header.parent_session, } + if header.archived: + # An unflushed archived session still carries its initial state when + # first materialized; later changes live outside the append-only log. + result["archived"] = True + return result def dict_to_header(d: dict) -> SessionHeader: + archived = d.get("archived", False) + if not isinstance(archived, bool): + raise ValueError("Session archived metadata must be a boolean") return SessionHeader( id=d.get("id", ""), timestamp=d.get("timestamp", ""), cwd=d.get("cwd", ""), version=int(d.get("version", 1) or 1), parent_session=d.get("parentSession"), + archived=archived, ) @@ -434,6 +445,33 @@ def rewrite_header_line(path: Path, header: SessionHeader) -> None: temp_path.unlink() +def _archive_state_path(path: Path) -> Path: + # Hash the basename, never user-controlled path fragments. The sibling is + # short enough for Windows MAX_PATH even when the JSONL name is long. + identity = hashlib.sha256(os.path.normcase(path.name).encode("utf-8")).hexdigest()[:32] + return path.with_name(f".archive-{identity}.json") + + +def write_archive_state(path: Path, archived: bool) -> None: + """Atomically persist visibility without rewriting a concurrent JSONL append.""" + if not isinstance(archived, bool): + raise ValueError("archived must be a boolean") + if not path.is_file(): + raise FileNotFoundError(path) + target_path = _archive_state_path(path) + fd, raw_temp = tempfile.mkstemp(prefix=".archive-", suffix=".tmp", dir=path.parent) + temp_path = Path(raw_temp) + try: + with os.fdopen(fd, "wb") as target: + target.write(b"true\n" if archived else b"false\n") + target.flush() + os.fsync(target.fileno()) + os.replace(temp_path, target_path) + finally: + if temp_path.exists(): + temp_path.unlink() + + def append_entry_line(path: Path, entry: SessionEntry) -> None: """Append one entry as a JSON line (append-only).""" encoded = (json.dumps(entry_to_line_dict(entry), ensure_ascii=False) + "\n").encode("utf-8") @@ -474,7 +512,15 @@ def read_header(path: Path) -> SessionHeader | None: line = f.readline() if not line: return None - return dict_to_header(json.loads(line)) + header = dict_to_header(json.loads(line)) + try: + state = json.loads(_archive_state_path(path).read_text(encoding="utf-8")) + except FileNotFoundError: + return header + if not isinstance(state, bool): + raise ValueError("Session archive metadata must be a boolean") + header.archived = state + return header except (OSError, ValueError): return None @@ -595,9 +641,9 @@ def list_session_files(sessions_dir: Path) -> list[Path]: return files -def build_session_info(path: Path) -> SessionInfo | None: +def build_session_info(path: Path, *, header: SessionHeader | None = None) -> SessionInfo | None: """Build a SessionInfo summary by scanning a JSONL file (for /sessions).""" - header = read_header(path) + header = header if header is not None else read_header(path) if header is None: return None entries = read_entries(path) @@ -650,4 +696,5 @@ def build_session_info(path: Path) -> SessionInfo | None: first_message=first_text, all_messages_text="\n".join(parts), name=name, + archived=header.archived, ) diff --git a/packages/core/src/agent_core/session/types.py b/packages/core/src/agent_core/session/types.py index 095b8d3..691023b 100644 --- a/packages/core/src/agent_core/session/types.py +++ b/packages/core/src/agent_core/session/types.py @@ -50,6 +50,7 @@ class SessionHeader: type: Literal["session"] = "session" version: int = CURRENT_SESSION_VERSION parent_session: str | None = None + archived: bool = False # ─── Entry base + variants ──────────────── @@ -304,3 +305,4 @@ class SessionInfo: first_message: str all_messages_text: str name: str | None = None + archived: bool = False diff --git a/packages/core/tests/test_session_archive.py b/packages/core/tests/test_session_archive.py new file mode 100644 index 0000000..a9ae9a9 --- /dev/null +++ b/packages/core/tests/test_session_archive.py @@ -0,0 +1,392 @@ +"""Archiving changes visibility while preserving the session and branch history.""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest +from agent_llm import AssistantMessage, TextContent, UserMessage + +from agent_core.session.session_manager import SessionManager +from agent_core.session.storage import dict_to_header, header_to_dict, read_header, rewrite_header_line, write_header_line +from agent_core.session.types import SessionHeader + + +CWD = "/archive/project" + + +def _saved(tmp_path: Path, text: str = "question") -> SessionManager: + manager = SessionManager.create(cwd=CWD, agent_dir=tmp_path) + manager.append_message(UserMessage(content=text)) + manager.append_message(AssistantMessage(content=[TextContent(text="answer")])) + assert manager.path is not None and manager.path.exists() + return manager + + +def _tail(path: Path) -> bytes: + return path.read_bytes().split(b"\n", 1)[1] + + +def test_archive_and_restore_preserve_id_path_history_and_branch(tmp_path: Path): + manager = _saved(tmp_path) + manager.set_name("保留名称") + manager.append_collaboration_mode_change("plan", plan_id="plan-1") + revision = manager.append_plan_revision( + plan_id="plan-1", revision=1, title="计划", markdown="实施步骤", + digest="a" * 64, source_message_id="message-1", + ) + manager.append_message(UserMessage(content="another branch")) + manager.set_leaf_id(revision.id) + manager.flush() + path = manager.path + assert path is not None + session_id = manager.header.id + entries = list(manager.entries) + branch = manager.get_branch() + context = manager.build_session_context() + tail = _tail(path) + original_log = path.read_bytes() + + manager.set_archived(True) + assert manager.path == path and manager.header.id == session_id + assert path.read_bytes() == original_log + assert _tail(path) == tail + reopened = SessionManager.open(path, agent_dir=tmp_path) + assert reopened.header.archived is True + assert reopened.entries == entries + assert reopened.leaf_id == revision.id + assert reopened.get_branch() == branch + assert reopened.build_session_context() == context + assert reopened.get_name() == "保留名称" + + reopened.set_archived(False) + restored = SessionManager.open(path, agent_dir=tmp_path) + assert restored.header.archived is False + assert restored.header.id == session_id + assert restored.path == path and _tail(path) == tail + assert path.read_bytes() == original_log + assert restored.get_branch() == branch + + +def test_list_filters_before_loading_history_and_supports_all(tmp_path: Path, monkeypatch): + active = _saved(tmp_path, "active") + archived = _saved(tmp_path, "archived") + archived.set_archived(True) + from agent_core.session import storage + + original = storage.read_entries + scanned = [] + + def record_read(path, **kwargs): + scanned.append(path) + return original(path, **kwargs) + + monkeypatch.setattr(storage, "read_entries", record_read) + infos = SessionManager.list_sessions(cwd=CWD, agent_dir=tmp_path, archived=False) + assert [info.id for info in infos] == [active.header.id] + assert infos[0].archived is False + assert scanned == [active.path] + scanned.clear() + infos = SessionManager.list_sessions(cwd=CWD, agent_dir=tmp_path, archived=True) + assert [info.id for info in infos] == [archived.header.id] + assert infos[0].archived is True + assert scanned == [archived.path] + all_infos = SessionManager.list_sessions(cwd=CWD, agent_dir=tmp_path, archived=None) + assert {info.id for info in all_infos} == {active.header.id, archived.header.id} + assert {info.id for info in SessionManager.list_sessions(cwd=CWD, agent_dir=tmp_path)} == { + active.header.id, archived.header.id, + } + + +def test_continue_recent_skips_archived_without_loading_its_entries(tmp_path: Path, monkeypatch): + active = _saved(tmp_path, "active") + archived = _saved(tmp_path, "archived") + archived.set_archived(True) + assert active.path is not None and archived.path is not None + os.utime(active.path, (1, 1)) + os.utime(archived.path, (2, 2)) + from agent_core.session import session_manager + + original = session_manager.read_entries + scanned = [] + + def record_read(path, **kwargs): + scanned.append(path) + return original(path, **kwargs) + + monkeypatch.setattr(session_manager, "read_entries", record_read) + recent = SessionManager.continue_recent(cwd=CWD, agent_dir=tmp_path) + assert recent is not None and recent.header.id == active.header.id + assert scanned == [active.path] + active.set_archived(True) + scanned.clear() + assert SessionManager.continue_recent(cwd=CWD, agent_dir=tmp_path) is None + assert scanned == [] + + +def test_legacy_header_defaults_to_active(tmp_path: Path): + manager = _saved(tmp_path) + assert manager.path is not None + raw_header = header_to_dict(manager.header) + raw_header.pop("archived", None) + manager.path.write_bytes(json.dumps(raw_header).encode() + b"\n" + _tail(manager.path)) + restored = SessionManager.open(manager.path) + assert restored.header.archived is False + assert [item.id for item in SessionManager.list_sessions(cwd=CWD, agent_dir=tmp_path, archived=False)] == [manager.header.id] + + +@pytest.mark.parametrize("invalid", [None, 0, 1, "false", "true", [], {}]) +def test_invalid_archive_metadata_is_rejected(invalid): + raw = header_to_dict(SessionHeader(id="id", timestamp="timestamp", cwd=CWD)) + raw["archived"] = invalid + with pytest.raises(ValueError, match="boolean"): + dict_to_header(raw) + + +@pytest.mark.parametrize("failure", ["replace", "fsync"]) +@pytest.mark.parametrize("archived", [True, False]) +def test_failed_archive_is_atomic_and_leaves_no_temporary_file(tmp_path: Path, monkeypatch, failure, archived): + manager = _saved(tmp_path) + manager.set_archived(not archived) + assert manager.path is not None + before = manager.path.read_bytes() + header = manager.header + entries = list(manager.entries) + leaf = manager.leaf_id + + def fail(*_args): + raise OSError("disk failure") + + monkeypatch.setattr(f"agent_core.session.storage.os.{failure}", fail) + with pytest.raises(OSError, match="disk failure"): + manager.set_archived(archived) + assert manager.header is header and manager.header.archived is not archived + assert manager.entries == entries and manager.leaf_id == leaf + assert manager.path.read_bytes() == before + persisted_header = read_header(manager.path) + assert persisted_header is not None and persisted_header.archived is not archived + assert list(manager.path.parent.glob("*.tmp")) == [] + assert list(manager.path.parent.glob(".*.tmp")) == [] + + +def test_archive_does_not_materialize_empty_or_lose_buffered_entries(tmp_path: Path): + manager = SessionManager.create(cwd=CWD, agent_dir=tmp_path) + assert manager.path is not None + manager.set_archived(True) + assert not manager.path.exists() + message = UserMessage(content="buffered question") + manager.append_message(message) + manager.set_archived(False) + manager.set_archived(True) + assert not manager.path.exists() + manager.flush() + restored = SessionManager.open(manager.path) + assert restored.header.archived is True + assert restored.entries == manager.entries + assert restored.build_session_context().messages == [message] + + +def test_in_memory_archive_is_reversible_without_touching_disk(tmp_path: Path): + manager = SessionManager.create(cwd=CWD, agent_dir=tmp_path, in_memory=True) + manager.append_message(UserMessage(content="memory only")) + manager.set_archived(True) + manager.flush() + assert manager.header.archived is True + assert list(tmp_path.iterdir()) == [] + manager.set_archived(False) + assert manager.header.archived is False + + +@pytest.mark.parametrize("invalid", [0, 1, None, "false"]) +def test_archive_mutation_requires_boolean(tmp_path: Path, invalid): + manager = _saved(tmp_path) + with pytest.raises(ValueError, match="boolean"): + manager.set_archived(invalid) + + +def test_repeated_archive_does_not_rewrite_the_header(tmp_path: Path, monkeypatch): + manager = _saved(tmp_path) + manager.set_archived(True) + + def unexpected(*_args): + raise AssertionError("unchanged archive state must not touch the file") + + monkeypatch.setattr("agent_core.session.session_manager.write_archive_state", unexpected) + manager.set_archived(True) + + +def test_archive_uses_short_sibling_temp_for_valid_long_windows_path(tmp_path: Path, monkeypatch): + from agent_core.session import storage + + parent = tmp_path / ("deep-" + "x" * max(1, 185 - len(str(tmp_path)) - 6)) + path = parent / "2026-10-25T13-18-03-696Z_01a17336-09b0-7212-97a7-c6c87396fb13.jsonl" + assert len(str(path)) < 260 + header = SessionHeader(id="saved-session", timestamp="2026-10-25T13:18:03Z", cwd=CWD) + write_header_line(path, header) + with path.open("ab") as target: + target.write(b'{"preserved":"entry bytes"}\r\n') + tail = _tail(path) + mkstemp = storage.tempfile.mkstemp + + def windows_mkstemp(*, prefix, suffix, dir): + # Apply the real Windows constraint on every platform, including CI. + if len(str(Path(dir) / f"{prefix}{'x' * 8}{suffix}")) >= 260: + raise FileNotFoundError("Windows MAX_PATH exceeded") + return mkstemp(prefix=prefix, suffix=suffix, dir=dir) + + monkeypatch.setattr(storage.tempfile, "mkstemp", windows_mkstemp) + header.archived = True + rewrite_header_line(path, header) + archived_header = read_header(path) + assert archived_header is not None and archived_header.archived is True + assert _tail(path) == tail + assert list(parent.glob(".*.tmp")) == [] + + +@pytest.mark.parametrize("archived", [False, True, None]) +def test_list_rejects_other_workspace_when_directory_encoding_collides(tmp_path: Path, archived): + from agent_core.session.ids import encode_cwd + + cwd, foreign_cwd = "/archive/project/nested", "/archive/project-nested" + assert encode_cwd(cwd) == encode_cwd(foreign_cwd) + managers = [] + for workspace in (cwd, foreign_cwd): + manager = SessionManager.create(cwd=workspace, agent_dir=tmp_path) + manager.append_message(AssistantMessage()) + manager.set_archived(archived is True) + managers.append(manager) + assert [info.id for info in SessionManager.list_sessions( + cwd=cwd, agent_dir=tmp_path, archived=archived, + )] == [managers[0].header.id] + + +def test_recent_rejects_other_workspace_when_directory_encoding_collides(tmp_path: Path): + cwd, foreign_cwd = "/archive/project/nested", "/archive/project-nested" + current = SessionManager.create(cwd=cwd, agent_dir=tmp_path) + foreign = SessionManager.create(cwd=foreign_cwd, agent_dir=tmp_path) + for manager, modified in ((current, 1), (foreign, 2)): + manager.append_message(AssistantMessage()) + assert manager.path is not None + os.utime(manager.path, (modified, modified)) + recent = SessionManager.continue_recent(cwd=cwd, agent_dir=tmp_path) + assert recent is not None and recent.header.id == current.header.id + current.set_archived(True) + assert SessionManager.continue_recent(cwd=cwd, agent_dir=tmp_path) is None + + +def test_workspace_filter_accepts_normalized_equivalent_cwd(tmp_path: Path): + manager = SessionManager.create(cwd="/archive/project/../project", agent_dir=tmp_path) + manager.append_message(AssistantMessage()) + assert [info.id for info in SessionManager.list_sessions(cwd=CWD, agent_dir=tmp_path, archived=False)] == [manager.header.id] + recent = SessionManager.continue_recent(cwd=CWD, agent_dir=tmp_path) + assert recent is not None and recent.header.id == manager.header.id + + +def test_archive_preserves_an_append_from_another_open_manager(tmp_path: Path, monkeypatch): + from agent_core.session import storage + + manager = _saved(tmp_path) + path = manager.path + assert path is not None + other = SessionManager.open(path) + original_replace = storage.os.replace + appended_message = UserMessage(content="concurrent append sentinel") + + def append_then_replace(source, target): + # The other writer appends after archive preparation and immediately + # before the atomic visibility update. Its committed line must survive. + other.append_message(appended_message) + assert b"concurrent append sentinel" in path.read_bytes() + original_replace(source, target) + + monkeypatch.setattr(storage.os, "replace", append_then_replace) + manager.set_archived(True) + restored = SessionManager.open(path) + assert restored.header.archived is True + assert restored.build_session_context().messages[-1] == appended_message + assert restored.entries == other.entries + + +def test_false_sidecar_overrides_a_legacy_archived_header(tmp_path: Path): + manager = _saved(tmp_path) + assert manager.path is not None + manager.header.archived = True + rewrite_header_line(manager.path, manager.header) + original_log = manager.path.read_bytes() + assert json.loads(original_log.split(b"\n", 1)[0])["archived"] is True + reopened = SessionManager.open(manager.path) + reopened.set_archived(False) + assert manager.path.read_bytes() == original_log + assert SessionManager.open(manager.path).header.archived is False + assert [info.id for info in SessionManager.list_sessions(cwd=CWD, agent_dir=tmp_path, archived=False)] == [manager.header.id] + + +def test_archive_metadata_is_not_a_session_and_uses_a_short_stable_name(tmp_path: Path): + from agent_core.session.storage import _archive_state_path + + manager = _saved(tmp_path) + assert manager.path is not None + manager.set_archived(True) + metadata = _archive_state_path(manager.path) + assert metadata.parent == manager.path.parent + assert metadata.name.startswith(".archive-") and metadata.suffix == ".json" + assert len(metadata.name) < len(manager.path.name) + assert json.loads(metadata.read_text(encoding="utf-8")) is True + assert _archive_state_path(tmp_path / manager.path.name).name == metadata.name + manager.path.unlink() + assert metadata.exists() + assert SessionManager.list_sessions(cwd=CWD, agent_dir=tmp_path, archived=None) == [] + assert SessionManager.continue_recent(cwd=CWD, agent_dir=tmp_path) is None + + +@pytest.mark.skipif(os.name != "nt", reason="Windows filenames are case insensitive") +def test_archive_state_uses_the_same_identity_for_windows_filename_case_variants(tmp_path: Path): + manager = _saved(tmp_path) + assert manager.path is not None + manager.set_archived(True) + variant = manager.path.with_name(manager.path.name.upper()) + assert variant.is_file() + reopened = SessionManager.open(variant) + assert reopened.header.archived is True + reopened.set_archived(False) + assert SessionManager.open(manager.path).header.archived is False + + +def test_archive_sidecar_handles_a_valid_long_windows_path(tmp_path: Path, monkeypatch): + from agent_core.session import storage + + parent = tmp_path / ("deep-" + "x" * max(1, 185 - len(str(tmp_path)) - 6)) + path = parent / "2026-10-25T13-18-03-696Z_01a17336-09b0-7212-97a7-c6c87396fb13.jsonl" + assert len(str(path)) < 260 + header = SessionHeader(id="saved-session", timestamp="2026-10-25T13:18:03Z", cwd=CWD) + write_header_line(path, header) + original_log = path.read_bytes() + mkstemp = storage.tempfile.mkstemp + + def windows_mkstemp(*, prefix, suffix, dir): + if len(str(Path(dir) / f"{prefix}{'x' * 8}{suffix}")) >= 260: + raise FileNotFoundError("Windows MAX_PATH exceeded") + return mkstemp(prefix=prefix, suffix=suffix, dir=dir) + + monkeypatch.setattr(storage.tempfile, "mkstemp", windows_mkstemp) + storage.write_archive_state(path, True) + assert len(str(storage._archive_state_path(path))) < 260 + restored = read_header(path) + assert restored is not None and restored.archived is True + assert path.read_bytes() == original_log + storage.write_archive_state(path, False) + restored = read_header(path) + assert restored is not None and restored.archived is False + assert list(parent.glob(".*.tmp")) == [] + + +@pytest.mark.parametrize("invalid", [None, 1, "true", {}, []]) +def test_invalid_sidecar_state_does_not_resume_a_possibly_archived_session(tmp_path: Path, invalid): + from agent_core.session.storage import _archive_state_path + + manager = _saved(tmp_path) + assert manager.path is not None + _archive_state_path(manager.path).write_text(json.dumps(invalid), encoding="utf-8") + assert read_header(manager.path) is None + assert SessionManager.continue_recent(cwd=CWD, agent_dir=tmp_path) is None From e3644ad7f6fda40e230045a8ce3ec33e6c90d6da Mon Sep 17 00:00:00 2001 From: JiuXiang <904085642@qq.com> Date: Mon, 26 Oct 2026 20:44:25 +0800 Subject: [PATCH 2/2] fix(desktop): address session archive review findings --- .../desktop/src/renderer/App.archive.test.tsx | 2 +- .../src/renderer/workbench/Workbench.test.tsx | 43 +++++ .../src/renderer/workbench/Workbench.tsx | 2 +- .../desktop/src/renderer/workbench/panels.tsx | 18 +- docs/specs/session-archive.md | 5 +- .../app/src/coding_agent/desktop/runtime.py | 22 ++- .../app/tests/test_desktop_session_archive.py | 128 +++++++++++++++ .../src/agent_core/session/session_manager.py | 12 +- .../core/src/agent_core/session/storage.py | 36 +++- packages/core/tests/test_session_archive.py | 155 +++++++++++++++--- 10 files changed, 374 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/renderer/App.archive.test.tsx b/apps/desktop/src/renderer/App.archive.test.tsx index 899aace..ca51a2a 100644 --- a/apps/desktop/src/renderer/App.archive.test.tsx +++ b/apps/desktop/src/renderer/App.archive.test.tsx @@ -22,7 +22,7 @@ describe("desktop session archive", () => { const payload = (id = selected, path = project): WorkspacePayload => ({ path, sessionId: id, archived: records.get(id)?.archived ?? false, - model: { id: "offline", name: "Offline", provider: "offline", input: ["text"] }, + model: { id: "offline", name: "Offline", provider: "offline" }, thinkingLevel: null, tools: [], collaborationMode: "default", messages: [ { role: "user", content: `Question in ${id}` }, diff --git a/apps/desktop/src/renderer/workbench/Workbench.test.tsx b/apps/desktop/src/renderer/workbench/Workbench.test.tsx index d467160..9d60b15 100644 --- a/apps/desktop/src/renderer/workbench/Workbench.test.tsx +++ b/apps/desktop/src/renderer/workbench/Workbench.test.tsx @@ -82,6 +82,49 @@ describe("workspace workbench", () => { expect(screen.getByRole("button", { name: /^侧边对话/ })).toBeVisible(); }); + it("allows reading archived side conversations without creating or sending one", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /^侧边对话/ })); + await screen.findByRole("option", { name: "Side question" }); + expect(screen.getByRole("button", { name: "新建侧边对话" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "开始侧边对话" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "新建侧边对话" })); + fireEvent.click(screen.getByRole("button", { name: "开始侧边对话" })); + expect(request).not.toHaveBeenCalledWith("workbench.chat.open", scope); + + fireEvent.change(screen.getByRole("combobox", { name: "选择侧边对话" }), { target: { value: "child" } }); + const input = await screen.findByLabelText("侧边对话消息"); + expect(request).toHaveBeenCalledWith("workbench.chat.open", { ...scope, conversationId: "child" }); + expect(input).toBeDisabled(); + expect(screen.getByRole("button", { name: "发送 ↑" })).toBeDisabled(); + await waitFor(() => expect(screen.getByRole("combobox", { name: "选择侧边对话" })).toBeEnabled()); + }); + + it("updates an open side conversation to read-only on archive and preserves its draft on restore", async () => { + const view = render(); + fireEvent.click(screen.getByRole("button", { name: /^侧边对话/ })); + fireEvent.click(screen.getByRole("button", { name: "开始侧边对话" })); + const input = await screen.findByLabelText("侧边对话消息"); + await waitFor(() => expect(input).toBeEnabled()); + fireEvent.change(input, { target: { value: "Preserved side draft" } }); + + view.rerender(); + expect(input).toBeDisabled(); + expect(screen.getByRole("button", { name: "发送 ↑" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "新建侧边对话" })).toBeDisabled(); + fireEvent.submit(input.closest("form")!); + fireEvent.keyDown(input, { key: "Enter" }); + expect(request.mock.calls.some(([method]) => method === "workbench.chat.send")).toBe(false); + expect(input).toHaveValue("Preserved side draft"); + + view.rerender(); + expect(input).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "发送 ↑" })); + await waitFor(() => expect(request).toHaveBeenCalledWith("workbench.chat.send", { + ...scope, conversationId: "child", text: "Preserved side draft", + })); + }); + it("limits pointer and keyboard resizing by sidebar and conversation widths", () => { vi.stubGlobal("innerWidth", 1440); vi.stubGlobal("PointerEvent", MouseEvent); diff --git a/apps/desktop/src/renderer/workbench/Workbench.tsx b/apps/desktop/src/renderer/workbench/Workbench.tsx index add1f9e..5126e74 100644 --- a/apps/desktop/src/renderer/workbench/Workbench.tsx +++ b/apps/desktop/src/renderer/workbench/Workbench.tsx @@ -81,7 +81,7 @@ export const Workbench = memo(function Workbench({ workspace, open, onClose, wid return ; diff --git a/apps/desktop/src/renderer/workbench/panels.tsx b/apps/desktop/src/renderer/workbench/panels.tsx index ce78ae4..81886ec 100644 --- a/apps/desktop/src/renderer/workbench/panels.tsx +++ b/apps/desktop/src/renderer/workbench/panels.tsx @@ -149,7 +149,7 @@ function messageText(message: AgentMessage): string { return typeof message.content === "string" ? message.content : message.content.map((block) => block.text ?? "").join(""); } -export function SideChatPanel({ scope }: { scope: WorkbenchScope }) { +export function SideChatPanel({ scope, readOnly = false }: { scope: WorkbenchScope; readOnly?: boolean }) { const [conversations, setConversations] = useState<{ conversationId: string; title: string }[]>([]); const [snapshot, setSnapshot] = useState(null); const [drafts, setDrafts] = useState>({}); @@ -171,6 +171,7 @@ export function SideChatPanel({ scope }: { scope: WorkbenchScope }) { }, [scope, refreshList]); useEffect(() => { bottom.current?.scrollIntoView?.({ block: "nearest" }); }, [snapshot?.streaming, snapshot?.messages.length]); const open = async (conversationId?: string) => { + if (readOnly && !conversationId) return; const generation = ++navigation.current; const seq = eventSeq.current; selected.current = conversationId ?? null; @@ -185,7 +186,8 @@ export function SideChatPanel({ scope }: { scope: WorkbenchScope }) { finally { if (generation === navigation.current) setBusy(false); } }; const request = async (method: string, extra: Record = {}) => { - if (!snapshot || busy) return; + if (!snapshot || busy || (readOnly && (method === "workbench.chat.send" + || (method === "workbench.chat.approve" && extra.allowed === true)))) return; const id = snapshot.conversationId, generation = navigation.current, seq = eventSeq.current; setBusy(true); setError(null); try { @@ -197,10 +199,10 @@ export function SideChatPanel({ scope }: { scope: WorkbenchScope }) { }; const draft = drafts[snapshot?.conversationId ?? ""] ?? ""; return
-
+

独立上下文 · 使用当前会话可用的开发工具 · 不会向主对话注入消息

- {!snapshot &&

在独立对话中追问,不打断当前任务。

} + {!snapshot &&

在独立对话中追问,不打断当前任务。

} {snapshot?.messages.map((message, index) => message.role === "toolResult" ?
{message.tool_name || "工具结果"}{message.is_error ? " · 失败" : ""}
{messageText(message)}
:
{message.role === "user" ? "你" : "AI"}{messageText(message)}
)} {snapshot?.streaming &&
AI{snapshot.streaming}
} {snapshot?.status === "running" && !snapshot.streaming &&

侧边对话正在处理…

} @@ -208,10 +210,10 @@ export function SideChatPanel({ scope }: { scope: WorkbenchScope }) { {error &&

{error}

}
- {snapshot?.approval &&
批准 {snapshot.approval.toolName}?
{JSON.stringify(snapshot.approval.args, null, 2)}
} - {snapshot &&
{ event.preventDefault(); if (draft.trim() && snapshot.status !== "running") void request("workbench.chat.send", { text: draft }); }}> -