refactor: 拆分 3 个过大 ACP 文件为模块化子文件(每个 <500 行)

通过 4 阶段 workflow(分析 → 计划 → 重构 → 验证)将 3 个超大的 ACP
源文件拆分为 28 个模块化子文件,每个均严格小于 500 行,且完整保留
所有公共 API(barrel 模式重导出)。

变更概要:
- packages/acp-link/src/server.ts: 1800 → 20 行(barrel),新增 11 个子模块
  (server/types、payload-decode、permission-mode、runtime-state、dispatch、
  handlers-agent、handlers-session、acp-client、client-send、start-server、
  testing-internals)
- src/services/acp/agent.ts: 1297 → 33 行(barrel),新增 9 个子模块
  (agent/AcpAgent、sessionTypes、permissionMode、configOptions、promptQueue、
  internalAccessors、createSessionMethod、sessionLifecycle、promptFlow)
- src/services/acp/bridge.ts: 1516 → 29 行(barrel),新增 8 个子模块
  (bridge/types、paths、contentBlocks、toolInfo、toolResults、modelUsage、
  notifications、forwarding)

验证:
- bun run precheck 全通过(typecheck + lint + 5851 tests)
- ACP service tests: 176 pass / 0 fail
- ACP link tests: 47 pass / 0 fail
- 所有外部消费者(entry.ts、permissions.ts、__tests__/)的 import 路径不变
- 测试文件零修改

迁移计划详见 docs/acp-refactor-plan.md。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-19 15:39:01 +08:00
parent 35768837a7
commit 65f81de52b
32 changed files with 5481 additions and 4591 deletions

View File

@@ -0,0 +1,280 @@
/**
* Session-lifecycle methods for AcpAgent (excluding createSession, which
* lives in ./createSessionMethod.ts), attached to the prototype via
* Object.assign. The barrel (./index.ts) imports this module for its side
* effect so the prototype is populated before any instance is built.
*
* Methods attached here: getOrCreateSession, teardownSession,
* replaySessionHistory, applySessionMode, updateConfigOption.
*/
import { type UUID } from 'node:crypto'
import { dirname } from 'node:path'
import * as path from 'node:path'
import type {
NewSessionRequest,
NewSessionResponse,
} from '@agentclientprotocol/sdk'
import type { Message } from '../../../types/message.js'
import { deserializeMessages } from '../../../utils/conversationRecovery.js'
import { getLastSessionLog } from '../../../utils/sessionStorage.js'
import type { PermissionMode } from '../../../types/permissions.js'
import { setOriginalCwd, switchSession } from '../../../bootstrap/state.js'
import type { SessionId } from '../../../types/ids.js'
import { replayHistoryMessages } from '../bridge.js'
import { computeSessionFingerprint } from '../utils.js'
import {
resolveSessionFilePath,
readSessionLite,
extractJsonStringField,
} from '../../../utils/sessionStoragePortable.js'
import { AcpAgent } from './AcpAgent.js'
import type { AcpSession } from './sessionTypes.js'
import { isPermissionMode } from './permissionMode.js'
import {
getConnection,
readClientCapabilities,
syncSessionConfigState,
} from './internalAccessors.js'
// ── getOrCreateSession ───────────────────────────────────────────
async function getOrCreateSession(
this: AcpAgent,
params: {
sessionId: string
cwd: string
mcpServers?: NewSessionRequest['mcpServers']
_meta?: NewSessionRequest['_meta']
// replay:true (default, session/load) streams the conversation history back
// to the client via session/update. replay:false (session/resume) only
// restores the in-process context — per session-setup.mdx the Agent MUST
// NOT replay history when resuming.
replay?: boolean
},
): Promise<NewSessionResponse> {
const shouldReplay = params.replay !== false
const existingSession = this.sessions.get(params.sessionId)
if (existingSession) {
const fingerprint = computeSessionFingerprint({
cwd: params.cwd,
mcpServers: params.mcpServers as
| Array<{ name: string; [key: string]: unknown }>
| undefined,
})
if (fingerprint === existingSession.sessionFingerprint) {
const resolved = await resolveSessionFilePath(
params.sessionId,
params.cwd,
)
switchSession(
params.sessionId as SessionId,
resolved ? dirname(resolved.filePath) : null,
)
setOriginalCwd(params.cwd)
if (shouldReplay) {
await this.replaySessionHistory(params)
}
return {
sessionId: params.sessionId,
modes: existingSession.modes,
configOptions: existingSession.configOptions,
}
}
await this.teardownSession(params.sessionId)
}
// 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
// Per session-setup.mdx "Working Directory": the cwd MUST be the absolute
// path used for the session regardless of where the Agent was spawned.
// Reject cross-project loads where the persisted session's original cwd
// does not match the requested cwd, otherwise the client could load a
// session belonging to project B while passing project A's cwd.
if (resolved) {
const lite = await readSessionLite(resolved.filePath)
const originalCwd = lite && extractJsonStringField(lite.head, 'cwd')
if (originalCwd && path.resolve(originalCwd) !== path.resolve(params.cwd)) {
throw new Error(
`Session cwd mismatch: session belongs to ${originalCwd}, requested ${params.cwd}`,
)
}
}
switchSession(params.sessionId as SessionId, projectDir)
setOriginalCwd(params.cwd)
let initialMessages: Message[] | undefined
if (resolved) {
try {
const log = await getLastSessionLog(params.sessionId as UUID)
if (log && log.messages.length > 0) {
initialMessages = deserializeMessages(log.messages)
}
} catch (err) {
console.error('[ACP] Failed to load session history:', err)
}
}
const response = await this.createSession(
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
},
{ sessionId: params.sessionId, initialMessages },
)
// Replay history to client if loaded. session/resume skips this block.
if (shouldReplay && initialMessages && initialMessages.length > 0) {
const session = this.sessions.get(params.sessionId)
if (session) {
await replayHistoryMessages(
params.sessionId,
initialMessages as unknown as Array<Record<string, unknown>>,
getConnection(this),
session.toolUseCache,
readClientCapabilities(this),
session.cwd,
)
}
}
return {
sessionId: response.sessionId,
modes: response.modes,
configOptions: response.configOptions,
}
}
// ── teardownSession ──────────────────────────────────────────────
async function teardownSession(
this: AcpAgent,
sessionId: string,
): Promise<void> {
const session = this.sessions.get(sessionId)
if (!session) return
await this.cancel({ sessionId })
this.sessions.delete(sessionId)
}
// ── replaySessionHistory ─────────────────────────────────────────
/**
* 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).
*/
async function replaySessionHistory(
this: AcpAgent,
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>>,
getConnection(this),
session.toolUseCache,
readClientCapabilities(this),
session.cwd,
)
} catch (err) {
console.error('[ACP] Failed to replay session history:', err)
}
}
// ── applySessionMode ─────────────────────────────────────────────
function applySessionMode(
this: AcpAgent,
sessionId: string,
modeId: string,
): void {
if (!isPermissionMode(modeId)) {
throw new Error(`Invalid mode: ${modeId}`)
}
const session = this.sessions.get(sessionId)
if (session) {
if (
modeId === 'bypassPermissions' &&
!session.appState.toolPermissionContext.isBypassPermissionsModeAvailable
) {
throw new Error(`Mode not available: ${modeId}`)
}
const isAvailable = session.modes.availableModes.some(
mode => mode.id === modeId,
)
if (!isAvailable) {
throw new Error(`Mode not available: ${modeId}`)
}
session.modes = { ...session.modes, currentModeId: modeId }
// Sync mode to appState so the permission pipeline sees the correct mode
session.appState.toolPermissionContext = {
...session.appState.toolPermissionContext,
mode: modeId as PermissionMode,
}
}
}
// ── updateConfigOption ───────────────────────────────────────────
async function updateConfigOption(
this: AcpAgent,
sessionId: string,
configId: string,
value: string,
): Promise<void> {
const session = this.sessions.get(sessionId)
if (!session) return
// Delegate to the shell's private syncSessionConfigState via a typed cast.
// The shell declares syncSessionConfigState as a private method; it is not
// part of the merged public interface, so we access it through the shared
// internal accessor to preserve exact original behavior.
syncSessionConfigState(this, session, configId, value)
session.configOptions = session.configOptions.map(o =>
o.id === configId && typeof o.currentValue === 'string'
? { ...o, currentValue: value }
: o,
)
await getConnection(this).sessionUpdate({
sessionId,
update: {
sessionUpdate: 'config_option_update',
configOptions: session.configOptions,
},
})
}
// ── Prototype attachment ─────────────────────────────────────────
Object.assign(AcpAgent.prototype, {
getOrCreateSession,
teardownSession,
replaySessionHistory,
applySessionMode,
updateConfigOption,
})