diff --git a/src/services/acp/__tests__/agent.test.ts b/src/services/acp/__tests__/agent.test.ts index 2c6417f00..3aee6c52a 100644 --- a/src/services/acp/__tests__/agent.test.ts +++ b/src/services/acp/__tests__/agent.test.ts @@ -120,6 +120,15 @@ mockModulePreservingExports('../../../utils/listSessionsImpl.ts', { 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') mockModulePreservingExports('../../../utils/model/model.ts', { @@ -1166,7 +1175,7 @@ describe('AcpAgent', () => { test('newSession calls switchSession with the generated sessionId', async () => { const agent = new AcpAgent(makeConn()) 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 () => { @@ -1178,7 +1187,10 @@ describe('AcpAgent', () => { mcpServers: [], } as any) - expect(mockSwitchSession).toHaveBeenCalledWith(requestedId) + expect(mockSwitchSession).toHaveBeenCalledWith( + requestedId, + expect.any(String), + ) }) test('loadSession calls switchSession with the requested sessionId', async () => { @@ -1190,7 +1202,10 @@ describe('AcpAgent', () => { mcpServers: [], } as any) - expect(mockSwitchSession).toHaveBeenCalledWith(requestedId) + expect(mockSwitchSession).toHaveBeenCalledWith( + requestedId, + expect.any(String), + ) }) test('resumeSession with existing session still calls switchSession', async () => { @@ -1205,7 +1220,10 @@ describe('AcpAgent', () => { mcpServers: [], } as any) - expect(mockSwitchSession).toHaveBeenCalledWith(sessionId) + expect(mockSwitchSession).toHaveBeenCalledWith( + sessionId, + expect.any(String), + ) }) test('prompt does not trigger additional switchSession for multi-session', async () => { diff --git a/src/services/acp/agent.ts b/src/services/acp/agent.ts index 4c747a6ac..ddfe4bcc1 100644 --- a/src/services/acp/agent.ts +++ b/src/services/acp/agent.ts @@ -39,6 +39,7 @@ import type { SessionConfigOption, } from '@agentclientprotocol/sdk' import { randomUUID, type UUID } from 'node:crypto' +import { dirname } from 'node:path' import type { Message } from '../../types/message.js' import { deserializeMessages } from '../../utils/conversationRecovery.js' import { @@ -53,7 +54,11 @@ import { getEmptyToolPermissionContext } from '../../Tool.js' import type { PermissionMode } from '../../types/permissions.js' import type { Command } from '../../types/command.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 { enableConfigs } from '../../utils/config.js' import { FileStateCache } from '../../utils/fileStateCache.js' @@ -72,6 +77,7 @@ import { } from './utils.js' import { promptToQueryInput } from './promptConversion.js' import { listSessionsImpl } from '../../utils/listSessionsImpl.js' +import { resolveSessionFilePath } from '../../utils/sessionStoragePortable.js' import { getMainLoopModel } from '../../utils/model/model.js' import { getModelOptions } from '../../utils/model/modelOptions.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, // 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 setOriginalCwd(cwd) @@ -680,8 +689,18 @@ export class AcpAgent implements Agent { | undefined, }) if (fingerprint === existingSession.sessionFingerprint) { - // Align global state so subsequent operations use the correct session - switchSession(params.sessionId as SessionId) + const resolved = await resolveSessionFilePath( + params.sessionId, + params.cwd, + ) + switchSession( + params.sessionId as SessionId, + resolved ? dirname(resolved.filePath) : null, + ) + setOriginalCwd(params.cwd) + + await this.replaySessionHistory(params) + return { sessionId: params.sessionId, 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) } - // Align global state BEFORE sessionIdExists() check — the lookup uses - // getSessionId() internally when resolving project-scoped paths. - switchSession(params.sessionId as SessionId) - - // Set CWD early so session file lookup can find the right project directory + // Locate the session file by sessionId across all project directories. + // params.cwd may not match the project directory where the session was + // originally created (e.g. client sends a subdirectory path), so we + // search by sessionId first and fall back to cwd-based lookup. + const resolved = await resolveSessionFilePath(params.sessionId, params.cwd) + const projectDir = resolved ? dirname(resolved.filePath) : null + switchSession(params.sessionId as SessionId, projectDir) setOriginalCwd(params.cwd) - // Try to load session history for resume/load let initialMessages: Message[] | undefined - if (sessionIdExists(params.sessionId)) { + if (resolved) { try { const log = await getLastSessionLog(params.sessionId as UUID) if (log && log.messages.length > 0) { @@ -754,6 +773,37 @@ export class AcpAgent implements Agent { 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 { + 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>, + 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 { if (!isPermissionMode(modeId)) { throw new Error(`Invalid mode: ${modeId}`)