Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions TODO/done/38-fix-empty-new-session-history-race.md
Original file line number Diff line number Diff line change
@@ -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`:通过。
33 changes: 27 additions & 6 deletions src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1319,6 +1331,7 @@ export function App(): React.JSX.Element {
])

useEffect(() => {
if (temporarySession) return
if (
runtime.status !== 'ready' ||
!runtime.sessionId ||
Expand Down Expand Up @@ -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<void> => {
if (workspaceRequestPending.current) return
Expand Down Expand Up @@ -1500,6 +1518,7 @@ export function App(): React.JSX.Element {
}
applySnapshot(detached.data)
setTemporarySession(true)
setOpeningSession(false)
setTemporaryApprovalMode('yolo')
setComposerInput('')
setReferences([])
Expand Down Expand Up @@ -1542,6 +1561,7 @@ export function App(): React.JSX.Element {
applySnapshot(left.data)
if (!alternative) {
setTemporarySession(true)
setOpeningSession(false)
setTemporaryApprovalMode('yolo')
setComposerInput('')
setReferences([])
Expand Down Expand Up @@ -1584,6 +1604,7 @@ export function App(): React.JSX.Element {
return
}
setTemporarySession(true)
setOpeningSession(false)
setTemporaryApprovalMode('yolo')
setComposerInput('')
setReferences([])
Expand All @@ -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}
Expand Down Expand Up @@ -1642,14 +1663,14 @@ export function App(): React.JSX.Element {
)
return undefined
}}
runtime={runtime}
runtime={visibleRuntime}
workspaceId={activeWorkspaceId}
/>
</Panel>
<Separator className="resize-handle" id="files-conversation" />
<Panel defaultSize="65%" id="conversation" minSize={480}>
<Conversation
runtime={runtime}
runtime={visibleRuntime}
onSnapshot={applySnapshot}
projection={projection}
setProjection={setProjection}
Expand Down
146 changes: 146 additions & 0 deletions tests/renderer/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,152 @@ describe('App shell', () => {
).toBeInTheDocument()
})

it('临时新会话忽略旧会话迟到的历史和 Runtime 快照,显式切回后才恢复', async () => {
let runtimeListener:
Parameters<typeof window.desktop.onRuntimeEvent>[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<ReturnType<typeof window.desktop.getMessages>>
) => 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(<App />)

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,
Expand Down