fix: ACP loadSession 历史记录恢复失败 — 用 resolveSessionFilePath 替代 getProjectDir 定位 session 文件

- params.cwd 可能与 session 文件实际存储的项目目录不一致(子目录、
  hash 算法差异等),导致 getProjectDir 推算出的路径找不到文件
- 改用 resolveSessionFilePath(sessionId, cwd) 按 sessionId 跨项目
  搜索,先精确匹配再 fallback 全项目扫描
- 切换回已缓存的 session 时也回放历史消息给客户端
- createSession 内部 switchSession 保留 sessionProjectDir 不被覆盖为 null
This commit is contained in:
claude-code-best
2026-06-04 21:57:46 +08:00
parent d8892f19d5
commit 01f26cf42b
2 changed files with 84 additions and 16 deletions

View File

@@ -120,6 +120,15 @@ mockModulePreservingExports('../../../utils/listSessionsImpl.ts', {
listSessionsImpl: mock(async () => []), listSessionsImpl: mock(async () => []),
}) })
const mockResolveSessionFilePath = mock(async () => ({
filePath: '/fake/project/dir/session.jsonl',
projectPath: '/tmp',
fileSize: 100,
}))
mockModulePreservingExports('../../../utils/sessionStoragePortable.js', {
resolveSessionFilePath: mockResolveSessionFilePath,
})
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6') const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
mockModulePreservingExports('../../../utils/model/model.ts', { mockModulePreservingExports('../../../utils/model/model.ts', {
@@ -1166,7 +1175,7 @@ describe('AcpAgent', () => {
test('newSession calls switchSession with the generated sessionId', async () => { test('newSession calls switchSession with the generated sessionId', async () => {
const agent = new AcpAgent(makeConn()) const agent = new AcpAgent(makeConn())
const res = await agent.newSession({ cwd: '/tmp' } as any) const res = await agent.newSession({ cwd: '/tmp' } as any)
expect(mockSwitchSession).toHaveBeenCalledWith(res.sessionId) expect(mockSwitchSession).toHaveBeenCalledWith(res.sessionId, null)
}) })
test('resumeSession calls switchSession with the requested sessionId', async () => { test('resumeSession calls switchSession with the requested sessionId', async () => {
@@ -1178,7 +1187,10 @@ describe('AcpAgent', () => {
mcpServers: [], mcpServers: [],
} as any) } as any)
expect(mockSwitchSession).toHaveBeenCalledWith(requestedId) expect(mockSwitchSession).toHaveBeenCalledWith(
requestedId,
expect.any(String),
)
}) })
test('loadSession calls switchSession with the requested sessionId', async () => { test('loadSession calls switchSession with the requested sessionId', async () => {
@@ -1190,7 +1202,10 @@ describe('AcpAgent', () => {
mcpServers: [], mcpServers: [],
} as any) } as any)
expect(mockSwitchSession).toHaveBeenCalledWith(requestedId) expect(mockSwitchSession).toHaveBeenCalledWith(
requestedId,
expect.any(String),
)
}) })
test('resumeSession with existing session still calls switchSession', async () => { test('resumeSession with existing session still calls switchSession', async () => {
@@ -1205,7 +1220,10 @@ describe('AcpAgent', () => {
mcpServers: [], mcpServers: [],
} as any) } as any)
expect(mockSwitchSession).toHaveBeenCalledWith(sessionId) expect(mockSwitchSession).toHaveBeenCalledWith(
sessionId,
expect.any(String),
)
}) })
test('prompt does not trigger additional switchSession for multi-session', async () => { test('prompt does not trigger additional switchSession for multi-session', async () => {

View File

@@ -39,6 +39,7 @@ import type {
SessionConfigOption, SessionConfigOption,
} from '@agentclientprotocol/sdk' } from '@agentclientprotocol/sdk'
import { randomUUID, type UUID } from 'node:crypto' import { randomUUID, type UUID } from 'node:crypto'
import { dirname } from 'node:path'
import type { Message } from '../../types/message.js' import type { Message } from '../../types/message.js'
import { deserializeMessages } from '../../utils/conversationRecovery.js' import { deserializeMessages } from '../../utils/conversationRecovery.js'
import { import {
@@ -53,7 +54,11 @@ import { getEmptyToolPermissionContext } from '../../Tool.js'
import type { PermissionMode } from '../../types/permissions.js' import type { PermissionMode } from '../../types/permissions.js'
import type { Command } from '../../types/command.js' import type { Command } from '../../types/command.js'
import { getCommands } from '../../commands.js' import { getCommands } from '../../commands.js'
import { setOriginalCwd, switchSession } from '../../bootstrap/state.js' import {
setOriginalCwd,
switchSession,
getSessionProjectDir,
} from '../../bootstrap/state.js'
import type { SessionId } from '../../types/ids.js' import type { SessionId } from '../../types/ids.js'
import { enableConfigs } from '../../utils/config.js' import { enableConfigs } from '../../utils/config.js'
import { FileStateCache } from '../../utils/fileStateCache.js' import { FileStateCache } from '../../utils/fileStateCache.js'
@@ -72,6 +77,7 @@ import {
} from './utils.js' } from './utils.js'
import { promptToQueryInput } from './promptConversion.js' import { promptToQueryInput } from './promptConversion.js'
import { listSessionsImpl } from '../../utils/listSessionsImpl.js' import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
import { resolveSessionFilePath } from '../../utils/sessionStoragePortable.js'
import { getMainLoopModel } from '../../utils/model/model.js' import { getMainLoopModel } from '../../utils/model/model.js'
import { getModelOptions } from '../../utils/model/modelOptions.js' import { getModelOptions } from '../../utils/model/modelOptions.js'
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
@@ -474,7 +480,10 @@ export class AcpAgent implements Agent {
// Align the global session state so that transcript persistence, // Align the global session state so that transcript persistence,
// analytics, and cost tracking use the ACP session ID. // analytics, and cost tracking use the ACP session ID.
switchSession(sessionId as SessionId) // Preserve the projectDir set by getOrCreateSession so that
// getSessionProjectDir() continues to resolve correctly.
const currentProjectDir = getSessionProjectDir()
switchSession(sessionId as SessionId, currentProjectDir)
// Set CWD for the session // Set CWD for the session
setOriginalCwd(cwd) setOriginalCwd(cwd)
@@ -680,8 +689,18 @@ export class AcpAgent implements Agent {
| undefined, | undefined,
}) })
if (fingerprint === existingSession.sessionFingerprint) { if (fingerprint === existingSession.sessionFingerprint) {
// Align global state so subsequent operations use the correct session const resolved = await resolveSessionFilePath(
switchSession(params.sessionId as SessionId) params.sessionId,
params.cwd,
)
switchSession(
params.sessionId as SessionId,
resolved ? dirname(resolved.filePath) : null,
)
setOriginalCwd(params.cwd)
await this.replaySessionHistory(params)
return { return {
sessionId: params.sessionId, sessionId: params.sessionId,
modes: existingSession.modes, modes: existingSession.modes,
@@ -690,20 +709,20 @@ export class AcpAgent implements Agent {
} }
} }
// Session-defining params changed — tear down and recreate
await this.teardownSession(params.sessionId) await this.teardownSession(params.sessionId)
} }
// Align global state BEFORE sessionIdExists() check — the lookup uses // Locate the session file by sessionId across all project directories.
// getSessionId() internally when resolving project-scoped paths. // params.cwd may not match the project directory where the session was
switchSession(params.sessionId as SessionId) // originally created (e.g. client sends a subdirectory path), so we
// search by sessionId first and fall back to cwd-based lookup.
// Set CWD early so session file lookup can find the right project directory const resolved = await resolveSessionFilePath(params.sessionId, params.cwd)
const projectDir = resolved ? dirname(resolved.filePath) : null
switchSession(params.sessionId as SessionId, projectDir)
setOriginalCwd(params.cwd) setOriginalCwd(params.cwd)
// Try to load session history for resume/load
let initialMessages: Message[] | undefined let initialMessages: Message[] | undefined
if (sessionIdExists(params.sessionId)) { if (resolved) {
try { try {
const log = await getLastSessionLog(params.sessionId as UUID) const log = await getLastSessionLog(params.sessionId as UUID)
if (log && log.messages.length > 0) { if (log && log.messages.length > 0) {
@@ -754,6 +773,37 @@ export class AcpAgent implements Agent {
this.sessions.delete(sessionId) this.sessions.delete(sessionId)
} }
/**
* Load session history from disk and replay it to the ACP client.
* Used when switching back to a session that is already in memory
* (the client needs the conversation replayed to display it).
*/
private async replaySessionHistory(params: {
sessionId: string
cwd: string
}): Promise<void> {
try {
const log = await getLastSessionLog(params.sessionId as UUID)
if (!log || log.messages.length === 0) return
const messages = deserializeMessages(log.messages)
if (messages.length === 0) return
const session = this.sessions.get(params.sessionId)
if (!session) return
await replayHistoryMessages(
params.sessionId,
messages as unknown as Array<Record<string, unknown>>,
this.conn,
session.toolUseCache,
this.clientCapabilities,
session.cwd,
)
} catch (err) {
console.error('[ACP] Failed to replay session history:', err)
}
}
private applySessionMode(sessionId: string, modeId: string): void { private applySessionMode(sessionId: string, modeId: string): void {
if (!isPermissionMode(modeId)) { if (!isPermissionMode(modeId)) {
throw new Error(`Invalid mode: ${modeId}`) throw new Error(`Invalid mode: ${modeId}`)