From e451305e20e525e4d23ea7ecdb2dcce0a7e7b6cc Mon Sep 17 00:00:00 2001 From: LetItBe12345 Date: Wed, 29 Jul 2026 13:34:01 +0800 Subject: [PATCH] fix empty new session history race --- .github/workflows/ci.yml | 2 +- .../38-fix-empty-new-session-history-race.md | 29 ++++ src/renderer/app.tsx | 33 +++- tests/renderer/app.test.tsx | 146 ++++++++++++++++++ 4 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 TODO/done/38-fix-empty-new-session-history-race.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d4cf61..7640246 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: run: pnpm lint - name: Format check run: pnpm format:check - - name: Workspace and Session regression tests + - name: Workspace, Session, and empty new-session regression tests run: pnpm test:workspace-session - name: Test run: pnpm test diff --git a/TODO/done/38-fix-empty-new-session-history-race.md b/TODO/done/38-fix-empty-new-session-history-race.md new file mode 100644 index 0000000..1a79df8 --- /dev/null +++ b/TODO/done/38-fix-empty-new-session-history-race.md @@ -0,0 +1,29 @@ +# MVP-38:修复空新会话被旧历史覆盖 + +## 问题 + +旧 Session 的 `getMessages()` 请求可能在用户点击“新建对话”后才返回。Renderer +会把这份迟到的历史写入临时新会话的空白投影,界面因此看起来自动切回了 +旧会话。会话列表同时仍把 Runtime 底层持有的旧 Session 显示为选中。 + +## 完成条件 + +- [x] 进入临时新会话时取消旧 Session 的历史恢复,迟到结果不能改写空白界面。 +- [x] Runtime 继续持有旧 Session 时,临时新会话不把旧 Session 行显示为当前项。 +- [x] 显式点击旧 Session 仍能退出临时新会话并恢复历史。 +- [x] Renderer 回归测试覆盖迟到历史、Runtime 快照、OMP 实时事件和显式切换。 +- [x] CI 的 Workspace/Session 专项步骤运行上述测试。 +- [x] 类型检查、Lint、格式检查和完整测试通过。 + +## 实施结果 + +- 临时新会话作为独立的 Renderer 显示状态,不恢复 Runtime 底层旧 Session 的历史。 +- 旧历史请求、旧 OMP 投影事件和中断输入在临时新会话期间不改写当前对话。 +- 会话侧栏和对话标题使用去掉旧 Session ID 和名称的可见 Runtime 快照。 + +## 验证 + +- `pnpm test:workspace-session`:3 个测试文件、37 项测试通过。 +- `pnpm check`:25 个测试文件、167 项测试通过,类型、Lint 和格式检查通过。 +- `OMP_RPC_FAKE=1 node scripts/rpc-smoke.mjs`:通过。 +- `pnpm build`:通过。 diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 5c383cf..4f9c91e 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -949,16 +949,25 @@ export function App(): React.JSX.Element { const sessionRequestId = useRef(0) const workspaceRequestPending = useRef(false) const runtimeReadyRef = useRef(false) + const temporarySessionRef = useRef(temporarySession) const activeWorkspaceId = overview.activeWorkspaceId const activeWorkspaceIdRef = useRef(activeWorkspaceId) const sessionSearchRef = useRef(sessionSearch) const runtimeRef = useRef(runtime) const currentProjectionKey = runtimeSessionKey(runtime) + const visibleRuntime = useMemo( + () => + temporarySession + ? { ...runtime, sessionId: undefined, sessionName: undefined } + : runtime, + [runtime, temporarySession] + ) useLayoutEffect(() => { projectionRef.current = projection runtimeRef.current = runtime - }, [projection, runtime]) + temporarySessionRef.current = temporarySession + }, [projection, runtime, temporarySession]) useLayoutEffect(() => { activeWorkspaceIdRef.current = activeWorkspaceId @@ -974,7 +983,8 @@ export function App(): React.JSX.Element { const nextProjectionId = runtimeSessionKey(snapshot) if (nextProjectionId !== projectionSessionId.current) { projectionSessionId.current = nextProjectionId - if (!preserveProjection) setProjection(createConversationProjection()) + if (!preserveProjection && !temporarySessionRef.current) + setProjection(createConversationProjection()) } setSlashCatalog((current) => { if (current.sessionKey === nextProjectionId) return current @@ -1165,6 +1175,7 @@ export function App(): React.JSX.Element { [key: string]: unknown }): void => { if (ompEvent.type === 'runtime_interrupted') { + if (temporarySessionRef.current) return const input = ompEvent['input'] if ( input && @@ -1231,6 +1242,7 @@ export function App(): React.JSX.Element { }) return } + if (temporarySessionRef.current) return setProjection((current) => reduceOmpEvent(current, ompEvent)) } if (event.type === 'omp-event') handleOmpEvent(event.event) @@ -1319,6 +1331,7 @@ export function App(): React.JSX.Element { ]) useEffect(() => { + if (temporarySession) return if ( runtime.status !== 'ready' || !runtime.sessionId || @@ -1353,7 +1366,12 @@ export function App(): React.JSX.Element { cancelled = true window.clearTimeout(loadingTimer) } - }, [currentProjectionKey, runtime.sessionId, runtime.status]) + }, [ + currentProjectionKey, + runtime.sessionId, + runtime.status, + temporarySession + ]) const openWorkspace = async (): Promise => { if (workspaceRequestPending.current) return @@ -1500,6 +1518,7 @@ export function App(): React.JSX.Element { } applySnapshot(detached.data) setTemporarySession(true) + setOpeningSession(false) setTemporaryApprovalMode('yolo') setComposerInput('') setReferences([]) @@ -1542,6 +1561,7 @@ export function App(): React.JSX.Element { applySnapshot(left.data) if (!alternative) { setTemporarySession(true) + setOpeningSession(false) setTemporaryApprovalMode('yolo') setComposerInput('') setReferences([]) @@ -1584,6 +1604,7 @@ export function App(): React.JSX.Element { return } setTemporarySession(true) + setOpeningSession(false) setTemporaryApprovalMode('yolo') setComposerInput('') setReferences([]) @@ -1592,7 +1613,7 @@ export function App(): React.JSX.Element { setProjection(createConversationProjection()) setSessionError(null) }} - runtime={runtime} + runtime={visibleRuntime} overview={overview} onOpenWorkspace={() => void openWorkspace()} openingWorkspace={openingWorkspace} @@ -1642,14 +1663,14 @@ export function App(): React.JSX.Element { ) return undefined }} - runtime={runtime} + runtime={visibleRuntime} workspaceId={activeWorkspaceId} /> { ).toBeInTheDocument() }) + it('临时新会话忽略旧会话迟到的历史和 Runtime 快照,显式切回后才恢复', async () => { + let runtimeListener: + Parameters[0] | undefined + vi.mocked(window.desktop.onRuntimeEvent).mockImplementationOnce( + (listener) => { + runtimeListener = listener + return vi.fn() + } + ) + vi.mocked(window.desktop.getWorkspaces).mockResolvedValueOnce({ + ok: true, + data: { + activeWorkspaceId: 'workspace-1', + workspaces: [ + { + id: 'workspace-1', + path: '/tmp/workspace', + name: 'workspace', + available: true, + pinned: false, + addedAt: '2026-01-01T00:00:00.000Z', + lastUsedAt: '2026-01-01T00:00:00.000Z' + } + ], + hasMore: false + } + }) + vi.mocked(window.desktop.getRuntimeState).mockResolvedValueOnce({ + ok: true, + data: { + status: 'ready', + workspacePath: '/tmp/workspace', + sessionId: 'old-session', + sessionName: '旧会话', + isStreaming: false, + queuedMessageCount: 0 + } + }) + vi.mocked(window.desktop.listSessions).mockResolvedValue({ + ok: true, + data: { + sessions: [ + { + id: 'old-session', + workspaceId: 'workspace-1', + path: '/tmp/old-session.jsonl', + title: '旧会话', + createdAt: '2026-01-01T00:00:00.000Z', + modifiedAt: '2026-01-01T00:00:00.000Z', + messageCount: 1, + size: 1, + pinned: false, + archived: false, + compatibility: 'v3', + status: 'complete' + } + ], + hasMore: false, + nextOffset: 0 + } + }) + const oldHistory = [ + { + role: 'user', + content: [{ type: 'text', text: '旧会话内容' }] + } + ] + let finishOldHistory: + | (( + value: Awaited> + ) => void) + | undefined + vi.mocked(window.desktop.getMessages) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOldHistory = resolve + }) + ) + .mockResolvedValueOnce({ ok: true, data: oldHistory }) + vi.mocked(window.desktop.switchSession).mockResolvedValueOnce({ + ok: true, + data: { + status: 'ready', + workspacePath: '/tmp/workspace', + sessionId: 'old-session', + sessionName: '旧会话', + isStreaming: false, + queuedMessageCount: 0 + } + }) + render() + + const oldSessionButton = await screen.findByRole('button', { + name: '旧会话' + }) + await waitFor(() => expect(window.desktop.getMessages).toHaveBeenCalled()) + fireEvent.click(screen.getByRole('button', { name: '新建对话' })) + + expect(oldSessionButton).not.toHaveClass('bg-[var(--surface-selected)]') + expect(screen.getByText('开始处理本地项目')).toBeInTheDocument() + act(() => { + runtimeListener?.({ + type: 'snapshot', + snapshot: { + status: 'ready', + workspacePath: '/tmp/workspace', + sessionId: 'old-session', + sessionName: '旧会话', + isStreaming: false, + queuedMessageCount: 0 + } + }) + runtimeListener?.({ + type: 'omp-event-batch', + events: [ + { type: 'agent_start' }, + { + type: 'message_end', + message: { + id: 'late-assistant', + role: 'assistant', + content: [{ type: 'text', text: '迟到的实时回复' }] + } + }, + { type: 'agent_end' } + ] + }) + }) + finishOldHistory?.({ ok: true, data: oldHistory }) + + await waitFor(() => + expect(screen.queryByText('旧会话内容')).not.toBeInTheDocument() + ) + expect(screen.queryByText('迟到的实时回复')).not.toBeInTheDocument() + expect(screen.getByText('开始处理本地项目')).toBeInTheDocument() + expect(window.desktop.getMessages).toHaveBeenCalledTimes(1) + + fireEvent.click(oldSessionButton) + await waitFor(() => + expect(window.desktop.switchSession).toHaveBeenCalledWith('old-session') + ) + expect(await screen.findByText('旧会话内容')).toBeInTheDocument() + expect(window.desktop.getMessages).toHaveBeenCalledTimes(2) + }) + it('新 Session 创建请求返回前立即显示用户首条消息', async () => { vi.mocked(window.desktop.getWorkspaces).mockResolvedValueOnce({ ok: true,