From 2c8a22d4b33a6110b5063343d375b4c4a8ba70cf Mon Sep 17 00:00:00 2001 From: "xuzhongpeng.xzp" <1452754335@qq.com> Date: Tue, 12 May 2026 19:03:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(acp):=20=E5=AF=B9=E9=BD=90=20ACP=20session?= =?UTF-8?q?=20ID=20=E4=B8=8E=E5=85=A8=E5=B1=80=E4=BC=9A=E8=AF=9D=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 newSession/resumeSession/loadSession 中调用 switchSession, 确保 transcript 持久化、analytics 与 cost tracking 使用 ACP session ID, 而非内部默认 session ID。 - newSession 生成 sessionId 后立即对齐全局状态 - resumeSession 命中 fingerprint 缓存路径也对齐 - loadSession 在 sessionIdExists() 检查前对齐(lookup 依赖 getSessionId) - 补充 5 个测试覆盖上述路径,以及 prompt 不触发额外 switchSession --- src/services/acp/__tests__/agent.test.ts | 66 ++++++++++++++++++++++++ src/services/acp/agent.ts | 13 ++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/services/acp/__tests__/agent.test.ts b/src/services/acp/__tests__/agent.test.ts index 1635a7ea7b..2c6417f002 100644 --- a/src/services/acp/__tests__/agent.test.ts +++ b/src/services/acp/__tests__/agent.test.ts @@ -69,8 +69,11 @@ mockModulePreservingExports('../../../utils/config.ts', { enableConfigs: mock(() => {}), }) +const mockSwitchSession = mock(() => {}) + mockModulePreservingExports('../../../bootstrap/state.ts', { setOriginalCwd: mock(() => {}), + switchSession: mockSwitchSession, addSlowOperation: mock(() => {}), }) @@ -222,6 +225,7 @@ describe('AcpAgent', () => { delete process.env.ACP_PERMISSION_MODE delete process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS mockSetModel.mockClear() + mockSwitchSession.mockClear() mockSubmitMessage.mockReset() mockSubmitMessage.mockImplementation(async function* (_input: string) {}) mockGetMainLoopModel.mockClear() @@ -1157,4 +1161,66 @@ describe('AcpAgent', () => { expect(commit.input).toEqual({ hint: '[message]' }) }) }) + + describe('sessionId alignment with global state', () => { + 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) + }) + + test('resumeSession calls switchSession with the requested sessionId', async () => { + const agent = new AcpAgent(makeConn()) + const requestedId = 'resume-test-session-id' + await agent.unstable_resumeSession({ + sessionId: requestedId, + cwd: '/tmp', + mcpServers: [], + } as any) + + expect(mockSwitchSession).toHaveBeenCalledWith(requestedId) + }) + + test('loadSession calls switchSession with the requested sessionId', async () => { + const agent = new AcpAgent(makeConn()) + const requestedId = 'load-test-session-id' + await agent.loadSession({ + sessionId: requestedId, + cwd: '/tmp', + mcpServers: [], + } as any) + + expect(mockSwitchSession).toHaveBeenCalledWith(requestedId) + }) + + test('resumeSession with existing session still calls switchSession', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + mockSwitchSession.mockClear() + + // Resume the same session — should still align global state + await agent.unstable_resumeSession({ + sessionId, + cwd: '/tmp', + mcpServers: [], + } as any) + + expect(mockSwitchSession).toHaveBeenCalledWith(sessionId) + }) + + test('prompt does not trigger additional switchSession for multi-session', async () => { + const agent = new AcpAgent(makeConn()) + await agent.newSession({ cwd: '/tmp' } as any) + await agent.newSession({ cwd: '/tmp' } as any) + mockSwitchSession.mockClear() + + // Prompts should not call switchSession — alignment happens at session creation + const s1 = agent.sessions.keys().next().value + await agent.prompt({ + sessionId: s1, + prompt: [{ type: 'text', text: 'hello' }], + } as any) + expect(mockSwitchSession).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/services/acp/agent.ts b/src/services/acp/agent.ts index a824397c4b..4c747a6ac3 100644 --- a/src/services/acp/agent.ts +++ b/src/services/acp/agent.ts @@ -53,7 +53,8 @@ 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 } from '../../bootstrap/state.js' +import { setOriginalCwd, switchSession } from '../../bootstrap/state.js' +import type { SessionId } from '../../types/ids.js' import { enableConfigs } from '../../utils/config.js' import { FileStateCache } from '../../utils/fileStateCache.js' import { getDefaultAppState } from '../../state/AppStateStore.js' @@ -471,6 +472,10 @@ export class AcpAgent implements Agent { const sessionId = opts.sessionId ?? randomUUID() const cwd = params.cwd + // Align the global session state so that transcript persistence, + // analytics, and cost tracking use the ACP session ID. + switchSession(sessionId as SessionId) + // Set CWD for the session setOriginalCwd(cwd) const previousProcessCwd = process.cwd() @@ -675,6 +680,8 @@ 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) return { sessionId: params.sessionId, modes: existingSession.modes, @@ -687,6 +694,10 @@ export class AcpAgent implements Agent { 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 setOriginalCwd(params.cwd)