mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user