mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-20 15:25:50 +00:00
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:
File diff suppressed because it is too large
Load Diff
404
src/services/acp/agent/AcpAgent.ts
Normal file
404
src/services/acp/agent/AcpAgent.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* ACP Agent implementation — bridges ACP protocol methods to Claude Code's
|
||||
* internal QueryEngine / query() pipeline.
|
||||
*
|
||||
* Architecture: Uses internal QueryEngine (not @anthropic-ai/claude-agent-sdk)
|
||||
* to directly run queries, with a bridge layer converting SDKMessage → ACP SessionUpdate.
|
||||
*
|
||||
* NOTE: The AcpAgent class is split across three modules for line-budget reasons.
|
||||
* The class shell + lightweight protocol handlers live here; the heavy
|
||||
* session-lifecycle methods (createSession / getOrCreateSession /
|
||||
* replaySessionHistory / teardownSession / applySessionMode / updateConfigOption)
|
||||
* are attached to the prototype in `./sessionLifecycle.js`, and the prompt
|
||||
* flow (prompt / setSessionConfigOption) in `./promptFlow.js`. The barrel
|
||||
* `./index.js` imports those side-effect modules so the prototype is fully
|
||||
* populated before any AcpAgent instance is constructed.
|
||||
*/
|
||||
import type {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
AuthenticateRequest,
|
||||
AuthenticateResponse,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
CancelNotification,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
ListSessionsRequest,
|
||||
ListSessionsResponse,
|
||||
ResumeSessionRequest,
|
||||
ResumeSessionResponse,
|
||||
ForkSessionRequest,
|
||||
ForkSessionResponse,
|
||||
CloseSessionRequest,
|
||||
CloseSessionResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
ClientCapabilities,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { Message } from '../../../types/message.js'
|
||||
import { sanitizeTitle } from '../utils.js'
|
||||
import { listSessionsImpl } from '../../../utils/listSessionsImpl.js'
|
||||
import type { AcpSession } from './sessionTypes.js'
|
||||
|
||||
// ── Agent class ───────────────────────────────────────────────────
|
||||
//
|
||||
// NOTE: This class is intentionally merged with the `AcpAgent` interface
|
||||
// declared at the bottom of this file. The merged interface declares methods
|
||||
// that are attached to AcpAgent.prototype at module load time by the sibling
|
||||
// side-effect modules (createSessionMethod.ts / sessionLifecycle.ts /
|
||||
// promptFlow.ts) imported by the barrel (./agent.ts). This is the standard
|
||||
// prototype-augmentation pattern and is safe because the barrel guarantees
|
||||
// the side-effect imports run before any instance is constructed.
|
||||
// biome-ignore lint/suspicious/noUnsafeDeclarationMerging: prototype-augmentation pattern — merged interface methods are attached to AcpAgent.prototype by sibling side-effect modules imported by the barrel (./agent.ts) before any instance is constructed.
|
||||
export class AcpAgent implements Agent {
|
||||
private conn: AgentSideConnection
|
||||
sessions = new Map<string, AcpSession>()
|
||||
private clientCapabilities?: ClientCapabilities
|
||||
|
||||
constructor(conn: AgentSideConnection) {
|
||||
this.conn = conn
|
||||
}
|
||||
|
||||
// ── initialize ────────────────────────────────────────────────
|
||||
|
||||
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
|
||||
this.clientCapabilities = params.clientCapabilities
|
||||
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
// Explicit empty authMethods signals "no authentication required" to
|
||||
// Clients rather than "capability unknown". Matches authenticate() no-op.
|
||||
authMethods: [],
|
||||
agentInfo: {
|
||||
name: 'claude-code',
|
||||
title: 'Claude Code',
|
||||
version:
|
||||
typeof (globalThis as unknown as Record<string, unknown>).MACRO ===
|
||||
'object' &&
|
||||
(globalThis as unknown as Record<string, Record<string, unknown>>)
|
||||
.MACRO !== null
|
||||
? String(
|
||||
(
|
||||
(
|
||||
globalThis as unknown as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>
|
||||
).MACRO as Record<string, unknown>
|
||||
).VERSION ?? '0.0.0',
|
||||
)
|
||||
: '0.0.0',
|
||||
},
|
||||
agentCapabilities: {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
promptQueueing: true,
|
||||
// session/fork is UNSTABLE — not part of stable v1 SessionCapabilities.
|
||||
// Advertise via _meta namespace per extensibility.mdx "Advertising
|
||||
// Custom Capabilities" instead of the standard sessionCapabilities map.
|
||||
forkSession: true,
|
||||
},
|
||||
},
|
||||
// image:false — promptToQueryInput() does not parse ContentBlock::Image
|
||||
// blocks yet. Re-enable only after multimodal query input support lands.
|
||||
promptCapabilities: {
|
||||
image: false,
|
||||
embeddedContext: true,
|
||||
},
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
loadSession: true,
|
||||
sessionCapabilities: {
|
||||
list: {},
|
||||
resume: {},
|
||||
close: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── authenticate ──────────────────────────────────────────────
|
||||
|
||||
async authenticate(
|
||||
_params: AuthenticateRequest,
|
||||
): Promise<AuthenticateResponse> {
|
||||
// No authentication required — this is a self-hosted/custom deployment
|
||||
return {}
|
||||
}
|
||||
|
||||
// ── newSession ────────────────────────────────────────────────
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
const result = await this.createSession(params)
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
return result
|
||||
}
|
||||
|
||||
// ── resumeSession ──────────────────────────────────────────────
|
||||
|
||||
async unstable_resumeSession(
|
||||
params: ResumeSessionRequest,
|
||||
): Promise<ResumeSessionResponse> {
|
||||
// Per session-setup.mdx "Resuming a Session": the Agent MUST NOT replay the
|
||||
// conversation history via session/update notifications before responding.
|
||||
// Only restore context + MCP connections, then return immediately. This
|
||||
// differs from session/load which DOES replay history.
|
||||
const result = await this.getOrCreateSession({ ...params, replay: false })
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
return result
|
||||
}
|
||||
|
||||
// ── loadSession ────────────────────────────────────────────────
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params)
|
||||
this.scheduleAvailableCommandsUpdate(result.sessionId)
|
||||
return result
|
||||
}
|
||||
|
||||
// ── listSessions ───────────────────────────────────────────────
|
||||
|
||||
async listSessions(
|
||||
params: ListSessionsRequest,
|
||||
): Promise<ListSessionsResponse> {
|
||||
// Pagination is not implemented: we always return all available sessions
|
||||
// for the requested cwd (no nextCursor). Per session-list.mdx the Agent
|
||||
// SHOULD return an error if the cursor is invalid, so explicitly reject
|
||||
// any client-supplied cursor rather than silently accepting it.
|
||||
if (params.cursor !== undefined && params.cursor !== null) {
|
||||
throw new Error(
|
||||
'Pagination cursor not supported: listSessions returns all results in a single page.',
|
||||
)
|
||||
}
|
||||
|
||||
const candidates = await listSessionsImpl({
|
||||
dir: params.cwd ?? undefined,
|
||||
})
|
||||
|
||||
const sessions = []
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.cwd) continue
|
||||
// Only include title when non-empty; schema allows null/omitted title.
|
||||
const title = sanitizeTitle(candidate.summary ?? '')
|
||||
sessions.push({
|
||||
sessionId: candidate.sessionId,
|
||||
cwd: candidate.cwd,
|
||||
...(title ? { title } : {}),
|
||||
updatedAt: new Date(candidate.lastModified).toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return { sessions }
|
||||
}
|
||||
|
||||
// ── forkSession ────────────────────────────────────────────────
|
||||
|
||||
async unstable_forkSession(
|
||||
params: ForkSessionRequest,
|
||||
): Promise<ForkSessionResponse> {
|
||||
// Load the source session's messages so the fork actually branches from
|
||||
// the source conversation rather than starting a blank session. Per the
|
||||
// unstable ForkSessionRequest, params.sessionId is the ID to fork from.
|
||||
const { initialMessages } = await loadForkSourceMessages(params.sessionId)
|
||||
const response = await this.createSession(
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
_meta: params._meta,
|
||||
},
|
||||
{ initialMessages },
|
||||
)
|
||||
this.scheduleAvailableCommandsUpdate(response.sessionId)
|
||||
return response
|
||||
}
|
||||
|
||||
// ── closeSession ───────────────────────────────────────────────
|
||||
|
||||
async unstable_closeSession(
|
||||
params: CloseSessionRequest,
|
||||
): Promise<CloseSessionResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
await this.teardownSession(params.sessionId)
|
||||
return {}
|
||||
}
|
||||
|
||||
// ── cancel ────────────────────────────────────────────────────
|
||||
|
||||
async cancel(params: CancelNotification): Promise<void> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) return
|
||||
|
||||
// Set cancelled flag — checked by prompt() loop to break out
|
||||
session.cancelled = true
|
||||
session.cancelGeneration += 1
|
||||
|
||||
// Cancel any queued prompts
|
||||
for (const [, pending] of session.pendingMessages) {
|
||||
pending.resolve(true)
|
||||
}
|
||||
session.pendingMessages.clear()
|
||||
session.pendingQueue = []
|
||||
session.pendingQueueHead = 0
|
||||
|
||||
// Interrupt the query engine to abort the current API call
|
||||
session.queryEngine.interrupt()
|
||||
}
|
||||
|
||||
// ── setSessionMode ──────────────────────────────────────────────
|
||||
|
||||
async setSessionMode(
|
||||
params: SetSessionModeRequest,
|
||||
): Promise<SetSessionModeResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
|
||||
this.applySessionMode(params.sessionId, params.modeId)
|
||||
// Per session-modes.mdx: when the Agent changes its own mode it MUST send
|
||||
// a current_mode_update notification so mode-only Clients learn the
|
||||
// switch. Mirrors the current_mode_update sent by setSessionConfigOption
|
||||
// when configId === 'mode'.
|
||||
await this.conn.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'current_mode_update',
|
||||
currentModeId: params.modeId,
|
||||
},
|
||||
})
|
||||
await this.updateConfigOption(params.sessionId, 'mode', params.modeId)
|
||||
return {}
|
||||
}
|
||||
|
||||
// ── setSessionModel ─────────────────────────────────────────────
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
// Store the raw value — QueryEngine.submitMessage() calls
|
||||
// parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo")
|
||||
session.queryEngine.setModel(params.modelId)
|
||||
await this.updateConfigOption(params.sessionId, 'model', params.modelId)
|
||||
return {}
|
||||
}
|
||||
|
||||
// ── Private helpers (lightweight, kept with the class) ──────────
|
||||
|
||||
private async sendAvailableCommandsUpdate(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
const availableCommands = session.commands
|
||||
.filter(
|
||||
cmd =>
|
||||
cmd.type === 'prompt' && !cmd.isHidden && cmd.userInvocable !== false,
|
||||
)
|
||||
.map(cmd => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
input: cmd.argumentHint ? { hint: cmd.argumentHint } : undefined,
|
||||
}))
|
||||
|
||||
await this.conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private scheduleAvailableCommandsUpdate(sessionId: string): void {
|
||||
setTimeout(() => {
|
||||
void this.sendAvailableCommandsUpdate(sessionId).catch(err => {
|
||||
console.error('[ACP] Failed to send available commands update:', err)
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prototype-attached methods (declared here for type safety) ────
|
||||
//
|
||||
// The following methods are implemented in sibling modules
|
||||
// (createSessionMethod.ts / sessionLifecycle.ts / promptFlow.ts) and attached
|
||||
// to AcpAgent.prototype via Object.assign at module load time. They are
|
||||
// declared on the class via TypeScript declaration merging so `this` is
|
||||
// typed correctly in the prototype-augmentation modules.
|
||||
export interface AcpAgent {
|
||||
// ── prompt flow (promptFlow.ts) ───────────────────────────────
|
||||
prompt(params: PromptRequest): Promise<PromptResponse>
|
||||
setSessionConfigOption(
|
||||
params: SetSessionConfigOptionRequest,
|
||||
): Promise<SetSessionConfigOptionResponse>
|
||||
|
||||
// ── session lifecycle (sessionLifecycle.ts) ───────────────────
|
||||
createSession(
|
||||
params: NewSessionRequest,
|
||||
opts?: {
|
||||
forceNewId?: boolean
|
||||
sessionId?: string
|
||||
initialMessages?: Message[]
|
||||
},
|
||||
): Promise<NewSessionResponse>
|
||||
getOrCreateSession(params: {
|
||||
sessionId: string
|
||||
cwd: string
|
||||
mcpServers?: NewSessionRequest['mcpServers']
|
||||
_meta?: NewSessionRequest['_meta']
|
||||
replay?: boolean
|
||||
}): Promise<NewSessionResponse>
|
||||
teardownSession(sessionId: string): Promise<void>
|
||||
replaySessionHistory(params: {
|
||||
sessionId: string
|
||||
cwd: string
|
||||
}): Promise<void>
|
||||
applySessionMode(sessionId: string, modeId: string): void
|
||||
updateConfigOption(
|
||||
sessionId: string,
|
||||
configId: string,
|
||||
value: string,
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
// ── Module-local helpers used only by the class shell ────────────
|
||||
|
||||
import { type UUID } from 'node:crypto'
|
||||
import { deserializeMessages } from '../../../utils/conversationRecovery.js'
|
||||
import { getLastSessionLog } from '../../../utils/sessionStorage.js'
|
||||
|
||||
/**
|
||||
* Load the source session's persisted messages for forkSession.
|
||||
* Extracted as a module-local helper to keep the fork handler compact.
|
||||
*/
|
||||
async function loadForkSourceMessages(
|
||||
sessionId: string,
|
||||
): Promise<{ initialMessages: Message[] | undefined }> {
|
||||
let initialMessages: Message[] | undefined
|
||||
try {
|
||||
const log = await getLastSessionLog(sessionId as UUID)
|
||||
if (log && log.messages.length > 0) {
|
||||
initialMessages = deserializeMessages(log.messages)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ACP] fork source load failed:', err)
|
||||
}
|
||||
return { initialMessages }
|
||||
}
|
||||
74
src/services/acp/agent/configOptions.ts
Normal file
74
src/services/acp/agent/configOptions.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
SessionConfigOption,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
|
||||
export function buildConfigOptions(
|
||||
modes: SessionModeState,
|
||||
models: SessionModelState,
|
||||
): SessionConfigOption[] {
|
||||
return [
|
||||
{
|
||||
id: 'mode',
|
||||
name: 'Mode',
|
||||
description: 'Session permission mode',
|
||||
category: 'mode',
|
||||
type: 'select' as const,
|
||||
currentValue: modes.currentModeId,
|
||||
options: modes.availableModes.map(
|
||||
(m: SessionModeState['availableModes'][number]) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'AI model to use',
|
||||
category: 'model',
|
||||
type: 'select' as const,
|
||||
currentValue: models.currentModelId,
|
||||
options: models.availableModels.map(
|
||||
(m: SessionModelState['availableModels'][number]) => ({
|
||||
value: m.modelId,
|
||||
name: m.name,
|
||||
description: m.description ?? undefined,
|
||||
}),
|
||||
),
|
||||
},
|
||||
] as SessionConfigOption[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a SessionConfigOption's `options` (which may be flat
|
||||
* SessionConfigSelectOption entries or grouped SessionConfigSelectGroup
|
||||
* entries) into a list of valid value strings. Used to validate that a
|
||||
* setSessionConfigOption value is one of the listed options.
|
||||
*/
|
||||
export function flattenConfigOptionValues(options: unknown): string[] {
|
||||
const values: string[] = []
|
||||
if (!Array.isArray(options)) return values
|
||||
for (const opt of options) {
|
||||
if (typeof opt !== 'object' || opt === null) continue
|
||||
const maybeGroup = opt as { group?: unknown; options?: unknown[] }
|
||||
if (Array.isArray(maybeGroup.options)) {
|
||||
// SessionConfigSelectGroup — recurse into its options
|
||||
for (const inner of maybeGroup.options) {
|
||||
if (
|
||||
inner &&
|
||||
typeof inner === 'object' &&
|
||||
typeof (inner as { value?: unknown }).value === 'string'
|
||||
) {
|
||||
values.push((inner as { value: string }).value)
|
||||
}
|
||||
}
|
||||
} else if (typeof (opt as { value?: unknown }).value === 'string') {
|
||||
// SessionConfigSelectOption
|
||||
values.push((opt as { value: string }).value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
291
src/services/acp/agent/createSessionMethod.ts
Normal file
291
src/services/acp/agent/createSessionMethod.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* AcpAgent.prototype.createSession implementation, attached via Object.assign.
|
||||
* Extracted from sessionLifecycle.ts to keep that module under the 500-line
|
||||
* budget. The barrel (./index.ts) imports this module for its side effect.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { Message } from '../../../types/message.js'
|
||||
import { QueryEngine } from '../../../QueryEngine.js'
|
||||
import type { QueryEngineConfig } from '../../../QueryEngine.js'
|
||||
import type { Tools } from '../../../Tool.js'
|
||||
import { getTools } from '../../../tools.js'
|
||||
import { getEmptyToolPermissionContext } from '../../../Tool.js'
|
||||
import type { PermissionMode } from '../../../types/permissions.js'
|
||||
import { getCommands } from '../../../commands.js'
|
||||
import { getAgentDefinitionsWithOverrides } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.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'
|
||||
import { getDefaultAppState } from '../../../state/AppStateStore.js'
|
||||
import type { AppState } from '../../../state/AppStateStore.js'
|
||||
import { createAcpCanUseTool } from '../permissions.js'
|
||||
import { computeSessionFingerprint } from '../utils.js'
|
||||
import { getMainLoopModel } from '../../../utils/model/model.js'
|
||||
import { getModelOptions } from '../../../utils/model/modelOptions.js'
|
||||
import { getSettings_DEPRECATED } from '../../../utils/settings/settings.js'
|
||||
import { AcpAgent } from './AcpAgent.js'
|
||||
import type { AcpSession } from './sessionTypes.js'
|
||||
import {
|
||||
resolveSessionPermissionMode,
|
||||
isAcpBypassPermissionModeAvailable,
|
||||
hasOwnField,
|
||||
} from './permissionMode.js'
|
||||
import { buildConfigOptions } from './configOptions.js'
|
||||
import { readClientCapabilities } from './internalAccessors.js'
|
||||
|
||||
/**
|
||||
* Resolve the effective `permissions.defaultMode` setting by walking the
|
||||
* settings object. Lives here so createSession can read it without depending
|
||||
* on AcpAgent.getSetting (which is a private instance method on the shell).
|
||||
*/
|
||||
function readSettingsPermissionMode(): unknown {
|
||||
const settings = getSettings_DEPRECATED() as Record<string, unknown>
|
||||
const perms = settings.permissions as Record<string, unknown> | undefined
|
||||
return perms?.defaultMode
|
||||
}
|
||||
|
||||
// ── createSession ────────────────────────────────────────────────
|
||||
|
||||
async function createSession(
|
||||
this: AcpAgent,
|
||||
params: NewSessionRequest,
|
||||
opts: {
|
||||
forceNewId?: boolean
|
||||
sessionId?: string
|
||||
initialMessages?: Message[]
|
||||
} = {},
|
||||
): Promise<NewSessionResponse> {
|
||||
enableConfigs()
|
||||
|
||||
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.
|
||||
// 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)
|
||||
const previousProcessCwd = process.cwd()
|
||||
let processCwdChanged = false
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
processCwdChanged = true
|
||||
} catch {
|
||||
// CWD may not exist yet; best-effort
|
||||
}
|
||||
|
||||
try {
|
||||
// Build tools with a permissive permission context.
|
||||
const permissionContext = getEmptyToolPermissionContext()
|
||||
const tools: Tools = getTools(permissionContext)
|
||||
|
||||
// Parse permission mode from _meta (passed by RCS/acp-link) or settings.
|
||||
const meta = params._meta as Record<string, unknown> | null | undefined
|
||||
const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode')
|
||||
const metaPermissionMode = hasMetaPermissionMode
|
||||
? meta?.permissionMode
|
||||
: undefined
|
||||
const settingsPermissionMode = readSettingsPermissionMode()
|
||||
const permissionMode = resolveSessionPermissionMode(
|
||||
metaPermissionMode,
|
||||
hasMetaPermissionMode,
|
||||
settingsPermissionMode,
|
||||
)
|
||||
|
||||
// The clientCapabilities field on the shell is private; access it via
|
||||
// the public initialize() side effect. Since createSession is only ever
|
||||
// called after initialize() has run (per ACP protocol), this accessor
|
||||
// is safe.
|
||||
const clientCapabilities = readClientCapabilities(this)
|
||||
|
||||
// Create the permission bridge canUseTool function. The connection field
|
||||
// is private on the shell; access it through the internal accessor.
|
||||
const conn = (
|
||||
this as unknown as {
|
||||
conn: import('@agentclientprotocol/sdk').AgentSideConnection
|
||||
}
|
||||
).conn
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
conn,
|
||||
sessionId,
|
||||
() => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default',
|
||||
clientCapabilities,
|
||||
cwd,
|
||||
(modeId: string) => {
|
||||
this.applySessionMode(sessionId, modeId)
|
||||
},
|
||||
() =>
|
||||
this.sessions.get(sessionId)?.appState.toolPermissionContext
|
||||
.isBypassPermissionsModeAvailable ?? false,
|
||||
)
|
||||
|
||||
// Parse MCP servers from ACP params
|
||||
// MCP server config is handled separately in the tools system
|
||||
|
||||
// ACP clients can expose bypass only when both the process and local config allow it.
|
||||
const isBypassAvailable = isAcpBypassPermissionModeAvailable(
|
||||
settingsPermissionMode,
|
||||
)
|
||||
|
||||
// Create a mutable AppState for the session
|
||||
const appState: AppState = {
|
||||
...getDefaultAppState(),
|
||||
toolPermissionContext: {
|
||||
...permissionContext,
|
||||
mode: permissionMode as PermissionMode,
|
||||
isBypassPermissionsModeAvailable: isBypassAvailable,
|
||||
},
|
||||
}
|
||||
|
||||
// Load commands and agent definitions for subagent support
|
||||
const [commands, agentDefinitionsResult] = await Promise.all([
|
||||
getCommands(cwd),
|
||||
getAgentDefinitionsWithOverrides(cwd),
|
||||
])
|
||||
|
||||
// Inject agent definitions into appState
|
||||
appState.agentDefinitions = agentDefinitionsResult
|
||||
|
||||
// Build QueryEngine config
|
||||
const engineConfig: QueryEngineConfig = {
|
||||
cwd,
|
||||
tools,
|
||||
commands,
|
||||
mcpClients: [],
|
||||
agents: agentDefinitionsResult.activeAgents,
|
||||
canUseTool,
|
||||
getAppState: () => appState,
|
||||
setAppState: (updater: (prev: AppState) => AppState) => {
|
||||
const updated = updater(appState)
|
||||
Object.assign(appState, updated)
|
||||
},
|
||||
readFileCache: new FileStateCache(500, 50 * 1024 * 1024),
|
||||
includePartialMessages: true,
|
||||
replayUserMessages: true,
|
||||
initialMessages: opts.initialMessages,
|
||||
}
|
||||
|
||||
const queryEngine = new QueryEngine(engineConfig)
|
||||
|
||||
// Build modes — bypassPermissions is opt-in for ACP clients.
|
||||
const availableModes = [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
description: 'Standard behavior, prompts for dangerous operations',
|
||||
},
|
||||
{
|
||||
id: 'acceptEdits',
|
||||
name: 'Accept Edits',
|
||||
description: 'Auto-accept file edit operations',
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
name: 'Plan Mode',
|
||||
description: 'Planning mode, no actual tool execution',
|
||||
},
|
||||
{
|
||||
id: 'auto',
|
||||
name: 'Auto',
|
||||
description:
|
||||
'Use a model classifier to approve/deny permission prompts.',
|
||||
},
|
||||
...(isBypassAvailable
|
||||
? [
|
||||
{
|
||||
id: 'bypassPermissions' as const,
|
||||
name: 'Bypass Permissions',
|
||||
description: 'Skip all permission checks',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'dontAsk',
|
||||
name: "Don't Ask",
|
||||
description: "Don't prompt for permissions, deny if not pre-approved",
|
||||
},
|
||||
]
|
||||
|
||||
const modes: SessionModeState = {
|
||||
currentModeId: permissionMode,
|
||||
availableModes,
|
||||
}
|
||||
|
||||
// Build models
|
||||
const modelOptions = getModelOptions()
|
||||
const currentModel = getMainLoopModel()
|
||||
const models: SessionModelState = {
|
||||
availableModels: modelOptions.map(m => ({
|
||||
modelId: String(m.value ?? ''),
|
||||
name: m.label ?? String(m.value ?? ''),
|
||||
description: m.description ?? undefined,
|
||||
})),
|
||||
currentModelId: currentModel,
|
||||
}
|
||||
|
||||
// Set the model on the engine
|
||||
queryEngine.setModel(currentModel)
|
||||
|
||||
// Build config options
|
||||
const configOptions = buildConfigOptions(modes, models)
|
||||
|
||||
const session: AcpSession = {
|
||||
queryEngine,
|
||||
cancelled: false,
|
||||
cancelGeneration: 0,
|
||||
cwd,
|
||||
modes,
|
||||
models,
|
||||
configOptions,
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
pendingQueue: [],
|
||||
pendingQueueHead: 0,
|
||||
toolUseCache: {},
|
||||
clientCapabilities,
|
||||
appState,
|
||||
commands,
|
||||
sessionFingerprint: computeSessionFingerprint({
|
||||
cwd,
|
||||
mcpServers: params.mcpServers as
|
||||
| Array<{ name: string; [key: string]: unknown }>
|
||||
| undefined,
|
||||
}),
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, session)
|
||||
|
||||
// Stable v1 NewSessionResponse only defines sessionId/modes/configOptions.
|
||||
// `models` is a draft/unstable field — omit it for v1 compliance.
|
||||
return {
|
||||
sessionId,
|
||||
modes,
|
||||
configOptions,
|
||||
}
|
||||
} finally {
|
||||
if (processCwdChanged) {
|
||||
process.chdir(previousProcessCwd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prototype attachment ─────────────────────────────────────────
|
||||
|
||||
Object.assign(AcpAgent.prototype, {
|
||||
createSession,
|
||||
})
|
||||
54
src/services/acp/agent/internalAccessors.ts
Normal file
54
src/services/acp/agent/internalAccessors.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Internal accessors for AcpAgent private fields and session-state helpers,
|
||||
* shared across the prototype-augmentation modules (createSessionMethod /
|
||||
* sessionLifecycle / promptFlow).
|
||||
*
|
||||
* AcpAgent's `conn` and `clientCapabilities` fields are declared `private`
|
||||
* on the shell class. TS-only privacy (no #) means bracket access still
|
||||
* works at runtime, but we cast through `unknown` to keep tsc strict happy
|
||||
* without widening the public API surface of the class.
|
||||
*/
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
ClientCapabilities,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { AcpAgent } from './AcpAgent.js'
|
||||
import type { AcpSession } from './sessionTypes.js'
|
||||
|
||||
type AcpAgentInternals = {
|
||||
conn: AgentSideConnection
|
||||
clientCapabilities: ClientCapabilities | undefined
|
||||
}
|
||||
|
||||
export function getConnection(agent: AcpAgent): AgentSideConnection {
|
||||
return (agent as unknown as AcpAgentInternals).conn
|
||||
}
|
||||
|
||||
export function readClientCapabilities(
|
||||
agent: AcpAgent,
|
||||
): ClientCapabilities | undefined {
|
||||
return (agent as unknown as AcpAgentInternals).clientCapabilities
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the session's current mode/model id based on the configId.
|
||||
*
|
||||
* This logic was originally the private `AcpAgent.syncSessionConfigState`
|
||||
* method on the shell class. It is called by the prototype-augmented
|
||||
* `updateConfigOption` (sessionLifecycle.ts) and `setSessionConfigOption`
|
||||
* (promptFlow.ts). Moving it here keeps it next to its only callers and
|
||||
* avoids the `noUnusedPrivateClassMembers` false positive that the
|
||||
* cast-based access would otherwise trigger on the shell.
|
||||
*/
|
||||
export function syncSessionConfigState(
|
||||
_agent: AcpAgent,
|
||||
session: AcpSession,
|
||||
configId: string,
|
||||
value: string,
|
||||
): void {
|
||||
if (configId === 'mode') {
|
||||
session.modes = { ...session.modes, currentModeId: value }
|
||||
} else if (configId === 'model') {
|
||||
session.models = { ...session.models, currentModelId: value }
|
||||
}
|
||||
}
|
||||
115
src/services/acp/agent/permissionMode.ts
Normal file
115
src/services/acp/agent/permissionMode.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { PermissionMode } from '../../../types/permissions.js'
|
||||
import { resolvePermissionMode } from '../utils.js'
|
||||
|
||||
export const permissionModeIds: readonly PermissionMode[] = [
|
||||
'auto',
|
||||
'default',
|
||||
'acceptEdits',
|
||||
'bypassPermissions',
|
||||
'dontAsk',
|
||||
'plan',
|
||||
]
|
||||
|
||||
export function isPermissionMode(modeId: string): modeId is PermissionMode {
|
||||
return (permissionModeIds as readonly string[]).includes(modeId)
|
||||
}
|
||||
|
||||
export function resolveSessionPermissionMode(
|
||||
metaMode: unknown,
|
||||
hasMetaMode: boolean,
|
||||
settingsMode: unknown,
|
||||
): PermissionMode {
|
||||
if (hasMetaMode) {
|
||||
const metaResolved = resolveRequiredPermissionMode(
|
||||
metaMode,
|
||||
'_meta.permissionMode',
|
||||
)
|
||||
if (
|
||||
metaResolved === 'bypassPermissions' &&
|
||||
!isAcpBypassPermissionModeAvailable(settingsMode)
|
||||
) {
|
||||
throw new Error(
|
||||
'Mode not available: bypassPermissions requires a local ACP bypass opt-in.',
|
||||
)
|
||||
}
|
||||
|
||||
return metaResolved
|
||||
}
|
||||
|
||||
const settingsResolved = resolveConfiguredPermissionMode(settingsMode)
|
||||
return settingsResolved ?? 'default'
|
||||
}
|
||||
|
||||
function resolveRequiredPermissionMode(
|
||||
mode: unknown,
|
||||
source: string,
|
||||
): PermissionMode {
|
||||
if (mode === undefined || mode === null) {
|
||||
throw new Error(`Invalid ${source}: expected a string.`)
|
||||
}
|
||||
|
||||
return resolvePermissionMode(mode, source) as PermissionMode
|
||||
}
|
||||
|
||||
function resolveConfiguredPermissionMode(
|
||||
mode: unknown,
|
||||
): PermissionMode | undefined {
|
||||
if (mode === undefined || mode === null) return undefined
|
||||
|
||||
try {
|
||||
return resolvePermissionMode(
|
||||
mode,
|
||||
'permissions.defaultMode',
|
||||
) as PermissionMode
|
||||
} catch (err: unknown) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
console.error(
|
||||
'[ACP] Invalid permissions.defaultMode, using default:',
|
||||
reason,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function hasOwnField(
|
||||
value: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
): boolean {
|
||||
return !!value && Object.hasOwn(value, key)
|
||||
}
|
||||
|
||||
export function isAcpBypassPermissionModeAvailable(
|
||||
settingsMode?: unknown,
|
||||
): boolean {
|
||||
return (
|
||||
isProcessBypassPermissionModeAvailable() &&
|
||||
(isAcpBypassLocallyEnabled() ||
|
||||
isSettingsBypassPermissionMode(settingsMode))
|
||||
)
|
||||
}
|
||||
|
||||
function isProcessBypassPermissionModeAvailable(): boolean {
|
||||
if (process.env.IS_SANDBOX) return true
|
||||
if (typeof process.geteuid === 'function') return process.geteuid() !== 0
|
||||
if (typeof process.getuid === 'function') return process.getuid() !== 0
|
||||
return true
|
||||
}
|
||||
|
||||
function isAcpBypassLocallyEnabled(): boolean {
|
||||
return (
|
||||
process.env.ACP_PERMISSION_MODE === 'bypassPermissions' ||
|
||||
isTruthyEnv(process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS)
|
||||
)
|
||||
}
|
||||
|
||||
function isSettingsBypassPermissionMode(settingsMode: unknown): boolean {
|
||||
try {
|
||||
return resolvePermissionMode(settingsMode) === 'bypassPermissions'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isTruthyEnv(value: string | undefined): boolean {
|
||||
return value === '1' || value?.toLowerCase() === 'true'
|
||||
}
|
||||
293
src/services/acp/agent/promptFlow.ts
Normal file
293
src/services/acp/agent/promptFlow.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Prompt-flow methods for AcpAgent, attached to the prototype via
|
||||
* Object.assign. Kept in a sibling module to keep AcpAgent.ts under the
|
||||
* 500-line budget. The barrel (./index.ts) imports this module for its
|
||||
* side effect so the prototype is populated before any instance is built.
|
||||
*
|
||||
* Methods attached: prompt, setSessionConfigOption.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { SessionId } from '../../../types/ids.js'
|
||||
import {
|
||||
switchSession,
|
||||
getSessionProjectDir,
|
||||
} from '../../../bootstrap/state.js'
|
||||
import { forwardSessionUpdates } from '../bridge.js'
|
||||
import type { ToolUseCache } from '../bridge.js'
|
||||
import { promptToQueryInput } from '../promptConversion.js'
|
||||
import { sanitizeTitle } from '../utils.js'
|
||||
import { AcpAgent } from './AcpAgent.js'
|
||||
import type { AcpSession } from './sessionTypes.js'
|
||||
import { flattenConfigOptionValues } from './configOptions.js'
|
||||
import { popNextPendingPrompt } from './promptQueue.js'
|
||||
import {
|
||||
getConnection,
|
||||
readClientCapabilities,
|
||||
syncSessionConfigState,
|
||||
} from './internalAccessors.js'
|
||||
|
||||
// ── prompt ───────────────────────────────────────────────────────
|
||||
|
||||
async function prompt(
|
||||
this: AcpAgent,
|
||||
params: PromptRequest,
|
||||
): Promise<PromptResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error(`Session ${params.sessionId} not found`)
|
||||
}
|
||||
|
||||
// Extract text/image content from the prompt
|
||||
const promptInput = promptToQueryInput(params.prompt)
|
||||
|
||||
// Per prompt-turn.mdx, `prompt` is a required ContentBlock[] and an
|
||||
// effectively-empty prompt is malformed input — reject it with an
|
||||
// invalid_params error rather than fabricating a successful end_turn.
|
||||
if (!promptInput.trim()) {
|
||||
throw new Error('Prompt content is empty')
|
||||
}
|
||||
|
||||
const promptCancelGeneration = session.cancelGeneration
|
||||
|
||||
// Handle prompt queuing — if a prompt is already running, queue this one
|
||||
if (session.promptRunning) {
|
||||
const promptUuid = randomUUID()
|
||||
const cancelled = await new Promise<boolean>(resolve => {
|
||||
session.pendingQueue.push(promptUuid)
|
||||
session.pendingMessages.set(promptUuid, { resolve })
|
||||
})
|
||||
if (cancelled) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
}
|
||||
|
||||
if (session.cancelGeneration !== promptCancelGeneration) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
|
||||
// Reset cancellation only when this prompt is about to run. Queued prompts
|
||||
// must not clear the cancellation state for the active prompt.
|
||||
session.cancelled = false
|
||||
session.promptRunning = true
|
||||
|
||||
try {
|
||||
// Reset the query engine's abort controller for a fresh query.
|
||||
// After a previous interrupt(), the internal controller is stuck in
|
||||
// aborted state — without this, submitMessage() fails immediately.
|
||||
session.queryEngine.resetAbortController()
|
||||
// Switch global session state so recordTranscript writes to the correct
|
||||
// session file. Without this, multi-session scenarios (or creating a new
|
||||
// session after another) write transcript data to the wrong file.
|
||||
switchSession(params.sessionId as SessionId, getSessionProjectDir())
|
||||
|
||||
const sdkMessages = session.queryEngine.submitMessage(promptInput)
|
||||
|
||||
const { stopReason, usage } = await forwardSessionUpdates(
|
||||
params.sessionId,
|
||||
sdkMessages,
|
||||
getConnection(this),
|
||||
session.queryEngine.getAbortSignal(),
|
||||
session.toolUseCache,
|
||||
readClientCapabilities(this),
|
||||
session.cwd,
|
||||
() => session.cancelled,
|
||||
)
|
||||
|
||||
// If the session was cancelled during processing, return cancelled
|
||||
if (session.cancelled) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
|
||||
// Emit a session_info_update so Clients learn the session's display
|
||||
// title / last-activity timestamp via the stable v1 session/update
|
||||
// channel. The title is derived from the first user prompt.
|
||||
await emitSessionInfoUpdate(this, params.sessionId, promptInput)
|
||||
|
||||
// Per extensibility.mdx:39 the root of PromptResponse is reserved —
|
||||
// stable v1 defines only `stopReason` (+ optional `_meta`). Token usage
|
||||
// is therefore carried under the `_meta.claudeCode.usage` extension
|
||||
// namespace rather than as a non-spec root field. thoughtTokens are
|
||||
// included in totalTokens so reported totals match billable tokens;
|
||||
// until bridge.ts tracks them they are reported as 0.
|
||||
if (usage) {
|
||||
const thoughtTokens = 0
|
||||
return {
|
||||
stopReason,
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
usage: {
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedReadTokens: usage.cachedReadTokens,
|
||||
cachedWriteTokens: usage.cachedWriteTokens,
|
||||
thoughtTokens,
|
||||
totalTokens:
|
||||
usage.inputTokens +
|
||||
usage.outputTokens +
|
||||
usage.cachedReadTokens +
|
||||
usage.cachedWriteTokens +
|
||||
thoughtTokens,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return { stopReason }
|
||||
} catch (err: unknown) {
|
||||
// Treat AbortError / cancellation-shaped errors as a turn cancellation
|
||||
// regardless of the session.cancelled flag, to close the race window
|
||||
// between interrupt() firing and cancel() setting the flag. Per
|
||||
// prompt-turn.mdx the Agent MUST return `cancelled` for aborts.
|
||||
const isAbort =
|
||||
err instanceof Error &&
|
||||
(err.name === 'AbortError' ||
|
||||
/abort|cancelled|interrupt/i.test(err.message))
|
||||
if (session.cancelled || isAbort) {
|
||||
return { stopReason: 'cancelled' }
|
||||
}
|
||||
|
||||
// Check for process death errors
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes('terminated') ||
|
||||
err.message.includes('process exited'))
|
||||
) {
|
||||
await this.teardownSession(params.sessionId)
|
||||
throw new Error(
|
||||
'The Claude Agent process exited unexpectedly. Please start a new session.',
|
||||
)
|
||||
}
|
||||
|
||||
throw err
|
||||
} finally {
|
||||
// Resolve next pending prompt if any
|
||||
const nextPrompt = popNextPendingPrompt(session)
|
||||
if (nextPrompt) {
|
||||
session.promptRunning = true
|
||||
nextPrompt.resolve(false)
|
||||
} else {
|
||||
session.promptRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── setSessionConfigOption ───────────────────────────────────────
|
||||
|
||||
async function setSessionConfigOption(
|
||||
this: AcpAgent,
|
||||
params: SetSessionConfigOptionRequest,
|
||||
): Promise<SetSessionConfigOptionResponse> {
|
||||
const session = this.sessions.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error('Session not found')
|
||||
}
|
||||
if (typeof params.value !== 'string') {
|
||||
throw new Error(
|
||||
`Invalid value for config option ${params.configId}: ${String(params.value)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const option = session.configOptions.find(o => o.id === params.configId)
|
||||
if (!option) {
|
||||
throw new Error(`Unknown config option: ${params.configId}`)
|
||||
}
|
||||
|
||||
// Per session-config-options.mdx: value MUST be one of the values listed
|
||||
// in the option's options array. Reject unknown values with an error
|
||||
// rather than silently persisting them. Only `select` options carry an
|
||||
// options array; `boolean` options have no enumerated values.
|
||||
if (option.type === 'select') {
|
||||
const validValues = flattenConfigOptionValues(
|
||||
(option as { options?: unknown }).options,
|
||||
)
|
||||
if (!validValues.includes(params.value)) {
|
||||
throw new Error(
|
||||
`Invalid value '${params.value}' for config option ${params.configId}; must be one of: ${validValues.join(', ')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const value = params.value
|
||||
|
||||
if (params.configId === 'mode') {
|
||||
this.applySessionMode(params.sessionId, value)
|
||||
await getConnection(this).sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'current_mode_update',
|
||||
currentModeId: value,
|
||||
},
|
||||
})
|
||||
} else if (params.configId === 'model') {
|
||||
session.queryEngine.setModel(value)
|
||||
}
|
||||
|
||||
syncSessionConfigState(this, session, params.configId, value)
|
||||
|
||||
session.configOptions = session.configOptions.map(o =>
|
||||
o.id === params.configId && typeof o.currentValue === 'string'
|
||||
? { ...o, currentValue: value }
|
||||
: o,
|
||||
)
|
||||
|
||||
return { configOptions: session.configOptions }
|
||||
}
|
||||
|
||||
// ── Private-field accessors ──────────────────────────────────────
|
||||
//
|
||||
// getConnection / readClientCapabilities / syncSessionConfigState are
|
||||
// imported from ./internalAccessors.js (shared with sessionLifecycle.ts and
|
||||
// createSessionMethod.ts). The session_info_update helper below is local to
|
||||
// this module because it is only called from prompt().
|
||||
|
||||
/**
|
||||
* Emit a session_info_update notification carrying a derived session title
|
||||
* (truncated first user prompt) and the current last-activity timestamp.
|
||||
* Sent once per session — subsequent turns reuse the same title.
|
||||
*
|
||||
* This logic was originally the private `AcpAgent.maybeEmitSessionInfoUpdate`
|
||||
* method on the shell class. It is only called from the prompt flow, so it
|
||||
* lives here to avoid the `noUnusedPrivateClassMembers` false positive that
|
||||
* cast-based access would otherwise trigger on the shell.
|
||||
*/
|
||||
async function emitSessionInfoUpdate(
|
||||
agent: AcpAgent,
|
||||
sessionId: string,
|
||||
firstPrompt: string,
|
||||
): Promise<void> {
|
||||
const session = agent.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
// sessionInfoTitleSent is tracked via toolUseCache to avoid reshaping
|
||||
// AcpSession; use a dedicated per-session flag instead.
|
||||
const cache = session.toolUseCache as ToolUseCache & {
|
||||
__sessionInfoTitleSent?: boolean
|
||||
}
|
||||
if (cache.__sessionInfoTitleSent) return
|
||||
cache.__sessionInfoTitleSent = true
|
||||
const title = sanitizeTitle(firstPrompt).slice(0, 100)
|
||||
try {
|
||||
await getConnection(agent).sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'session_info_update',
|
||||
...(title ? { title } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[ACP] Failed to send session_info_update:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prototype attachment ─────────────────────────────────────────
|
||||
|
||||
Object.assign(AcpAgent.prototype, {
|
||||
prompt,
|
||||
setSessionConfigOption,
|
||||
})
|
||||
36
src/services/acp/agent/promptQueue.ts
Normal file
36
src/services/acp/agent/promptQueue.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { AcpSession, PendingPrompt } from './sessionTypes.js'
|
||||
|
||||
export function popNextPendingPrompt(
|
||||
session: AcpSession,
|
||||
): PendingPrompt | undefined {
|
||||
while (session.pendingQueueHead < session.pendingQueue.length) {
|
||||
const nextId = session.pendingQueue[session.pendingQueueHead++]
|
||||
if (!nextId) continue
|
||||
const next = session.pendingMessages.get(nextId)
|
||||
if (!next) continue
|
||||
session.pendingMessages.delete(nextId)
|
||||
compactPendingQueue(session)
|
||||
return next
|
||||
}
|
||||
|
||||
compactPendingQueue(session)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function compactPendingQueue(session: AcpSession): void {
|
||||
if (session.pendingQueueHead === 0) return
|
||||
|
||||
if (session.pendingQueueHead >= session.pendingQueue.length) {
|
||||
session.pendingQueue = []
|
||||
session.pendingQueueHead = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
session.pendingQueueHead > 1024 &&
|
||||
session.pendingQueueHead * 2 > session.pendingQueue.length
|
||||
) {
|
||||
session.pendingQueue = session.pendingQueue.slice(session.pendingQueueHead)
|
||||
session.pendingQueueHead = 0
|
||||
}
|
||||
}
|
||||
280
src/services/acp/agent/sessionLifecycle.ts
Normal file
280
src/services/acp/agent/sessionLifecycle.ts
Normal 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,
|
||||
})
|
||||
35
src/services/acp/agent/sessionTypes.ts
Normal file
35
src/services/acp/agent/sessionTypes.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
ClientCapabilities,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
SessionConfigOption,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { QueryEngine } from '../../../QueryEngine.js'
|
||||
import type { Command } from '../../../types/command.js'
|
||||
import type { AppState } from '../../../state/AppStateStore.js'
|
||||
import type { ToolUseCache } from '../bridge.js'
|
||||
|
||||
// ── Session state ─────────────────────────────────────────────────
|
||||
|
||||
export type AcpSession = {
|
||||
queryEngine: QueryEngine
|
||||
cancelled: boolean
|
||||
cancelGeneration: number
|
||||
cwd: string
|
||||
sessionFingerprint: string
|
||||
modes: SessionModeState
|
||||
models: SessionModelState
|
||||
configOptions: SessionConfigOption[]
|
||||
promptRunning: boolean
|
||||
pendingMessages: Map<string, PendingPrompt>
|
||||
pendingQueue: string[]
|
||||
pendingQueueHead: number
|
||||
toolUseCache: ToolUseCache
|
||||
clientCapabilities?: ClientCapabilities
|
||||
appState: AppState
|
||||
commands: Command[]
|
||||
}
|
||||
|
||||
export type PendingPrompt = {
|
||||
resolve: (cancelled: boolean) => void
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
146
src/services/acp/bridge/contentBlocks.ts
Normal file
146
src/services/acp/bridge/contentBlocks.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// Low-level conversion of Claude content block shapes into ACP ContentBlock values.
|
||||
import type { ContentBlock, ToolCallContent } from './types.js'
|
||||
|
||||
/**
|
||||
* Wraps a string or array of content blocks into a `{ content: ToolCallContent[] }`
|
||||
* update object. Used by `toolUpdateFromToolResult` for the default / error paths.
|
||||
*/
|
||||
export function toAcpContentUpdate(
|
||||
content: unknown,
|
||||
isError: boolean,
|
||||
): { content?: ToolCallContent[] } {
|
||||
if (Array.isArray(content) && content.length > 0) {
|
||||
return {
|
||||
content: content.map((c: Record<string, unknown>) => ({
|
||||
type: 'content' as const,
|
||||
content: toAcpContentBlock(c, isError),
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (typeof content === 'string' && content.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: {
|
||||
type: 'text' as const,
|
||||
text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function toAcpContentBlock(
|
||||
content: Record<string, unknown>,
|
||||
isError: boolean,
|
||||
): ContentBlock {
|
||||
const wrapText = (text: string): ContentBlock => ({
|
||||
type: 'text',
|
||||
text: isError ? `\`\`\`\n${text}\n\`\`\`` : text,
|
||||
})
|
||||
|
||||
const type = content.type as string
|
||||
switch (type) {
|
||||
case 'text': {
|
||||
const text = content.text as string
|
||||
return { type: 'text', text: isError ? `\`\`\`\n${text}\n\`\`\`` : text }
|
||||
}
|
||||
case 'image': {
|
||||
const source = content.source as Record<string, unknown> | undefined
|
||||
if (source?.type === 'base64') {
|
||||
return {
|
||||
type: 'image',
|
||||
data: source.data as string,
|
||||
mimeType: source.media_type as string,
|
||||
}
|
||||
}
|
||||
return wrapText(
|
||||
source?.type === 'url'
|
||||
? `[image: ${source.url as string}]`
|
||||
: '[image: file reference]',
|
||||
)
|
||||
}
|
||||
case 'resource_link': {
|
||||
// ACP v1 ResourceLink requires name + uri. Name falls back to uri when
|
||||
// absent so the client always has a display label. mimeType is optional.
|
||||
const uri = content.uri as string | undefined
|
||||
const name =
|
||||
(content.name as string | undefined) ?? (uri as string | undefined)
|
||||
return {
|
||||
type: 'resource_link',
|
||||
uri: uri as string,
|
||||
name: name as string,
|
||||
mimeType: content.mimeType as string | undefined,
|
||||
}
|
||||
}
|
||||
case 'resource': {
|
||||
// ACP v1 EmbeddedResource wraps an optional TextResource / BlobResource
|
||||
// shape. Forward the standard fields the client knows how to render.
|
||||
const r = content.resource as Record<string, unknown> | undefined
|
||||
// Construct a TextResource or BlobResource payload depending on what is
|
||||
// present. Cast through unknown because not every source shape satisfies
|
||||
// the full union contract.
|
||||
const resourcePayload = {
|
||||
uri: (r?.uri as string | undefined) ?? '',
|
||||
mimeType: r?.mimeType as string | null | undefined,
|
||||
...(typeof r?.text === 'string' ? { text: r.text as string } : {}),
|
||||
...(typeof r?.blob === 'string' ? { blob: r.blob as string } : {}),
|
||||
}
|
||||
return {
|
||||
type: 'resource',
|
||||
resource: resourcePayload,
|
||||
} as unknown as ContentBlock
|
||||
}
|
||||
case 'tool_reference':
|
||||
return wrapText(`Tool: ${content.tool_name as string}`)
|
||||
case 'tool_search_tool_search_result': {
|
||||
const refs = content.tool_references as
|
||||
| Array<{ tool_name: string }>
|
||||
| undefined
|
||||
return wrapText(
|
||||
`Tools found: ${refs?.map(r => r.tool_name).join(', ') || 'none'}`,
|
||||
)
|
||||
}
|
||||
case 'tool_search_tool_result_error':
|
||||
return wrapText(
|
||||
`Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`,
|
||||
)
|
||||
case 'web_search_result':
|
||||
return wrapText(`${content.title as string} (${content.url as string})`)
|
||||
case 'web_search_tool_result_error':
|
||||
return wrapText(`Error: ${content.error_code as string}`)
|
||||
case 'web_fetch_result':
|
||||
return wrapText(`Fetched: ${content.url as string}`)
|
||||
case 'web_fetch_tool_result_error':
|
||||
return wrapText(`Error: ${content.error_code as string}`)
|
||||
case 'code_execution_result':
|
||||
case 'bash_code_execution_result':
|
||||
return wrapText(
|
||||
`Output: ${(content.stdout as string) || (content.stderr as string) || ''}`,
|
||||
)
|
||||
case 'code_execution_tool_result_error':
|
||||
case 'bash_code_execution_tool_result_error':
|
||||
return wrapText(`Error: ${content.error_code as string}`)
|
||||
case 'text_editor_code_execution_view_result':
|
||||
return wrapText(content.content as string)
|
||||
case 'text_editor_code_execution_create_result':
|
||||
return wrapText(content.is_file_update ? 'File updated' : 'File created')
|
||||
case 'text_editor_code_execution_str_replace_result': {
|
||||
const lines = content.lines as string[] | undefined
|
||||
return wrapText(lines?.join('\n') || '')
|
||||
}
|
||||
case 'text_editor_code_execution_tool_result_error':
|
||||
return wrapText(
|
||||
`Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`,
|
||||
)
|
||||
default:
|
||||
try {
|
||||
return { type: 'text', text: JSON.stringify(content) }
|
||||
} catch {
|
||||
return { type: 'text', text: '[content]' }
|
||||
}
|
||||
}
|
||||
}
|
||||
402
src/services/acp/bridge/forwarding.ts
Normal file
402
src/services/acp/bridge/forwarding.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
// Stream replay + forwarding loop.
|
||||
//
|
||||
// `nextSdkMessageOrAbort` races an async generator against an AbortSignal.
|
||||
// `forwardSessionUpdates` consumes the SDKMessage stream and dispatches into
|
||||
// the notification converters, accumulating usage and mapping stop reasons.
|
||||
// `replayHistoryMessages` replays stored user/assistant history through
|
||||
// `toAcpNotifications`.
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
ClientCapabilities,
|
||||
StopReason,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.generated.js'
|
||||
import type { BridgeSDKMessage, SessionUsage, ToolUseCache } from './types.js'
|
||||
import {
|
||||
assistantMessageToAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
toAcpNotifications,
|
||||
} from './notifications.js'
|
||||
import { getMatchingModelUsage } from './modelUsage.js'
|
||||
|
||||
// Top-level const alias retained from the original module. Only the
|
||||
// forwardSessionUpdates default branch and replayHistoryMessages reference it.
|
||||
const logger: { debug: (...args: unknown[]) => void } = console
|
||||
|
||||
export function nextSdkMessageOrAbort(
|
||||
sdkMessages: AsyncGenerator<SDKMessage, void, unknown>,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<IteratorResult<SDKMessage, void>> {
|
||||
if (abortSignal.aborted) {
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
}
|
||||
let abortHandler: (() => void) | undefined
|
||||
const abortPromise = new Promise<IteratorResult<SDKMessage, void>>(
|
||||
resolve => {
|
||||
abortHandler = () => resolve({ done: true, value: undefined })
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true })
|
||||
},
|
||||
)
|
||||
return Promise.race([sdkMessages.next(), abortPromise]).finally(() => {
|
||||
if (abortHandler) {
|
||||
abortSignal.removeEventListener('abort', abortHandler)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Main forwarding function ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Iterates SDKMessages from QueryEngine.submitMessage(), converts each
|
||||
* to ACP SessionUpdate notifications, and sends them via conn.sessionUpdate().
|
||||
* Returns the final StopReason and accumulated usage for the prompt turn.
|
||||
*/
|
||||
export async function forwardSessionUpdates(
|
||||
sessionId: string,
|
||||
sdkMessages: AsyncGenerator<SDKMessage, void, unknown>,
|
||||
conn: AgentSideConnection,
|
||||
abortSignal: AbortSignal,
|
||||
toolUseCache: ToolUseCache,
|
||||
clientCapabilities?: ClientCapabilities,
|
||||
cwd?: string,
|
||||
isCancelled?: () => boolean,
|
||||
): Promise<{ stopReason: StopReason; usage?: SessionUsage }> {
|
||||
let stopReason: StopReason = 'end_turn'
|
||||
const accumulatedUsage: SessionUsage = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
}
|
||||
|
||||
// Track last assistant usage/model for context window size computation
|
||||
let lastAssistantTotalUsage: number | null = null
|
||||
let lastAssistantModel: string | null = null
|
||||
let lastContextWindowSize = 200000
|
||||
let streamingActive = false
|
||||
|
||||
try {
|
||||
while (!abortSignal.aborted) {
|
||||
// Race the next message against the abort signal so we unblock
|
||||
// immediately when cancelled, even if the generator is waiting for
|
||||
// a slow API response.
|
||||
const nextResult = await nextSdkMessageOrAbort(sdkMessages, abortSignal)
|
||||
if (nextResult.done || abortSignal.aborted) break
|
||||
const rawMsg = nextResult.value
|
||||
if (rawMsg == null) continue
|
||||
const msg = rawMsg as BridgeSDKMessage
|
||||
|
||||
switch (msg.type) {
|
||||
// ── System messages ────────────────────────────────────────
|
||||
case 'system': {
|
||||
const subtype = msg.subtype
|
||||
|
||||
if (subtype === 'compact_boundary') {
|
||||
// Reset assistant usage tracking after compaction
|
||||
lastAssistantTotalUsage = 0
|
||||
// NOTE: usage_update is an UNSTABLE SessionUpdate discriminator (not in
|
||||
// stable v1 schema). Token/cost info has no v1-stable carrier; we drop
|
||||
// it from session/update and rely on PromptResponse._meta for clients
|
||||
// that need it (see audit §4.1).
|
||||
await conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '\n\nCompacting completed.' },
|
||||
},
|
||||
})
|
||||
}
|
||||
// api_retry, local_command_output — skip for now
|
||||
break
|
||||
}
|
||||
|
||||
// ── Result messages ────────────────────────────────────────
|
||||
case 'result': {
|
||||
const usage = msg.usage
|
||||
|
||||
if (usage) {
|
||||
accumulatedUsage.inputTokens += usage.input_tokens ?? 0
|
||||
accumulatedUsage.outputTokens += usage.output_tokens ?? 0
|
||||
accumulatedUsage.cachedReadTokens +=
|
||||
usage.cache_read_input_tokens ?? 0
|
||||
accumulatedUsage.cachedWriteTokens +=
|
||||
usage.cache_creation_input_tokens ?? 0
|
||||
}
|
||||
|
||||
// Resolve context window size from modelUsage via prefix matching
|
||||
const modelUsage = msg.modelUsage
|
||||
if (modelUsage && lastAssistantModel) {
|
||||
const match = getMatchingModelUsage(modelUsage, lastAssistantModel)
|
||||
if (match?.contextWindow) {
|
||||
lastContextWindowSize = match.contextWindow
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: usage_update was removed — it is an UNSTABLE SessionUpdate
|
||||
// discriminator not present in the stable v1 schema (audit §4.1). Token
|
||||
// and cost information is returned via PromptResponse._meta.claudeCode.usage
|
||||
// instead.
|
||||
|
||||
// Determine stop reason
|
||||
const subtype = msg.subtype
|
||||
const isError = msg.is_error
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
stopReason = 'cancelled'
|
||||
break
|
||||
}
|
||||
|
||||
switch (subtype) {
|
||||
case 'success': {
|
||||
// Map Anthropic stop_reason to ACP StopReason. Branches are mutually
|
||||
// exclusive so a max_tokens termination that is also flagged isError
|
||||
// no longer silently flips to end_turn (audit §3.3, §3.4). refusal
|
||||
// (safety refusal) is a first-class ACP stop reason that must surface
|
||||
// to the client instead of being misreported as end_turn.
|
||||
const r = msg.stop_reason
|
||||
if (r === 'max_tokens') stopReason = 'max_tokens'
|
||||
else if (r === 'refusal') stopReason = 'refusal'
|
||||
else stopReason = 'end_turn'
|
||||
if (isError) stopReason = 'end_turn'
|
||||
break
|
||||
}
|
||||
case 'error_during_execution': {
|
||||
// Mutually exclusive: max_tokens wins when reported, otherwise the
|
||||
// error path falls back to end_turn. Avoids the prior two-if
|
||||
// sequence that overwrote max_tokens with end_turn (audit §3.4).
|
||||
if (msg.stop_reason === 'max_tokens') {
|
||||
stopReason = 'max_tokens'
|
||||
} else {
|
||||
stopReason = 'end_turn'
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'error_max_budget_usd':
|
||||
case 'error_max_turns':
|
||||
case 'error_max_structured_output_retries':
|
||||
if (isError) {
|
||||
stopReason = 'max_turn_requests'
|
||||
} else {
|
||||
stopReason = 'max_turn_requests'
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// ── Stream events ──────────────────────────────────────────
|
||||
case 'stream_event': {
|
||||
const notifications = streamEventToAcpNotifications(
|
||||
msg,
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
conn,
|
||||
{
|
||||
clientCapabilities,
|
||||
cwd,
|
||||
},
|
||||
)
|
||||
for (const notification of notifications) {
|
||||
await conn.sessionUpdate(notification)
|
||||
}
|
||||
streamingActive = true
|
||||
break
|
||||
}
|
||||
|
||||
// ── Assistant messages ─────────────────────────────────────
|
||||
case 'assistant': {
|
||||
// Track last assistant total usage for context window computation
|
||||
// (only for top-level messages, not subagents)
|
||||
const assistantMsg = msg.message
|
||||
const parentToolUseId = msg.parent_tool_use_id
|
||||
if (assistantMsg?.usage && parentToolUseId === null) {
|
||||
const usage = assistantMsg.usage
|
||||
lastAssistantTotalUsage =
|
||||
(typeof usage.input_tokens === 'number'
|
||||
? usage.input_tokens
|
||||
: 0) +
|
||||
(typeof usage.output_tokens === 'number'
|
||||
? usage.output_tokens
|
||||
: 0) +
|
||||
(typeof usage.cache_read_input_tokens === 'number'
|
||||
? usage.cache_read_input_tokens
|
||||
: 0) +
|
||||
(typeof usage.cache_creation_input_tokens === 'number'
|
||||
? usage.cache_creation_input_tokens
|
||||
: 0)
|
||||
}
|
||||
// Track the current top-level model for context window size lookup
|
||||
if (
|
||||
parentToolUseId === null &&
|
||||
assistantMsg?.model &&
|
||||
assistantMsg.model !== '<synthetic>'
|
||||
) {
|
||||
lastAssistantModel = assistantMsg.model
|
||||
}
|
||||
|
||||
const notifications = assistantMessageToAcpNotifications(
|
||||
msg,
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
conn,
|
||||
{
|
||||
clientCapabilities,
|
||||
cwd,
|
||||
parentToolUseId,
|
||||
streamingActive,
|
||||
},
|
||||
)
|
||||
for (const notification of notifications) {
|
||||
await conn.sessionUpdate(notification)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// ── User messages ──────────────────────────────────────────
|
||||
case 'user': {
|
||||
// In ACP mode, user messages from replay/synthetic are typically skipped
|
||||
// The client already knows what the user sent
|
||||
break
|
||||
}
|
||||
|
||||
// ── Progress messages ──────────────────────────────────────
|
||||
case 'progress': {
|
||||
const progressData = msg.data
|
||||
if (!progressData) break
|
||||
|
||||
// Handle agent/skill subagent progress
|
||||
const progressType = progressData.type
|
||||
if (
|
||||
progressType === 'agent_progress' ||
|
||||
progressType === 'skill_progress'
|
||||
) {
|
||||
const progressMessage = progressData.message
|
||||
if (progressMessage) {
|
||||
const content = progressMessage.content as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined
|
||||
if (content) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text') {
|
||||
await conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: block.text as string },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// ── Tool use summary ───────────────────────────────────────
|
||||
case 'tool_use_summary': {
|
||||
// Skip for now — not critical for basic functionality
|
||||
break
|
||||
}
|
||||
|
||||
// ── Attachment messages ────────────────────────────────────
|
||||
case 'attachment': {
|
||||
// Skip — handled by QueryEngine internally
|
||||
break
|
||||
}
|
||||
|
||||
// ── Compact boundary ───────────────────────────────────────
|
||||
case 'compact_boundary': {
|
||||
lastAssistantTotalUsage = 0
|
||||
// NOTE: usage_update removed — UNSTABLE discriminator, not in v1 stable
|
||||
// schema (audit §4.1). Token info flows through PromptResponse._meta.
|
||||
await conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '\n\nCompacting completed.' },
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
logger.debug('Ignoring unknown SDK message type')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If we exited the loop because abort fired or cancel was requested, return cancelled
|
||||
if (abortSignal.aborted || isCancelled?.()) {
|
||||
return { stopReason: 'cancelled', usage: accumulatedUsage }
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (abortSignal.aborted) {
|
||||
return { stopReason: 'cancelled', usage: accumulatedUsage }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
return { stopReason, usage: accumulatedUsage }
|
||||
}
|
||||
|
||||
// ── History replay ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replays conversation history messages to the ACP client as session updates.
|
||||
* Used when resuming/loading a session to show the client the previous conversation.
|
||||
*/
|
||||
export async function replayHistoryMessages(
|
||||
sessionId: string,
|
||||
messages: Array<Record<string, unknown>>,
|
||||
conn: AgentSideConnection,
|
||||
toolUseCache: ToolUseCache,
|
||||
clientCapabilities?: ClientCapabilities,
|
||||
cwd?: string,
|
||||
): Promise<void> {
|
||||
for (const rawMsg of messages) {
|
||||
const msg = rawMsg as BridgeSDKMessage
|
||||
// Skip non-conversation messages
|
||||
if (msg.type !== 'user' && msg.type !== 'assistant') {
|
||||
logger.debug('Ignoring unknown SDK message type')
|
||||
continue
|
||||
}
|
||||
// Skip meta messages (synthetic continuation prompts)
|
||||
if (msg.isMeta === true) continue
|
||||
|
||||
const messageData = msg.message
|
||||
const content = messageData?.content
|
||||
if (!content) continue
|
||||
|
||||
const role: 'assistant' | 'user' =
|
||||
msg.type === 'assistant' ? 'assistant' : 'user'
|
||||
|
||||
if (typeof content === 'string') {
|
||||
if (!content.trim()) continue
|
||||
await conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate:
|
||||
role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
|
||||
content: { type: 'text', text: content },
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const notifications = toAcpNotifications(
|
||||
content as Array<Record<string, unknown>>,
|
||||
role,
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
conn,
|
||||
undefined,
|
||||
{ clientCapabilities, cwd },
|
||||
)
|
||||
for (const notification of notifications) {
|
||||
await conn.sessionUpdate(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/services/acp/bridge/modelUsage.ts
Normal file
27
src/services/acp/bridge/modelUsage.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Pure helpers used by the forwarding loop to resolve contextWindow from the
|
||||
// modelUsage map by longest prefix match.
|
||||
|
||||
export function commonPrefixLength(a: string, b: string): number {
|
||||
let i = 0
|
||||
const maxLen = Math.min(a.length, b.length)
|
||||
while (i < maxLen && a[i] === b[i]) i++
|
||||
return i
|
||||
}
|
||||
|
||||
export function getMatchingModelUsage(
|
||||
modelUsage: Record<string, { contextWindow?: number }>,
|
||||
currentModel: string,
|
||||
): { contextWindow?: number } | null {
|
||||
let bestKey: string | null = null
|
||||
let bestLen = 0
|
||||
|
||||
for (const key of Object.keys(modelUsage)) {
|
||||
const len = commonPrefixLength(key, currentModel)
|
||||
if (len > bestLen) {
|
||||
bestLen = len
|
||||
bestKey = key
|
||||
}
|
||||
}
|
||||
|
||||
return bestKey ? (modelUsage[bestKey] ?? null) : null
|
||||
}
|
||||
351
src/services/acp/bridge/notifications.ts
Normal file
351
src/services/acp/bridge/notifications.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
// Core content-block → SessionUpdate conversion engine.
|
||||
//
|
||||
// `toAcpNotifications` handles text/thinking/image/tool_use/tool_result/etc.
|
||||
// and writes into the ToolUseCache. `assistantMessageToAcpNotifications` and
|
||||
// `streamEventToAcpNotifications` are thin adapters. `normalizePlanStatus`
|
||||
// maps TodoWrite status strings onto the ACP PlanEntry status enum.
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
ClientCapabilities,
|
||||
PlanEntry,
|
||||
SessionNotification,
|
||||
SessionUpdate,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { ToolUseCache } from './types.js'
|
||||
import { toolInfoFromToolUse } from './toolInfo.js'
|
||||
import { toolUpdateFromToolResult } from './toolResults.js'
|
||||
|
||||
/**
|
||||
* Maps a TodoWrite status string onto the ACP PlanEntry status enum.
|
||||
* Unknown / unsupported values fall back to 'pending'.
|
||||
*/
|
||||
export function normalizePlanStatus(
|
||||
status: string,
|
||||
): 'pending' | 'in_progress' | 'completed' {
|
||||
if (status === 'in_progress') return 'in_progress'
|
||||
if (status === 'completed') return 'completed'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
export function toAcpNotifications(
|
||||
content: Array<Record<string, unknown>>,
|
||||
role: 'assistant' | 'user',
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
_conn: AgentSideConnection,
|
||||
_logger?: { error: (...args: unknown[]) => void },
|
||||
options?: {
|
||||
registerHooks?: boolean
|
||||
clientCapabilities?: ClientCapabilities
|
||||
parentToolUseId?: string | null
|
||||
cwd?: string
|
||||
streamingActive?: boolean
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const output: SessionNotification[] = []
|
||||
|
||||
for (const chunk of content) {
|
||||
const chunkType = chunk.type as string
|
||||
let update: SessionUpdate | null = null
|
||||
|
||||
switch (chunkType) {
|
||||
case 'text':
|
||||
case 'text_delta': {
|
||||
const text = (chunk.text as string) ?? ''
|
||||
update = {
|
||||
sessionUpdate:
|
||||
role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking':
|
||||
case 'thinking_delta': {
|
||||
const thinking = (chunk.thinking as string) ?? ''
|
||||
update = {
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: thinking },
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'image': {
|
||||
const source = chunk.source as Record<string, unknown> | undefined
|
||||
if (source?.type === 'base64') {
|
||||
update = {
|
||||
sessionUpdate:
|
||||
role === 'assistant'
|
||||
? 'agent_message_chunk'
|
||||
: 'user_message_chunk',
|
||||
content: {
|
||||
type: 'image',
|
||||
data: source.data as string,
|
||||
mimeType: source.media_type as string,
|
||||
},
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_use':
|
||||
case 'server_tool_use':
|
||||
case 'mcp_tool_use': {
|
||||
const toolUseId = (chunk.id as string) ?? ''
|
||||
const toolName = (chunk.name as string) ?? 'unknown'
|
||||
const toolInput = chunk.input as Record<string, unknown> | undefined
|
||||
const alreadyCached = toolUseId in toolUseCache
|
||||
|
||||
// Cache this tool_use for later matching
|
||||
toolUseCache[toolUseId] = {
|
||||
type: chunkType as 'tool_use' | 'server_tool_use' | 'mcp_tool_use',
|
||||
id: toolUseId,
|
||||
name: toolName,
|
||||
input: toolInput,
|
||||
}
|
||||
|
||||
// TodoWrite → plan update
|
||||
if (toolName === 'TodoWrite') {
|
||||
const todos = (toolInput as Record<string, unknown>)?.todos as
|
||||
| Array<{ content: string; status: string }>
|
||||
| undefined
|
||||
if (Array.isArray(todos)) {
|
||||
const entries: PlanEntry[] = todos.map(todo => ({
|
||||
content: todo.content,
|
||||
status: normalizePlanStatus(todo.status),
|
||||
priority: 'medium',
|
||||
}))
|
||||
update = {
|
||||
sessionUpdate: 'plan',
|
||||
entries,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular tool call
|
||||
const rawInput = toolInput ? { ...toolInput } : {}
|
||||
|
||||
if (alreadyCached) {
|
||||
// Second encounter — tool_use input is now fully received.
|
||||
// The tool is about to execute (pending permission, then run).
|
||||
// Emit a tool_call_update with status 'in_progress' so clients
|
||||
// can distinguish "awaiting approval / running" from the initial
|
||||
// 'pending' (per ACP v1 ToolCallStatus lifecycle, schema.json:3525).
|
||||
update = {
|
||||
_meta: {
|
||||
claudeCode: { toolName },
|
||||
},
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status: 'in_progress',
|
||||
rawInput,
|
||||
...toolInfoFromToolUse(
|
||||
{ name: toolName, id: toolUseId, input: toolInput ?? {} },
|
||||
false,
|
||||
options?.cwd,
|
||||
),
|
||||
}
|
||||
} else {
|
||||
// First encounter — send as tool_call
|
||||
update = {
|
||||
_meta: {
|
||||
claudeCode: { toolName },
|
||||
},
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: 'tool_call',
|
||||
rawInput,
|
||||
status: 'pending',
|
||||
...toolInfoFromToolUse(
|
||||
{ name: toolName, id: toolUseId, input: toolInput ?? {} },
|
||||
false,
|
||||
options?.cwd,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_result':
|
||||
case 'mcp_tool_result': {
|
||||
const toolUseId = (chunk.tool_use_id as string | undefined) ?? ''
|
||||
const toolUse = toolUseCache[toolUseId]
|
||||
if (!toolUse) break
|
||||
|
||||
if (toolUse.name !== 'TodoWrite') {
|
||||
const toolUpdate = toolUpdateFromToolResult(
|
||||
chunk as unknown as Record<string, unknown>,
|
||||
{ name: toolUse.name, id: toolUse.id },
|
||||
false,
|
||||
)
|
||||
|
||||
update = {
|
||||
_meta: {
|
||||
claudeCode: { toolName: toolUse.name },
|
||||
},
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status:
|
||||
(chunk.is_error as boolean | undefined) === true
|
||||
? 'failed'
|
||||
: 'completed',
|
||||
rawOutput: chunk.content,
|
||||
...toolUpdate,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'redacted_thinking':
|
||||
case 'input_json_delta':
|
||||
case 'citations_delta':
|
||||
case 'signature_delta':
|
||||
case 'container_upload':
|
||||
case 'compaction':
|
||||
case 'compaction_delta':
|
||||
// Skip these types
|
||||
break
|
||||
}
|
||||
|
||||
if (update) {
|
||||
// Add parentToolUseId to _meta if present
|
||||
if (options?.parentToolUseId) {
|
||||
const existingMeta = (update as Record<string, unknown>)._meta as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
;(update as Record<string, unknown>)._meta = {
|
||||
...existingMeta,
|
||||
claudeCode: {
|
||||
...((existingMeta?.claudeCode as Record<string, unknown>) ?? {}),
|
||||
parentToolUseId: options.parentToolUseId,
|
||||
},
|
||||
}
|
||||
}
|
||||
output.push({ sessionId, update })
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export function assistantMessageToAcpNotifications(
|
||||
msg: { message?: unknown; parent_tool_use_id?: string | null },
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
conn: AgentSideConnection,
|
||||
options?: {
|
||||
clientCapabilities?: ClientCapabilities
|
||||
parentToolUseId?: string | null
|
||||
cwd?: string
|
||||
streamingActive?: boolean
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const message = msg.message as Record<string, unknown> | undefined
|
||||
if (!message) return []
|
||||
|
||||
const content = message.content as
|
||||
| string
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined
|
||||
if (!content) return []
|
||||
|
||||
// If content is a string, treat as text
|
||||
if (typeof content === 'string') {
|
||||
return [
|
||||
{
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: content },
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// When streaming is active, text/thinking were already sent via stream_event
|
||||
// messages. Filter them out to avoid duplicate agent_message_chunk /
|
||||
// agent_thought_chunk notifications. String content (synthetic messages)
|
||||
// is unaffected — those have no corresponding stream_events.
|
||||
const contentToProcess = options?.streamingActive
|
||||
? content.filter(
|
||||
block => block.type !== 'text' && block.type !== 'thinking',
|
||||
)
|
||||
: content
|
||||
|
||||
if (contentToProcess.length === 0) return []
|
||||
|
||||
return toAcpNotifications(
|
||||
contentToProcess,
|
||||
'assistant',
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
conn,
|
||||
undefined,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
export function streamEventToAcpNotifications(
|
||||
msg: {
|
||||
event?: Record<string, unknown>
|
||||
parent_tool_use_id?: string | null
|
||||
},
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
conn: AgentSideConnection,
|
||||
options?: {
|
||||
clientCapabilities?: ClientCapabilities
|
||||
cwd?: string
|
||||
streamingActive?: boolean
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const event = (msg as unknown as { event: Record<string, unknown> }).event
|
||||
if (!event) return []
|
||||
|
||||
switch (event.type as string) {
|
||||
case 'content_block_start': {
|
||||
const contentBlock = event.content_block as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
if (!contentBlock) return []
|
||||
return toAcpNotifications(
|
||||
[contentBlock],
|
||||
'assistant',
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
conn,
|
||||
undefined,
|
||||
{
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
parentToolUseId: msg.parent_tool_use_id as string | null | undefined,
|
||||
cwd: options?.cwd,
|
||||
},
|
||||
)
|
||||
}
|
||||
case 'content_block_delta': {
|
||||
const delta = event.delta as Record<string, unknown> | undefined
|
||||
if (!delta) return []
|
||||
return toAcpNotifications(
|
||||
[delta],
|
||||
'assistant',
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
conn,
|
||||
undefined,
|
||||
{
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
parentToolUseId: msg.parent_tool_use_id as string | null | undefined,
|
||||
cwd: options?.cwd,
|
||||
},
|
||||
)
|
||||
}
|
||||
// No content to emit
|
||||
case 'message_start':
|
||||
case 'message_delta':
|
||||
case 'message_stop':
|
||||
case 'content_block_stop':
|
||||
return []
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
17
src/services/acp/bridge/paths.ts
Normal file
17
src/services/acp/bridge/paths.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Pure path-normalisation helper used by toolInfo / toolResults / forwarding.
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
|
||||
/**
|
||||
* Normalises an emitted file path against the session cwd so that
|
||||
* ToolCallLocation.path / Diff.path values are always absolute, as required
|
||||
* by the ACP v1 spec (tool-calls.mdx:304-306; all file paths MUST be absolute).
|
||||
* If no cwd is available, the original value is returned unchanged.
|
||||
*/
|
||||
export function toAbsolutePath(
|
||||
filePath: string | undefined,
|
||||
cwd?: string,
|
||||
): string | undefined {
|
||||
if (!filePath) return undefined
|
||||
if (!cwd) return filePath
|
||||
return isAbsolute(filePath) ? filePath : resolve(cwd, filePath)
|
||||
}
|
||||
239
src/services/acp/bridge/toolInfo.ts
Normal file
239
src/services/acp/bridge/toolInfo.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
// toolInfoFromToolUse — large switch mapping each known tool name to ACP ToolInfo.
|
||||
import type { ToolInfo } from './types.js'
|
||||
import { toAbsolutePath } from './paths.js'
|
||||
import { toDisplayPath } from '../utils.js'
|
||||
|
||||
export function toolInfoFromToolUse(
|
||||
toolUse: { name: string; id: string; input: Record<string, unknown> },
|
||||
_supportsTerminalOutput: boolean = false,
|
||||
cwd?: string,
|
||||
): ToolInfo {
|
||||
const name = toolUse.name
|
||||
const input = toolUse.input
|
||||
|
||||
switch (name) {
|
||||
case 'Agent':
|
||||
case 'Task': {
|
||||
const description = (input?.description as string | undefined) ?? 'Task'
|
||||
const prompt = input?.prompt as string | undefined
|
||||
return {
|
||||
title: description,
|
||||
kind: 'think',
|
||||
content: prompt
|
||||
? [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: { type: 'text' as const, text: prompt },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
case 'Bash': {
|
||||
const command = (input?.command as string | undefined) ?? 'Terminal'
|
||||
const description = input?.description as string | undefined
|
||||
// Standard ACP terminal lifecycle (terminal/create → embed real terminalId →
|
||||
// terminal/release) is not wired through BashTool yet. Embedding a fake
|
||||
// terminalId here would cause compliant clients to fail terminal/output
|
||||
// lookups, so we fall back to inline text content per audit doc §5.2.
|
||||
// The _supportsTerminalOutput flag is retained for forward compatibility
|
||||
// once terminal/create is actually plumbed through.
|
||||
void _supportsTerminalOutput
|
||||
return {
|
||||
title: command,
|
||||
kind: 'execute',
|
||||
content: description
|
||||
? [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: { type: 'text' as const, text: description },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
case 'Read': {
|
||||
const inputFilePath = input?.file_path as string | undefined
|
||||
const filePath = inputFilePath ?? 'File'
|
||||
const offset = input?.offset as number | undefined
|
||||
const limit = input?.limit as number | undefined
|
||||
let suffix = ''
|
||||
if (limit && limit > 0) {
|
||||
suffix = ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})`
|
||||
} else if (offset) {
|
||||
suffix = ` (from line ${offset})`
|
||||
}
|
||||
const displayPath = filePath ? toDisplayPath(filePath, cwd) : 'File'
|
||||
const absReadPath = toAbsolutePath(inputFilePath, cwd)
|
||||
return {
|
||||
title: `Read ${displayPath}${suffix}`,
|
||||
kind: 'read',
|
||||
locations: absReadPath
|
||||
? [{ path: absReadPath, line: offset ?? 1 }]
|
||||
: [],
|
||||
content: [],
|
||||
}
|
||||
}
|
||||
|
||||
case 'Write': {
|
||||
const filePath = (input?.file_path as string | undefined) ?? ''
|
||||
const content = (input?.content as string | undefined) ?? ''
|
||||
const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined
|
||||
const absWritePath = toAbsolutePath(filePath, cwd)
|
||||
return {
|
||||
title: displayPath ? `Write ${displayPath}` : 'Write',
|
||||
kind: 'edit',
|
||||
content: absWritePath
|
||||
? [
|
||||
{
|
||||
type: 'diff' as const,
|
||||
path: absWritePath,
|
||||
oldText: null,
|
||||
newText: content,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: { type: 'text' as const, text: content },
|
||||
},
|
||||
],
|
||||
locations: absWritePath ? [{ path: absWritePath }] : [],
|
||||
}
|
||||
}
|
||||
|
||||
case 'Edit': {
|
||||
const filePath = (input?.file_path as string | undefined) ?? ''
|
||||
const oldString = (input?.old_string as string | undefined) ?? ''
|
||||
const newString = (input?.new_string as string | undefined) ?? ''
|
||||
const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined
|
||||
const absEditPath = toAbsolutePath(filePath, cwd)
|
||||
return {
|
||||
title: displayPath ? `Edit ${displayPath}` : 'Edit',
|
||||
kind: 'edit',
|
||||
content: absEditPath
|
||||
? [
|
||||
{
|
||||
type: 'diff' as const,
|
||||
path: absEditPath,
|
||||
oldText: oldString || null,
|
||||
newText: newString,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: absEditPath ? [{ path: absEditPath }] : [],
|
||||
}
|
||||
}
|
||||
|
||||
case 'Glob': {
|
||||
const globPath = (input?.path as string | undefined) ?? ''
|
||||
const pattern = (input?.pattern as string | undefined) ?? ''
|
||||
const absGlobPath = toAbsolutePath(globPath, cwd)
|
||||
let label = 'Find'
|
||||
if (globPath) label += ` \`${globPath}\``
|
||||
if (pattern) label += ` \`${pattern}\``
|
||||
return {
|
||||
title: label,
|
||||
kind: 'search',
|
||||
content: [],
|
||||
locations: absGlobPath ? [{ path: absGlobPath }] : [],
|
||||
}
|
||||
}
|
||||
|
||||
case 'Grep': {
|
||||
const grepPattern = (input?.pattern as string | undefined) ?? ''
|
||||
const grepPath = (input?.path as string | undefined) ?? ''
|
||||
let label = 'grep'
|
||||
if (input?.['-i']) label += ' -i'
|
||||
if (input?.['-n']) label += ' -n'
|
||||
if (input?.['-A'] !== undefined) label += ` -A ${input['-A'] as number}`
|
||||
if (input?.['-B'] !== undefined) label += ` -B ${input['-B'] as number}`
|
||||
if (input?.['-C'] !== undefined) label += ` -C ${input['-C'] as number}`
|
||||
if (input?.output_mode === 'files_with_matches') label += ' -l'
|
||||
else if (input?.output_mode === 'count') label += ' -c'
|
||||
if (input?.head_limit !== undefined)
|
||||
label += ` | head -${input.head_limit as number}`
|
||||
if (input?.glob) label += ` --include="${input.glob as string}"`
|
||||
if (input?.type) label += ` --type=${input.type as string}`
|
||||
if (input?.multiline) label += ' -P'
|
||||
if (grepPattern) label += ` "${grepPattern}"`
|
||||
if (grepPath) label += ` ${grepPath}`
|
||||
return {
|
||||
title: label,
|
||||
kind: 'search',
|
||||
content: [],
|
||||
}
|
||||
}
|
||||
|
||||
case 'WebFetch': {
|
||||
const url = (input?.url as string | undefined) ?? ''
|
||||
const fetchPrompt = input?.prompt as string | undefined
|
||||
return {
|
||||
title: url ? `Fetch ${url}` : 'Fetch',
|
||||
kind: 'fetch',
|
||||
content: fetchPrompt
|
||||
? [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: { type: 'text' as const, text: fetchPrompt },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
case 'WebSearch': {
|
||||
const query = (input?.query as string | undefined) ?? 'Web search'
|
||||
let label = `"${query}"`
|
||||
const allowed = input?.allowed_domains as string[] | undefined
|
||||
const blocked = input?.blocked_domains as string[] | undefined
|
||||
if (allowed && allowed.length > 0)
|
||||
label += ` (allowed: ${allowed.join(', ')})`
|
||||
if (blocked && blocked.length > 0)
|
||||
label += ` (blocked: ${blocked.join(', ')})`
|
||||
return {
|
||||
title: label,
|
||||
kind: 'fetch',
|
||||
content: [],
|
||||
}
|
||||
}
|
||||
|
||||
case 'TodoWrite': {
|
||||
const todos = input?.todos as Array<{ content: string }> | undefined
|
||||
return {
|
||||
title: Array.isArray(todos)
|
||||
? `Update TODOs: ${todos.map(t => t.content).join(', ')}`
|
||||
: 'Update TODOs',
|
||||
kind: 'think',
|
||||
content: [],
|
||||
}
|
||||
}
|
||||
|
||||
case 'ExitPlanMode': {
|
||||
const plan = (input as Record<string, unknown>)?.plan as
|
||||
| string
|
||||
| undefined
|
||||
return {
|
||||
title: 'Ready to code?',
|
||||
kind: 'switch_mode',
|
||||
content: plan
|
||||
? [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: { type: 'text' as const, text: plan },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
title: name || 'Unknown Tool',
|
||||
kind: 'other',
|
||||
content: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
184
src/services/acp/bridge/toolResults.ts
Normal file
184
src/services/acp/bridge/toolResults.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
// Tool result → ToolCallContent conversion.
|
||||
import type { ToolCallContent } from './types.js'
|
||||
import type { EditToolResponse } from './types.js'
|
||||
import { toAcpContentUpdate, toAcpContentBlock } from './contentBlocks.js'
|
||||
import { toAbsolutePath } from './paths.js'
|
||||
import { markdownEscape } from '../utils.js'
|
||||
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult: Record<string, unknown>,
|
||||
toolUse: { name: string; id: string } | undefined,
|
||||
_supportsTerminalOutput: boolean = false,
|
||||
): {
|
||||
content?: ToolCallContent[]
|
||||
title?: string
|
||||
_meta?: Record<string, unknown>
|
||||
} {
|
||||
if (!toolUse) return {}
|
||||
|
||||
const isError = toolResult.is_error === true
|
||||
const resultContent = toolResult.content as
|
||||
| string
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined
|
||||
|
||||
// For error results, return error content
|
||||
if (isError && resultContent) {
|
||||
return toAcpContentUpdate(resultContent, true)
|
||||
}
|
||||
|
||||
switch (toolUse.name) {
|
||||
case 'Read': {
|
||||
if (typeof resultContent === 'string' && resultContent.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: {
|
||||
type: 'text' as const,
|
||||
text: markdownEscape(resultContent),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
if (Array.isArray(resultContent) && resultContent.length > 0) {
|
||||
return {
|
||||
content: resultContent.map((c: Record<string, unknown>) => ({
|
||||
type: 'content' as const,
|
||||
content:
|
||||
c.type === 'text'
|
||||
? {
|
||||
type: 'text' as const,
|
||||
text: markdownEscape(c.text as string),
|
||||
}
|
||||
: toAcpContentBlock(c, false),
|
||||
})),
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
case 'Bash': {
|
||||
let output = ''
|
||||
// Standard ACP terminal lifecycle (terminal/create → embed real terminalId
|
||||
// → terminal/release) is not wired through BashTool yet. Previously this
|
||||
// branch embedded a fake terminalId (= toolUse.id, never registered via
|
||||
// terminal/create) and injected non-standard _meta keys (terminal_info /
|
||||
// terminal_output / terminal_exit) that compliant clients cannot
|
||||
// interpret. We now fall back to inline text content for the output; see
|
||||
// audit doc §5.2/§4.4. The _supportsTerminalOutput flag is retained on
|
||||
// the signature for forward compatibility once terminal/create is plumbed
|
||||
// through.
|
||||
void _supportsTerminalOutput
|
||||
|
||||
// Handle bash_code_execution_result format
|
||||
if (
|
||||
resultContent &&
|
||||
typeof resultContent === 'object' &&
|
||||
!Array.isArray(resultContent) &&
|
||||
(resultContent as Record<string, unknown>).type ===
|
||||
'bash_code_execution_result'
|
||||
) {
|
||||
const bashResult = resultContent as Record<string, unknown>
|
||||
output = [bashResult.stdout, bashResult.stderr]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
} else if (typeof resultContent === 'string') {
|
||||
output = resultContent
|
||||
} else if (Array.isArray(resultContent) && resultContent.length > 0) {
|
||||
output = resultContent
|
||||
.map((c: Record<string, unknown>) =>
|
||||
c.type === 'text' ? (c.text as string) : '',
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
if (output.trim()) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: {
|
||||
type: 'text' as const,
|
||||
text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
case 'Edit':
|
||||
case 'Write': {
|
||||
return {}
|
||||
}
|
||||
|
||||
case 'ExitPlanMode': {
|
||||
return { title: 'Exited Plan Mode' }
|
||||
}
|
||||
|
||||
default: {
|
||||
return toAcpContentUpdate(resultContent ?? '', isError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds diff ToolUpdate content from the structured Edit toolResponse.
|
||||
* Parses structuredPatch hunks (lines prefixed with -, +, space) into
|
||||
* oldText/newText diff pairs.
|
||||
*
|
||||
* The optional `cwd` is used to normalise the emitted path against the
|
||||
* session cwd so that Diff.path / ToolCallLocation.path are absolute as
|
||||
* required by the ACP v1 spec (audit §5.5).
|
||||
*/
|
||||
export function toolUpdateFromEditToolResponse(
|
||||
toolResponse: unknown,
|
||||
cwd?: string,
|
||||
): {
|
||||
content?: ToolCallContent[]
|
||||
locations?: { path: string; line?: number }[]
|
||||
} {
|
||||
if (!toolResponse || typeof toolResponse !== 'object') return {}
|
||||
const response = toolResponse as EditToolResponse
|
||||
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {}
|
||||
|
||||
const absPath = toAbsolutePath(response.filePath, cwd) ?? response.filePath
|
||||
|
||||
const content: ToolCallContent[] = []
|
||||
const locations: { path: string; line?: number }[] = []
|
||||
|
||||
for (const { lines, newStart } of response.structuredPatch) {
|
||||
const oldText: string[] = []
|
||||
const newText: string[] = []
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('-')) {
|
||||
oldText.push(line.slice(1))
|
||||
} else if (line.startsWith('+')) {
|
||||
newText.push(line.slice(1))
|
||||
} else {
|
||||
oldText.push(line.slice(1))
|
||||
newText.push(line.slice(1))
|
||||
}
|
||||
}
|
||||
if (oldText.length > 0 || newText.length > 0) {
|
||||
locations.push({ path: absPath, line: newStart })
|
||||
content.push({
|
||||
type: 'diff',
|
||||
path: absPath,
|
||||
oldText: oldText.join('\n') || null,
|
||||
newText: newText.join('\n'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result: {
|
||||
content?: ToolCallContent[]
|
||||
locations?: { path: string; line?: number }[]
|
||||
} = {}
|
||||
if (content.length > 0) result.content = content
|
||||
if (locations.length > 0) result.locations = locations
|
||||
return result
|
||||
}
|
||||
188
src/services/acp/bridge/types.ts
Normal file
188
src/services/acp/bridge/types.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// Shared ACP-bridge type definitions.
|
||||
//
|
||||
// Re-exports the SDK type-only imports that the rest of the bridge sub-modules
|
||||
// depend on, plus the local discriminated union of every message shape consumed
|
||||
// by the forwarding loop.
|
||||
import type {
|
||||
ContentBlock,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
|
||||
export type { ContentBlock, ToolCallContent, ToolCallLocation, ToolKind }
|
||||
|
||||
// ── ToolUseCache ──────────────────────────────────────────────────
|
||||
|
||||
/** Maps tool_use_id → tool metadata for tracked inflight tool calls. */
|
||||
export type ToolUseCache = {
|
||||
[key: string]: {
|
||||
type: 'tool_use' | 'server_tool_use' | 'mcp_tool_use'
|
||||
id: string
|
||||
name: string
|
||||
input: unknown
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session usage tracking ────────────────────────────────────────
|
||||
|
||||
/** Accumulated token usage across a session, updated per result message. */
|
||||
export type SessionUsage = {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cachedReadTokens: number
|
||||
cachedWriteTokens: number
|
||||
}
|
||||
|
||||
/** Token usage reported in SDK result messages. */
|
||||
export type BridgeUsage = {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
}
|
||||
|
||||
/** system-init, compact_boundary, status, api_retry, local_command_output messages. */
|
||||
export type BridgeSystemMessage = {
|
||||
type: 'system'
|
||||
subtype?: string
|
||||
session_id?: string
|
||||
content?: string
|
||||
status?: string
|
||||
compact_result?: string
|
||||
compact_error?: string
|
||||
model?: string
|
||||
uuid?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Turn completion message: success with usage, or error with stop_reason. */
|
||||
export type BridgeResultMessage = {
|
||||
type: 'result'
|
||||
subtype?: string
|
||||
usage?: BridgeUsage
|
||||
modelUsage?: Record<string, { contextWindow?: number }>
|
||||
total_cost_usd?: number
|
||||
is_error?: boolean
|
||||
stop_reason?: string | null
|
||||
result?: string
|
||||
errors?: string[]
|
||||
duration_ms?: number
|
||||
duration_api_ms?: number
|
||||
num_turns?: number
|
||||
permission_denials?: unknown[]
|
||||
session_id?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Full assistant response message after the turn completes. */
|
||||
export type BridgeAssistantMessage = {
|
||||
type: 'assistant'
|
||||
message?: {
|
||||
role?: string
|
||||
id?: string
|
||||
model?: string
|
||||
content?: string | Array<Record<string, unknown>>
|
||||
usage?: BridgeUsage | Record<string, unknown>
|
||||
stop_reason?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
parent_tool_use_id?: string | null
|
||||
uuid?: string
|
||||
session_id?: string
|
||||
error?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Real-time streaming event (aka partial_assistant in the SDK schema). */
|
||||
export type BridgeStreamEventMessage = {
|
||||
type: 'stream_event'
|
||||
event?: { type?: string; [key: string]: unknown }
|
||||
message?: Record<string, unknown>
|
||||
parent_tool_use_id?: string | null
|
||||
session_id?: string
|
||||
uuid?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** User prompt message (may include tool_use_result from prior turns). */
|
||||
export type BridgeUserMessage = {
|
||||
type: 'user'
|
||||
message?: Record<string, unknown>
|
||||
uuid?: string
|
||||
isReplay?: boolean
|
||||
isMeta?: boolean
|
||||
timestamp?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Subagent or hook progress notification (internal, not an SDK message member). */
|
||||
export type BridgeProgressMessage = {
|
||||
type: 'progress'
|
||||
data?: {
|
||||
type?: string
|
||||
message?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Summary of tool calls made during a turn. */
|
||||
export type BridgeToolUseSummaryMessage = {
|
||||
type: 'tool_use_summary'
|
||||
summary?: string
|
||||
preceding_tool_use_ids?: string[]
|
||||
uuid?: string
|
||||
session_id?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** File attachment metadata (internal, not an SDK message member). */
|
||||
export type BridgeAttachmentMessage = {
|
||||
type: 'attachment'
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Compaction boundary marker (type is 'compact_boundary', not 'system'). */
|
||||
export type BridgeCompactBoundaryMessage = {
|
||||
type: 'compact_boundary'
|
||||
compact_metadata?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** ACP bridge local discriminated union — covers all message shapes consumed by the forwarding loop. */
|
||||
export type BridgeSDKMessage =
|
||||
| BridgeSystemMessage
|
||||
| BridgeResultMessage
|
||||
| BridgeAssistantMessage
|
||||
| BridgeStreamEventMessage
|
||||
| BridgeUserMessage
|
||||
| BridgeProgressMessage
|
||||
| BridgeToolUseSummaryMessage
|
||||
| BridgeAttachmentMessage
|
||||
| BridgeCompactBoundaryMessage
|
||||
|
||||
// ── Tool info / edit response shapes ──────────────────────────────
|
||||
|
||||
/** Sanitised tool metadata sent to ACP client for tool_call notifications. */
|
||||
export interface ToolInfo {
|
||||
title: string
|
||||
kind: ToolKind
|
||||
content: ToolCallContent[]
|
||||
locations?: ToolCallLocation[]
|
||||
}
|
||||
|
||||
/** Context lines and diff metadata for one hunk of an Edit tool response. */
|
||||
export interface EditToolResponseHunk {
|
||||
oldStart: number
|
||||
oldLines: number
|
||||
newStart: number
|
||||
newLines: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
/** Result block for Edit/Write tool responses containing hunks and optional file stats. */
|
||||
export interface EditToolResponse {
|
||||
filePath?: string
|
||||
structuredPatch?: EditToolResponseHunk[]
|
||||
}
|
||||
Reference in New Issue
Block a user