Skip to content

Commit 01f26cf

Browse files
fix: ACP loadSession 历史记录恢复失败 — 用 resolveSessionFilePath 替代 getProjectDir 定位 session 文件
- params.cwd 可能与 session 文件实际存储的项目目录不一致(子目录、 hash 算法差异等),导致 getProjectDir 推算出的路径找不到文件 - 改用 resolveSessionFilePath(sessionId, cwd) 按 sessionId 跨项目 搜索,先精确匹配再 fallback 全项目扫描 - 切换回已缓存的 session 时也回放历史消息给客户端 - createSession 内部 switchSession 保留 sessionProjectDir 不被覆盖为 null
1 parent d8892f1 commit 01f26cf

2 files changed

Lines changed: 84 additions & 16 deletions

File tree

src/services/acp/__tests__/agent.test.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ mockModulePreservingExports('../../../utils/listSessionsImpl.ts', {
120120
listSessionsImpl: mock(async () => []),
121121
})
122122

123+
const mockResolveSessionFilePath = mock(async () => ({
124+
filePath: '/fake/project/dir/session.jsonl',
125+
projectPath: '/tmp',
126+
fileSize: 100,
127+
}))
128+
mockModulePreservingExports('../../../utils/sessionStoragePortable.js', {
129+
resolveSessionFilePath: mockResolveSessionFilePath,
130+
})
131+
123132
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
124133

125134
mockModulePreservingExports('../../../utils/model/model.ts', {
@@ -1166,7 +1175,7 @@ describe('AcpAgent', () => {
11661175
test('newSession calls switchSession with the generated sessionId', async () => {
11671176
const agent = new AcpAgent(makeConn())
11681177
const res = await agent.newSession({ cwd: '/tmp' } as any)
1169-
expect(mockSwitchSession).toHaveBeenCalledWith(res.sessionId)
1178+
expect(mockSwitchSession).toHaveBeenCalledWith(res.sessionId, null)
11701179
})
11711180

11721181
test('resumeSession calls switchSession with the requested sessionId', async () => {
@@ -1178,7 +1187,10 @@ describe('AcpAgent', () => {
11781187
mcpServers: [],
11791188
} as any)
11801189

1181-
expect(mockSwitchSession).toHaveBeenCalledWith(requestedId)
1190+
expect(mockSwitchSession).toHaveBeenCalledWith(
1191+
requestedId,
1192+
expect.any(String),
1193+
)
11821194
})
11831195

11841196
test('loadSession calls switchSession with the requested sessionId', async () => {
@@ -1190,7 +1202,10 @@ describe('AcpAgent', () => {
11901202
mcpServers: [],
11911203
} as any)
11921204

1193-
expect(mockSwitchSession).toHaveBeenCalledWith(requestedId)
1205+
expect(mockSwitchSession).toHaveBeenCalledWith(
1206+
requestedId,
1207+
expect.any(String),
1208+
)
11941209
})
11951210

11961211
test('resumeSession with existing session still calls switchSession', async () => {
@@ -1205,7 +1220,10 @@ describe('AcpAgent', () => {
12051220
mcpServers: [],
12061221
} as any)
12071222

1208-
expect(mockSwitchSession).toHaveBeenCalledWith(sessionId)
1223+
expect(mockSwitchSession).toHaveBeenCalledWith(
1224+
sessionId,
1225+
expect.any(String),
1226+
)
12091227
})
12101228

12111229
test('prompt does not trigger additional switchSession for multi-session', async () => {

src/services/acp/agent.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type {
3939
SessionConfigOption,
4040
} from '@agentclientprotocol/sdk'
4141
import { randomUUID, type UUID } from 'node:crypto'
42+
import { dirname } from 'node:path'
4243
import type { Message } from '../../types/message.js'
4344
import { deserializeMessages } from '../../utils/conversationRecovery.js'
4445
import {
@@ -53,7 +54,11 @@ import { getEmptyToolPermissionContext } from '../../Tool.js'
5354
import type { PermissionMode } from '../../types/permissions.js'
5455
import type { Command } from '../../types/command.js'
5556
import { getCommands } from '../../commands.js'
56-
import { setOriginalCwd, switchSession } from '../../bootstrap/state.js'
57+
import {
58+
setOriginalCwd,
59+
switchSession,
60+
getSessionProjectDir,
61+
} from '../../bootstrap/state.js'
5762
import type { SessionId } from '../../types/ids.js'
5863
import { enableConfigs } from '../../utils/config.js'
5964
import { FileStateCache } from '../../utils/fileStateCache.js'
@@ -72,6 +77,7 @@ import {
7277
} from './utils.js'
7378
import { promptToQueryInput } from './promptConversion.js'
7479
import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
80+
import { resolveSessionFilePath } from '../../utils/sessionStoragePortable.js'
7581
import { getMainLoopModel } from '../../utils/model/model.js'
7682
import { getModelOptions } from '../../utils/model/modelOptions.js'
7783
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
@@ -474,7 +480,10 @@ export class AcpAgent implements Agent {
474480

475481
// Align the global session state so that transcript persistence,
476482
// analytics, and cost tracking use the ACP session ID.
477-
switchSession(sessionId as SessionId)
483+
// Preserve the projectDir set by getOrCreateSession so that
484+
// getSessionProjectDir() continues to resolve correctly.
485+
const currentProjectDir = getSessionProjectDir()
486+
switchSession(sessionId as SessionId, currentProjectDir)
478487

479488
// Set CWD for the session
480489
setOriginalCwd(cwd)
@@ -680,8 +689,18 @@ export class AcpAgent implements Agent {
680689
| undefined,
681690
})
682691
if (fingerprint === existingSession.sessionFingerprint) {
683-
// Align global state so subsequent operations use the correct session
684-
switchSession(params.sessionId as SessionId)
692+
const resolved = await resolveSessionFilePath(
693+
params.sessionId,
694+
params.cwd,
695+
)
696+
switchSession(
697+
params.sessionId as SessionId,
698+
resolved ? dirname(resolved.filePath) : null,
699+
)
700+
setOriginalCwd(params.cwd)
701+
702+
await this.replaySessionHistory(params)
703+
685704
return {
686705
sessionId: params.sessionId,
687706
modes: existingSession.modes,
@@ -690,20 +709,20 @@ export class AcpAgent implements Agent {
690709
}
691710
}
692711

693-
// Session-defining params changed — tear down and recreate
694712
await this.teardownSession(params.sessionId)
695713
}
696714

697-
// Align global state BEFORE sessionIdExists() check — the lookup uses
698-
// getSessionId() internally when resolving project-scoped paths.
699-
switchSession(params.sessionId as SessionId)
700-
701-
// Set CWD early so session file lookup can find the right project directory
715+
// Locate the session file by sessionId across all project directories.
716+
// params.cwd may not match the project directory where the session was
717+
// originally created (e.g. client sends a subdirectory path), so we
718+
// search by sessionId first and fall back to cwd-based lookup.
719+
const resolved = await resolveSessionFilePath(params.sessionId, params.cwd)
720+
const projectDir = resolved ? dirname(resolved.filePath) : null
721+
switchSession(params.sessionId as SessionId, projectDir)
702722
setOriginalCwd(params.cwd)
703723

704-
// Try to load session history for resume/load
705724
let initialMessages: Message[] | undefined
706-
if (sessionIdExists(params.sessionId)) {
725+
if (resolved) {
707726
try {
708727
const log = await getLastSessionLog(params.sessionId as UUID)
709728
if (log && log.messages.length > 0) {
@@ -754,6 +773,37 @@ export class AcpAgent implements Agent {
754773
this.sessions.delete(sessionId)
755774
}
756775

776+
/**
777+
* Load session history from disk and replay it to the ACP client.
778+
* Used when switching back to a session that is already in memory
779+
* (the client needs the conversation replayed to display it).
780+
*/
781+
private async replaySessionHistory(params: {
782+
sessionId: string
783+
cwd: string
784+
}): Promise<void> {
785+
try {
786+
const log = await getLastSessionLog(params.sessionId as UUID)
787+
if (!log || log.messages.length === 0) return
788+
const messages = deserializeMessages(log.messages)
789+
if (messages.length === 0) return
790+
791+
const session = this.sessions.get(params.sessionId)
792+
if (!session) return
793+
794+
await replayHistoryMessages(
795+
params.sessionId,
796+
messages as unknown as Array<Record<string, unknown>>,
797+
this.conn,
798+
session.toolUseCache,
799+
this.clientCapabilities,
800+
session.cwd,
801+
)
802+
} catch (err) {
803+
console.error('[ACP] Failed to replay session history:', err)
804+
}
805+
}
806+
757807
private applySessionMode(sessionId: string, modeId: string): void {
758808
if (!isPermissionMode(modeId)) {
759809
throw new Error(`Invalid mode: ${modeId}`)

0 commit comments

Comments
 (0)