This document evaluates DeepChat's ACP implementation against the official protocol docs:
- https://agentclientprotocol.com/protocol/prompt-turn
- https://agentclientprotocol.com/protocol/content
- https://agentclientprotocol.com/protocol/tool-calls
- https://agentclientprotocol.com/protocol/file-system
- https://agentclientprotocol.com/protocol/terminals
- https://agentclientprotocol.com/protocol/agent-plan
- https://agentclientprotocol.com/protocol/session-modes
- https://agentclientprotocol.com/protocol/slash-commands
The goal is to focus on what matters for a reliable ACP coding agent, mark optional items, and drop non-protocol or low-value tasks.
- Core prompt turn flow (
session/prompt,session/update,session/cancel, stop reasons) works. - Tool calls + permission requests work; tool output content formatting is only partial.
- Content mapping covers text/image/resource links; audio is degraded to text; annotations are absent.
- No ACP file-system or terminal tool handlers.
- Capability declaration during initialization is incomplete.
- Plans are flattened to reasoning text; session modes and slash commands are not wired.
- Must: Required for a functioning ACP coding agent.
- Should: Strongly recommended; improves fidelity to the spec.
- Optional: Nice to have or UX-only; not required for protocol correctness.
| Area | Feature | Status | Priority | Notes |
|---|---|---|---|---|
| Prompt turn | session/prompt, session/update, session/cancel, stop reasons |
✅ Implemented | Must | Keep parity with spec stop reasons. |
| Tool calls | Lifecycle + streaming + permission request | ✅ Implemented | Must | Ensure tool output content blocks are mapped. |
| Tool calls | Tool output as structured content blocks | Should | Map tool_response / tool_error blocks. |
|
| Content | Text, image, resource link | ✅ Implemented | Must | Verify resource URIs are preserved. |
| Content | input_resource (embedded) |
Should | Needed for inline context. | |
| Content | Audio blocks | Optional | Implement only if audio upload matters. | |
| Content | Annotations | ❌ Missing | Optional | Metadata only; low risk to defer. |
| File system | fs/read_text_file, fs/write_text_file |
❌ Missing | Must | Core to coding agent workflows. |
| Terminals | terminal/create/output/wait_for_exit/kill/release |
❌ Missing | Must | Enables command execution + logs. |
| Capabilities | Client capability declaration | Must | Advertise fs/terminal/content/modes. | |
| Agent plan | Structured plan updates |
Should | Use entries with status/priority. |
|
| Session modes | Mode definition + session/set_mode + current_mode_update |
❌ Missing | Should | Optional per spec but valuable. |
| Slash commands | available_commands_update + UI |
❌ Missing | Optional | UX feature; not required for baseline. |
-
Client capabilities
- Declare
fs.read_text_file,fs.write_text_file,terminal, content types (text/image/resource/audio if supported),modes,slash_commandsduring initialization. - Reflect UI/setting toggles so agents can adapt.
- Declare
-
File system tools
- Implement
fs/read_text_fileandfs/write_text_filehandlers with workspace boundary checks and path validation. - Surface permission prompts using existing permission flow.
- Map responses to ACP
content_blockstructure (text content for reads, empty response for writes).
- Implement
-
Terminal tools
- Implement
terminal/create,terminal/output,terminal/wait_for_exit,terminal/kill,terminal/release. - Use PTY (
node-pty) with output buffering + truncation logic that respects byte limits and UTF-8 boundaries. - Emit output via tool responses (no proprietary streaming format).
- Implement
-
Tool output content mapping
- Support
tool_responseandtool_errorcontent blocks; ensure IDs line up withtool_call_id. - Preserve
is_error,text, andcontentfields so agents can parse results.
- Support
-
Structured agent plans
- Parse
plansession updates into entries{content, priority, status}instead of flattening to reasoning text. - Minimal UI: list with status and priority badges; live replace on each plan update.
- Parse
-
Session modes
- Track available modes from session initialization.
- Handle
session/set_moderequests andcurrent_mode_updatenotifications. - UI affordance can be basic (selector + current mode indicator); advanced gating is optional.
-
Embedded resources
- Accept and render
input_resourceblocks (embedded content payload) in prompt turns. - Maintain
uri+mime_type; avoid silently dropping large payloads—enforce limits and warn.
- Accept and render
- Audio blocks: Implement
input_audioonly if audio upload is a product goal; otherwise keep text fallback. - Annotations: Pass through
annotationson content blocks when present; do not block other work. - Slash commands: Support
available_commands_updateplus minimal/autocomplete. Safe to defer. - UI polish: Live terminal widgets inside tool calls, command palette modal, diff visualization. Non-protocol; optional.
- Items not in ACP spec (e.g., bespoke diff tooling, "mode-specific UI" requirements) are out of scope for protocol compliance and can be treated as optional UX experiments.
- Capabilities: send accurate
clientCapabilitiesduring initialization. - File system: implement handlers + permission checks + workspace sandboxing.
- Terminals: PTY manager, output buffering, kill/release handling.
- Tool outputs: ensure structured
tool_response/tool_errormapping. - Plans + embedded resources: structured plan updates; input_resource handling.
- Session modes: mode tracking + set/update wiring.
- Optional: audio, annotations, slash commands, UI polish.
- Unit: path validation + workspace guard; terminal lifecycle (create/output/wait/kill/release); plan parsing; mode state transitions.
- Integration: prompt → file read/write → tool output blocks; terminal command with incremental output; mode switch affecting tool access.
- E2E (as available): agent edits a file, runs a command, updates plan, and reflects mode changes without crashes.
- Enforce workspace boundaries and normalize paths before fs operations.
- Clamp terminal output size and sanitize ANSI where rendered.
- Use existing permission prompts for fs/terminal; include "allow once/always" behavior if already supported.
┌─────────────────────────────────────────────────────────────────────────────┐
│ DeepChat (Client) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ AcpProvider │───>│ AcpSessionManager│───>│ AcpProcessManager │ │
│ │ │ │ │ │ │ │
│ │ - coreStream() │ │ - sessions[] │ │ - handles[] │ │
│ │ - permissions │ │ - persistence │ │ - spawn process │ │
│ └────────┬────────┘ └────────┬─────────┘ │ - createClientProxy() │ │
│ │ │ └───────────┬────────────┘ │
│ │ │ │ │
│ v v v │
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ │
│ │AcpContentMapper │ │AcpSessionPersist │ │ ClientSideConnection │ │
│ │ │ │ │ │ (SDK provided) │ │
│ │ - map events │ │ - SQLite storage │ └───────────┬────────────┘ │
│ └─────────────────┘ └──────────────────┘ │ │
│ │ JSON-RPC │
└─────────────────────────────────────────────────────────────│───────────────┘
│
v
┌───────────────────────────────────┐
│ ACP Agent Process │
│ (claude-code-acp, kimi-cli, etc) │
└───────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────────┐
│ DeepChat (Client) │
├──────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ AcpProvider │───>│ AcpSessionManager│───>│ AcpProcessManager │ │
│ │ │ │ │ │ │ │
│ │ - coreStream() │ │ + modeState │ │ + createClientProxy() ──────>│─┼─┐
│ │ - permissions │ │ + availableModes │ │ returns Client interface │ │ │
│ │ + setMode() │ │ │ │ │ │ │
│ └────────┬────────┘ └────────┬─────────┘ └──────────────────────────────┘ │ │
│ │ │ │ │
│ v v ┌──────────────────────────────┐ │ │
│ ┌─────────────────┐ ┌──────────────────┐ │ NEW: Client Impl │ │ │
│ │AcpContentMapper │ │ + AcpModeManager │ ├──────────────────────────────┤ │ │
│ │ │ │ │ │ ┌────────────────────────┐ │<┘ │
│ │ + plan entries │ │ - track modes │ │ │ AcpFsHandler │ │ │
│ │ + current_mode │ │ - switch modes │ │ │ - readTextFile() │ │ │
│ └─────────────────┘ └──────────────────┘ │ │ - writeTextFile() │ │ │
│ │ │ - workspace guard │ │ │
│ │ └────────────────────────┘ │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ AcpTerminalManager │ │ │
│ │ │ - createTerminal() │ │ │
│ │ │ - terminalOutput() │ │ │
│ │ │ - waitForExit() │ │ │
│ │ │ - kill / release │ │ │
│ │ │ - PTY management │ │ │
│ │ └────────────────────────┘ │ │
│ │ ┌────────────────────────┐ │ │
│ │ │ AcpCapabilities │ │ │
│ │ │ - fs flags │ │ │
│ │ │ - terminal flag │ │ │
│ │ │ - prompt types │ │ │
│ │ └────────────────────────┘ │ │
│ └──────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────────────────┘
│
│ JSON-RPC over stdio
v
┌───────────────────────────────────┐
│ ACP Agent Process │
│ │
│ Agent calls: │
│ - readTextFile(path) │
│ - writeTextFile(path, content) │
│ - createTerminal(cmd, args) │
│ - terminalOutput(id) │
│ - waitForTerminalExit(id) │
│ - killTerminal(id) │
│ - releaseTerminal(id) │
└───────────────────────────────────┘
src/main/presenter/llmProviderPresenter/agent/
├── acpProcessManager.ts # MODIFY: update createClientProxy()
├── acpSessionManager.ts # MODIFY: add mode tracking
├── acpContentMapper.ts # MODIFY: structured plan, mode updates
├── acpCapabilities.ts # NEW: capability constants
├── acpFsHandler.ts # NEW: file system operations
├── acpTerminalManager.ts # NEW: terminal lifecycle
└── acpModeManager.ts # NEW: session mode state (optional)
src/shared/presenter/
└── acpTypes.ts # MODIFY: add fs/terminal/mode types if needed
Purpose: Centralize client capability flags for initialization.
// src/main/presenter/llmProviderPresenter/agent/acpCapabilities.ts
import type * as schema from '@agentclientprotocol/sdk/dist/schema.js'
export interface AcpCapabilityOptions {
enableFs?: boolean
enableTerminal?: boolean
enableModes?: boolean
enableSlashCommands?: boolean
}
export function buildClientCapabilities(
options: AcpCapabilityOptions = {}
): schema.ClientCapabilities {
const caps: schema.ClientCapabilities = {
prompt: {
text: true,
image: true,
// audio: false, // enable when supported
embeddedContext: true
}
}
if (options.enableFs !== false) {
caps.fs = {
readTextFile: true,
writeTextFile: true
}
}
if (options.enableTerminal !== false) {
caps.terminal = true
}
if (options.enableModes) {
caps.modes = true
}
if (options.enableSlashCommands) {
caps.slashCommands = true
}
return caps
}Purpose: Handle fs/read_text_file and fs/write_text_file requests from agents.
// src/main/presenter/llmProviderPresenter/agent/acpFsHandler.ts
import * as fs from 'fs/promises'
import * as path from 'path'
import { RequestError } from '@agentclientprotocol/sdk'
import type * as schema from '@agentclientprotocol/sdk/dist/schema.js'
export interface FsHandlerOptions {
/** Session's working directory (workspace root). Null = allow all. */
workspaceRoot: string | null
/** Maximum file size in bytes to read (default: 10MB) */
maxReadSize?: number
}
export class AcpFsHandler {
private readonly workspaceRoot: string | null
private readonly maxReadSize: number
constructor(options: FsHandlerOptions) {
this.workspaceRoot = options.workspaceRoot
? path.resolve(options.workspaceRoot)
: null
this.maxReadSize = options.maxReadSize ?? 10 * 1024 * 1024
}
/**
* Validate that the path is within the workspace boundary.
* Throws RequestError if path escapes workspace.
*/
private validatePath(filePath: string): string {
const resolved = path.resolve(filePath)
if (this.workspaceRoot) {
const relative = path.relative(this.workspaceRoot, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw RequestError.invalidParams(
{ path: filePath },
`Path escapes workspace: ${filePath}`
)
}
}
return resolved
}
async readTextFile(
params: schema.ReadTextFileRequest
): Promise<schema.ReadTextFileResponse> {
const filePath = this.validatePath(params.path)
try {
const stat = await fs.stat(filePath)
if (stat.size > this.maxReadSize) {
throw RequestError.invalidParams(
{ path: params.path, size: stat.size },
`File too large: ${stat.size} bytes exceeds limit`
)
}
const content = await fs.readFile(filePath, 'utf-8')
const lines = content.split('\n')
// Handle optional line/limit parameters
const startLine = params.line ?? 1
const limit = params.limit ?? lines.length
const startIndex = Math.max(0, startLine - 1)
const endIndex = startIndex + limit
const selectedLines = lines.slice(startIndex, endIndex)
return { content: selectedLines.join('\n') }
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw RequestError.resourceNotFound(params.path)
}
throw error
}
}
async writeTextFile(
params: schema.WriteTextFileRequest
): Promise<schema.WriteTextFileResponse> {
const filePath = this.validatePath(params.path)
// Ensure parent directory exists
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(filePath, params.content, 'utf-8')
return {}
}
}Purpose: Manage PTY-based terminals for agent command execution.
// src/main/presenter/llmProviderPresenter/agent/acpTerminalManager.ts
import * as pty from 'node-pty'
import { nanoid } from 'nanoid'
import { RequestError } from '@agentclientprotocol/sdk'
import type * as schema from '@agentclientprotocol/sdk/dist/schema.js'
interface TerminalState {
id: string
sessionId: string
ptyProcess: pty.IPty
outputBuffer: string
maxOutputBytes: number
truncated: boolean
exitStatus: { exitCode: number; signal?: string } | null
exitPromise: Promise<{ exitCode: number; signal?: string }>
exitResolve: (status: { exitCode: number; signal?: string }) => void
killed: boolean
released: boolean
}
export class AcpTerminalManager {
private readonly terminals = new Map<string, TerminalState>()
private readonly defaultMaxOutputBytes = 1024 * 1024 // 1MB
async createTerminal(
params: schema.CreateTerminalRequest
): Promise<schema.CreateTerminalResponse> {
const id = `term_${nanoid(12)}`
const maxOutputBytes = params.maxOutputBytes ?? this.defaultMaxOutputBytes
let exitResolve!: (status: { exitCode: number; signal?: string }) => void
const exitPromise = new Promise<{ exitCode: number; signal?: string }>(
(resolve) => { exitResolve = resolve }
)
const shell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash'
const shellArgs = process.platform === 'win32'
? ['-NoLogo', '-Command', params.command, ...(params.args ?? [])]
: ['-c', [params.command, ...(params.args ?? [])].join(' ')]
const ptyProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: params.cwd ?? process.cwd(),
env: { ...process.env, ...params.env } as Record<string, string>
})
const state: TerminalState = {
id,
sessionId: params.sessionId,
ptyProcess,
outputBuffer: '',
maxOutputBytes,
truncated: false,
exitStatus: null,
exitPromise,
exitResolve,
killed: false,
released: false
}
// Collect output
ptyProcess.onData((data) => {
if (state.released) return
const currentBytes = Buffer.byteLength(state.outputBuffer, 'utf-8')
const newBytes = Buffer.byteLength(data, 'utf-8')
if (currentBytes + newBytes <= state.maxOutputBytes) {
state.outputBuffer += data
} else {
// Truncate at UTF-8 boundary
const remaining = state.maxOutputBytes - currentBytes
if (remaining > 0) {
state.outputBuffer += this.truncateAtCharBoundary(data, remaining)
}
state.truncated = true
}
})
// Handle exit
ptyProcess.onExit(({ exitCode, signal }) => {
state.exitStatus = { exitCode, signal: signal !== undefined ? String(signal) : undefined }
exitResolve(state.exitStatus)
})
this.terminals.set(id, state)
return { terminalId: id }
}
async terminalOutput(
params: schema.TerminalOutputRequest
): Promise<schema.TerminalOutputResponse> {
const state = this.getTerminal(params.terminalId)
return {
output: state.outputBuffer,
truncated: state.truncated,
exitStatus: state.exitStatus ?? undefined
}
}
async waitForTerminalExit(
params: schema.WaitForTerminalExitRequest
): Promise<schema.WaitForTerminalExitResponse> {
const state = this.getTerminal(params.terminalId)
const status = await state.exitPromise
return status
}
async killTerminal(
params: schema.KillTerminalCommandRequest
): Promise<schema.KillTerminalResponse> {
const state = this.getTerminal(params.terminalId)
if (!state.killed && !state.exitStatus) {
state.ptyProcess.kill()
state.killed = true
}
return {}
}
async releaseTerminal(
params: schema.ReleaseTerminalRequest
): Promise<schema.ReleaseTerminalResponse> {
const state = this.terminals.get(params.terminalId)
if (!state) return {} // Already released, idempotent
if (!state.killed && !state.exitStatus) {
state.ptyProcess.kill()
}
state.released = true
this.terminals.delete(params.terminalId)
return {}
}
/** Clean up all terminals for a session */
async releaseSessionTerminals(sessionId: string): Promise<void> {
const toRelease = Array.from(this.terminals.values())
.filter((t) => t.sessionId === sessionId)
.map((t) => t.id)
await Promise.all(
toRelease.map((id) => this.releaseTerminal({ terminalId: id }))
)
}
/** Shutdown all terminals */
async shutdown(): Promise<void> {
await Promise.all(
Array.from(this.terminals.keys()).map((id) =>
this.releaseTerminal({ terminalId: id })
)
)
}
private getTerminal(id: string): TerminalState {
const state = this.terminals.get(id)
if (!state) {
throw RequestError.resourceNotFound(id)
}
return state
}
private truncateAtCharBoundary(str: string, maxBytes: number): string {
const buf = Buffer.from(str, 'utf-8')
if (buf.length <= maxBytes) return str
// Find valid UTF-8 boundary
let truncated = buf.slice(0, maxBytes)
while (truncated.length > 0) {
try {
return truncated.toString('utf-8')
} catch {
truncated = truncated.slice(0, -1)
}
}
return ''
}
}File: src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts
Current:
private createClientProxy(): Client {
return {
requestPermission: async (params) => this.dispatchPermissionRequest(params),
sessionUpdate: async (notification) => {
this.dispatchSessionUpdate(notification)
}
}
}Target:
private createClientProxy(
fsHandler: AcpFsHandler,
terminalManager: AcpTerminalManager
): Client {
return {
// Existing
requestPermission: async (params) => this.dispatchPermissionRequest(params),
sessionUpdate: async (notification) => this.dispatchSessionUpdate(notification),
// NEW: File system
readTextFile: async (params) => fsHandler.readTextFile(params),
writeTextFile: async (params) => fsHandler.writeTextFile(params),
// NEW: Terminals
createTerminal: async (params) => terminalManager.createTerminal(params),
terminalOutput: async (params) => terminalManager.terminalOutput(params),
waitForTerminalExit: async (params) => terminalManager.waitForTerminalExit(params),
killTerminal: async (params) => terminalManager.killTerminal(params),
releaseTerminal: async (params) => terminalManager.releaseTerminal(params)
}
}File: src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts
Current:
const initPromise = connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {}, // <-- Empty!
clientInfo: { name: 'DeepChat', version: app.getVersion() }
})Target:
import { buildClientCapabilities } from './acpCapabilities'
const initPromise = connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: buildClientCapabilities({
enableFs: true,
enableTerminal: true,
enableModes: true
}),
clientInfo: { name: 'DeepChat', version: app.getVersion() }
})┌──────────┐ ┌────────────────┐ ┌───────────────┐
│ Agent │ │ DeepChat │ │ File System │
│ │ │ (AcpFsHandler)│ │ │
└────┬─────┘ └───────┬────────┘ └───────┬───────┘
│ │ │
│ fs/read_text_file │ │
│ {path, line?, limit?} │ │
│───────────────────────>│ │
│ │ │
│ │ validatePath(path) │
│ │ ─────────────────────> │
│ │ │
│ │ (check workspace bounds) │
│ │ <───────────────────── │
│ │ │
│ │ fs.readFile(path) │
│ │──────────────────────────>│
│ │ │
│ │ <file content> │
│ │<──────────────────────────│
│ │ │
│ │ slice lines if needed │
│ │ ─────────────────────> │
│ │ │
│ { content: string } │ │
│<───────────────────────│ │
│ │ │
┌──────────┐ ┌─────────────────────┐ ┌─────────┐
│ Agent │ │AcpTerminalManager │ │ PTY │
└────┬─────┘ └──────────┬──────────┘ └────┬────┘
│ │ │
│ terminal/create │ │
│ {cmd, args, cwd} │ │
│───────────────────────>│ │
│ │ pty.spawn(cmd) │
│ │──────────────────────>│
│ │ │
│ { terminalId } │ <pid> │
│<───────────────────────│<──────────────────────│
│ │ │
│ │ onData(chunk) │
│ │<──────────────────────│
│ │ buffer += chunk │
│ │ │
│ terminal/output │ │
│ { terminalId } │ │
│───────────────────────>│ │
│ │ │
│ { output, truncated, │ │
│ exitStatus? } │ │
│<───────────────────────│ │
│ │ │
│ │ onExit(code, signal) │
│ │<──────────────────────│
│ │ │
│ terminal/wait_for_exit│ │
│───────────────────────>│ │
│ │ │
│ { exitCode, signal } │ │
│<───────────────────────│ │
│ │ │
│ terminal/release │ │
│───────────────────────>│ │
│ │ pty.kill() │
│ │──────────────────────>│
│ │ cleanup state │
│ { } │ │
│<───────────────────────│ │
┌────────────┐ ┌─────────────┐ ┌──────────────────┐ ┌──────────┐
│ Renderer │ │ AcpProvider │ │ AcpSessionManager│ │ Agent │
└─────┬──────┘ └──────┬──────┘ └────────┬─────────┘ └────┬─────┘
│ │ │ │
│ setMode(modeId) │ │ │
│──────────────────>│ │ │
│ │ │ │
│ │ session/set_mode │ │
│ │ { sessionId, modeId } │
│ │─────────────────────────────────────────>│
│ │ │ │
│ │ │ (agent updates │
│ │ │ internal mode) │
│ │ │ │
│ │ { } │ │
│ │<─────────────────────────────────────────│
│ │ │ │
│ │ updateModeState │ │
│ │────────────────────>│ │
│ │ │ │
│ modeChanged event│ │ │
│<──────────────────│ │ │
│ │ │ │
┌──────────┐ ┌─────────────────────┐ ┌─────────────┐ ┌────────────┐
│ Agent │ │ AcpProcessManager │ │ AcpProvider │ │ Renderer │
└────┬─────┘ └──────────┬──────────┘ └──────┬──────┘ └─────┬──────┘
│ │ │ │
│ session/update │ │ │
│ { sessionUpdate: │ │ │
│ "current_mode_update", │ │
│ modeId } │ │ │
│─────────────────────>│ │ │
│ │ │ │
│ │ dispatchSessionUpdate│ │
│ │──────────────────────>│ │
│ │ │ │
│ │ │ map to event │
│ │ │ ─────────────> │
│ │ │ │
│ │ │ modeChanged │
│ │ │ event │
│ │ │─────────────────>│
│ │ │ │
│ │ │ │ Update
│ │ │ │ mode UI
Current behavior: Plan entries flattened to text.
Target behavior: Emit structured plan data.
// In acpContentMapper.ts
private handlePlanUpdate(
update: Extract<schema.SessionNotification['update'], { sessionUpdate: 'plan' }>,
payload: MappedContent
) {
const entries = update.entries ?? []
// Emit structured plan event (new event type)
payload.events.push({
type: 'plan',
entries: entries.map((e) => ({
content: e.content,
priority: e.priority,
status: e.status
}))
})
// Also emit as reasoning for backwards compatibility
if (entries.length > 0) {
const summary = entries.map((e) => `[${e.status}] ${e.content}`).join('\n')
payload.events.push(createStreamEvent.reasoning(`Plan:\n${summary}`))
}
}// Add to switch in map()
case 'current_mode_update':
this.handleModeUpdate(update, payload)
break
// New method
private handleModeUpdate(
update: Extract<schema.SessionNotification['update'], { sessionUpdate: 'current_mode_update' }>,
payload: MappedContent
) {
payload.events.push({
type: 'mode_change',
modeId: update.modeId
})
}┌────────────────────────────────────────────────────────────┐
│ Agent Plan [─][×] │
├────────────────────────────────────────────────────────────┤
│ │
│ ● Analyze project structure [done] │
│ ◐ Implement file handler [in progress]
│ ○ Add unit tests [pending] │
│ ○ Update documentation [pending] │
│ │
│ Progress: ████████░░░░░░░░ 25% │
│ │
└────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Chat Input Area │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ [Mode: Code ▼] Type your message... [Send] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ └─> ┌────────────────────┐ │
│ │ ○ Ask │ │
│ │ ● Code │ <-- current │
│ │ ○ Architect │ │
│ └────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ 🔧 Tool: execute_command [running] │
├────────────────────────────────────────────────────────────────┤
│ Command: npm test │
├────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ $ npm test │ │
│ │ │ │
│ │ > deepchat@0.5.1 test │ │
│ │ > vitest run │ │
│ │ │ │
│ │ ✓ acpFsHandler.test.ts (3 tests) 45ms │ │
│ │ ✓ acpTerminalManager.test.ts (5 tests) 120ms │ │
│ │ │ │
│ │ Test Files 2 passed (2) │ │
│ │ Tests 8 passed (8) │ │
│ │ │ │
│ │ Process exited with code 0 │ │
│ └────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
| Error Scenario | Response | User Feedback |
|---|---|---|
| Path escapes workspace | RequestError.invalidParams |
"File access denied: path outside workspace" |
| File not found | RequestError.resourceNotFound |
"File not found: {path}" |
| File too large | RequestError.invalidParams |
"File too large ({size} bytes)" |
| Terminal not found | RequestError.resourceNotFound |
"Terminal {id} not found" |
| Terminal already released | Return {} (idempotent) |
None |
| Write permission denied | OS error passthrough | "Permission denied: {path}" |
| PTY spawn failed | Error with details | "Failed to start terminal: {reason}" |
// test/main/presenter/llmProviderPresenter/acpFsHandler.test.ts
describe('AcpFsHandler', () => {
describe('validatePath', () => {
it('allows paths within workspace')
it('rejects paths escaping workspace with ..')
it('rejects absolute paths outside workspace')
it('allows any path when workspaceRoot is null')
})
describe('readTextFile', () => {
it('reads entire file when no line/limit specified')
it('respects line offset (1-based)')
it('respects limit parameter')
it('throws resourceNotFound for missing files')
it('throws invalidParams for files exceeding maxReadSize')
})
describe('writeTextFile', () => {
it('writes content to new file')
it('overwrites existing file')
it('creates parent directories if missing')
it('validates path before writing')
})
})// test/main/presenter/llmProviderPresenter/acpTerminalManager.test.ts
describe('AcpTerminalManager', () => {
describe('createTerminal', () => {
it('spawns PTY process and returns terminalId')
it('uses provided cwd and env')
it('buffers output up to maxOutputBytes')
it('sets truncated flag when exceeding limit')
})
describe('terminalOutput', () => {
it('returns current buffer without blocking')
it('includes exitStatus when process has exited')
it('throws for unknown terminalId')
})
describe('waitForTerminalExit', () => {
it('blocks until process exits')
it('returns exitCode and signal')
})
describe('killTerminal', () => {
it('kills running process')
it('is idempotent for already-killed terminals')
})
describe('releaseTerminal', () => {
it('kills process if still running')
it('removes terminal from manager')
it('is idempotent for already-released terminals')
})
})describe('ACP Integration', () => {
it('agent reads file, modifies content, writes back', async () => {
// 1. Start agent process
// 2. Create session
// 3. Send prompt requesting file modification
// 4. Verify agent calls readTextFile
// 5. Verify agent calls writeTextFile
// 6. Verify file was modified correctly
})
it('agent runs terminal command and observes output', async () => {
// 1. Send prompt requesting command execution
// 2. Verify createTerminal called
// 3. Verify terminalOutput returns expected data
// 4. Verify waitForTerminalExit returns exit code
// 5. Verify releaseTerminal cleans up
})
it('session mode switch affects agent behavior', async () => {
// 1. Create session, note initial mode
// 2. Call setSessionMode
// 3. Verify agent acknowledges mode change
// 4. Verify subsequent behavior reflects new mode
})
})- Implement
AcpCapabilitiesmodule - Update
initialize()to send proper capabilities - Implement
AcpFsHandlerwith tests - Wire
readTextFile/writeTextFileintocreateClientProxy() - Test with claude-code-acp agent
- Implement
AcpTerminalManagerwith tests - Wire terminal methods into
createClientProxy() - Test with agents that use terminal commands
- Add terminal output display in tool call UI
- Update
AcpContentMapperfor structured plans - Add plan display UI component
- Implement mode tracking in session manager
- Wire
setSessionModethrough provider - Add mode selector UI
- Slash command support (optional)
- Audio content handling (optional)
- Improve error messages and user feedback
- Performance optimization (output buffering, etc.)
-
Workspace Resolution: Should workspace root come from session's
cwd, or should there be a separate config? Current plan: use session's workdir. -
Permission Integration: Should fs/terminal operations go through the existing permission flow, or trust the agent's own permission checks? Current plan: trust agent (it already requested permission before calling these).
-
Terminal Output Streaming: Should we stream terminal output to the UI in real-time, or only show on-demand? Current plan: buffer in manager, show in tool call block.
-
Mode Persistence: Should the current mode be persisted per conversation? Current plan: start with agent's default mode each session.
-
Capability Toggles: Should users be able to disable fs/terminal capabilities in settings? Current plan: enable all by default, consider settings later.