初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+372
View File
@@ -0,0 +1,372 @@
package acp
import (
"context"
"fmt"
"log/slog"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"github.com/chenhg5/cc-connect/core"
)
func init() {
core.RegisterAgent("acp", New)
}
// Agent runs an ACP (Agent Client Protocol) agent subprocess over stdio JSON-RPC.
type Agent struct {
workDir string
command string
args []string
staticEnv map[string]string
extraEnv []string
sessionEnv []string
authMethod string // optional, e.g. "cursor_login" for Cursor CLI (see authenticate RPC)
displayName string // optional, for doctor (default "ACP")
// mode is the pending permission mode to apply to new sessions.
// When set, StartSession applies it via session/set_mode right after
// session/new. Empty means "use whatever the agent selects by default".
mode string
// listUnsupported caches a negative result after we probe the agent
// for sessionCapabilities.list once. Eliminates spawn cost on
// subsequent `/ls` invocations against agents that don't implement
// session/list (e.g. some Copilot/OpenClaw builds).
listUnsupported atomic.Bool
// modesCache holds the latest `modes` block we observed via
// session/new or session/load. It's populated by the session
// handshake so that future PermissionModes() calls can reflect the
// actual modes this specific ACP agent offers (rather than a
// hard-coded fallback that may not match).
modesMu sync.RWMutex
modesCache []core.PermissionModeInfo
modesCurrent string
mu sync.RWMutex
}
// sessionCallbacks lets a running acpSession report what it learned
// during the handshake back to its parent Agent. The session is owned
// by cc-connect's engine (not the agent), so without this the agent
// would never see availableModes / capability advertisements.
type sessionCallbacks interface {
reportModes(block acpModesBlock)
reportListSupported(supported bool)
}
// Ensure *Agent satisfies sessionCallbacks at compile time.
var _ sessionCallbacks = (*Agent)(nil)
// New builds an acp agent from project options.
// Required: options["command"] — executable name or path for the ACP agent.
// Optional: options["args"], options["env"], options["auth_method"],
// options["display_name"], options["mode"].
func New(opts map[string]any) (core.Agent, error) {
workDir, _ := opts["work_dir"].(string)
if workDir == "" {
workDir = "."
}
cmdStr, _ := opts["command"].(string)
cmdStr = strings.TrimSpace(cmdStr)
if cmdStr == "" {
return nil, fmt.Errorf("acp: agent option \"command\" is required (path or name of the ACP agent binary)")
}
if _, err := exec.LookPath(cmdStr); err != nil {
return nil, fmt.Errorf("acp: command %q not found in PATH: %w", cmdStr, err)
}
args := parseStringSlice(opts["args"])
staticEnv := envMapFromOpts(opts)
extra := envPairsFromOpts(opts)
authMethod, _ := opts["auth_method"].(string)
authMethod = strings.TrimSpace(authMethod)
displayName, _ := opts["display_name"].(string)
displayName = strings.TrimSpace(displayName)
if displayName == "" {
displayName = "ACP"
}
mode, _ := opts["mode"].(string)
mode = strings.TrimSpace(mode)
return &Agent{
workDir: workDir,
command: cmdStr,
args: args,
staticEnv: staticEnv,
extraEnv: extra,
authMethod: authMethod,
displayName: displayName,
mode: mode,
}, nil
}
func envMapFromOpts(opts map[string]any) map[string]string {
raw, ok := opts["env"]
if !ok || raw == nil {
return nil
}
switch m := raw.(type) {
case map[string]string:
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = v
}
return out
case map[string]any:
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = fmt.Sprint(v)
}
return out
default:
return nil
}
}
func envPairsFromOpts(opts map[string]any) []string {
raw, ok := opts["env"]
if !ok || raw == nil {
return nil
}
switch m := raw.(type) {
case map[string]string:
var out []string
for k, v := range m {
out = append(out, k+"="+v)
}
return out
case map[string]any:
var out []string
for k, v := range m {
out = append(out, fmt.Sprintf("%s=%v", k, v))
}
return out
default:
return nil
}
}
func parseStringSlice(v any) []string {
switch x := v.(type) {
case nil:
return nil
case []string:
return append([]string(nil), x...)
case []any:
out := make([]string, 0, len(x))
for _, e := range x {
switch t := e.(type) {
case string:
out = append(out, t)
default:
out = append(out, fmt.Sprint(t))
}
}
return out
default:
return nil
}
}
func (a *Agent) Name() string { return "acp" }
func (a *Agent) SetWorkDir(dir string) {
a.mu.Lock()
a.workDir = dir
a.mu.Unlock()
slog.Info("acp: work_dir changed", "work_dir", dir)
}
func (a *Agent) GetWorkDir() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.workDir
}
func (a *Agent) WorkspaceAgentOptions() map[string]any {
a.mu.RLock()
defer a.mu.RUnlock()
opts := map[string]any{
"command": a.command,
}
if len(a.args) > 0 {
opts["args"] = append([]string(nil), a.args...)
}
if len(a.staticEnv) > 0 {
env := make(map[string]string, len(a.staticEnv))
for k, v := range a.staticEnv {
env[k] = v
}
opts["env"] = env
}
if a.authMethod != "" {
opts["auth_method"] = a.authMethod
}
if a.displayName != "" {
opts["display_name"] = a.displayName
}
return opts
}
func (a *Agent) SetSessionEnv(env []string) {
a.mu.Lock()
a.sessionEnv = env
a.mu.Unlock()
}
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
a.mu.RLock()
command := a.command
args := a.args
workDir := a.workDir
authMethod := a.authMethod
pendingMode := a.mode
extra := append([]string(nil), a.extraEnv...)
extra = append(extra, a.sessionEnv...)
a.mu.RUnlock()
return newACPSession(ctx, acpSessionConfig{
command: command,
args: args,
extraEnv: extra,
workDir: workDir,
resumeSessionID: sessionID,
authMethod: authMethod,
initialMode: pendingMode,
callbacks: a,
})
}
func (a *Agent) Stop() error { return nil }
// -- AgentDoctorInfo --
func (a *Agent) CLIBinaryName() string {
a.mu.RLock()
cmd := a.command
a.mu.RUnlock()
return filepath.Base(cmd)
}
func (a *Agent) CLIDisplayName() string {
a.mu.RLock()
n := a.displayName
a.mu.RUnlock()
if n == "" {
return "ACP"
}
return n
}
// -- ModeSwitcher --
//
// cc-connect's engine treats ModeSwitcher as the point of truth for
// both displaying `/mode` options and applying a mode selection. For
// the generic ACP adapter we keep the Key == ACP modeId so downstream
// `session/set_mode` calls don't need any translation.
// SetMode stores a permission mode to apply to future sessions started
// via StartSession. If the caller-provided mode matches a known cached
// mode id (case-insensitive), it is normalised to that id. Otherwise
// it is stored as-is — some IM users may configure modes before the
// agent has started any session and thus advertised its mode list.
func (a *Agent) SetMode(mode string) {
normalised := mode
if m := a.matchModeID(mode); m != "" {
normalised = m
}
a.mu.Lock()
a.mode = normalised
a.mu.Unlock()
slog.Info("acp: mode changed for future sessions", "mode", normalised)
}
// GetMode returns the mode cc-connect will treat as "current" when
// rendering the `/mode` picker or applying SetLiveMode.
//
// Precedence: the most recent explicit SetMode wins (that's the user's
// intent — `/mode plan` should immediately be reflected in the next
// `/mode` listing even before the session/set_mode RPC has returned).
// Only if no one has ever called SetMode for this Agent do we fall
// back to whatever the server advertised as currentModeId during the
// last handshake.
func (a *Agent) GetMode() string {
a.mu.RLock()
pending := a.mode
a.mu.RUnlock()
if pending != "" {
return pending
}
a.modesMu.RLock()
defer a.modesMu.RUnlock()
return a.modesCurrent
}
// PermissionModes returns the modes this ACP agent offers. The list is
// populated from the latest `modes.availableModes` observed on
// session/new or session/load; before the first successful handshake
// it returns an empty slice, and the engine will hide the mode picker.
//
// ACP doesn't send per-mode Desc/NameZh, so Description (if the server
// sent one) maps to Desc for both locales. IM-side translators are
// free to map well-known ids to localised strings later.
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
a.modesMu.RLock()
defer a.modesMu.RUnlock()
out := make([]core.PermissionModeInfo, len(a.modesCache))
copy(out, a.modesCache)
return out
}
// matchModeID returns the canonical mode id for a user-typed string
// (case-insensitive match on id or display name). Empty string if no
// match or if we haven't observed modes yet.
func (a *Agent) matchModeID(input string) string {
input = strings.TrimSpace(input)
if input == "" {
return ""
}
lower := strings.ToLower(input)
a.modesMu.RLock()
defer a.modesMu.RUnlock()
for _, m := range a.modesCache {
if strings.ToLower(m.Key) == lower || strings.ToLower(m.Name) == lower {
return m.Key
}
}
return ""
}
// -- sessionCallbacks impl --
func (a *Agent) reportModes(block acpModesBlock) {
infos := make([]core.PermissionModeInfo, 0, len(block.AvailableModes))
for _, m := range block.AvailableModes {
infos = append(infos, core.PermissionModeInfo{
Key: m.ID,
Name: m.Name,
NameZh: m.Name,
Desc: m.Description,
DescZh: m.Description,
})
}
a.modesMu.Lock()
a.modesCache = infos
a.modesCurrent = block.CurrentModeID
a.modesMu.Unlock()
}
func (a *Agent) reportListSupported(supported bool) {
if !supported {
a.listUnsupported.Store(true)
} else {
a.listUnsupported.Store(false)
}
}
+72
View File
@@ -0,0 +1,72 @@
package acp
import (
"testing"
"github.com/chenhg5/cc-connect/core"
)
func TestNew_DisplayNameDefault(t *testing.T) {
a, err := New(map[string]any{"command": "true"})
if err != nil {
t.Fatal(err)
}
agent := a.(*Agent)
if got := agent.CLIDisplayName(); got != "ACP" {
t.Fatalf("CLIDisplayName = %q, want ACP", got)
}
}
func TestNew_DisplayNameCustom(t *testing.T) {
a, err := New(map[string]any{
"command": "true",
"display_name": "Copilot ACP",
})
if err != nil {
t.Fatal(err)
}
agent := a.(*Agent)
if got := agent.CLIDisplayName(); got != "Copilot ACP" {
t.Fatalf("CLIDisplayName = %q, want Copilot ACP", got)
}
}
func TestWorkspaceAgentOptions(t *testing.T) {
a, err := New(map[string]any{
"command": "true",
"args": []any{"--acp", "--stdio"},
"env": map[string]any{"FOO": "bar", "COPILOT_VALUE": "a=b"},
"auth_method": "cursor_login",
"display_name": "Copilot ACP",
})
if err != nil {
t.Fatal(err)
}
agent := a.(*Agent)
agent.SetSessionEnv([]string{"SESSION_ONLY=1"})
snapshotter, ok := a.(core.WorkspaceAgentOptionSnapshotter)
if !ok {
t.Fatalf("agent does not implement WorkspaceAgentOptionSnapshotter")
}
opts := snapshotter.WorkspaceAgentOptions()
if got, _ := opts["command"].(string); got != "true" {
t.Fatalf("command = %q, want true", got)
}
gotArgs, _ := opts["args"].([]string)
if len(gotArgs) != 2 || gotArgs[0] != "--acp" || gotArgs[1] != "--stdio" {
t.Fatalf("args = %#v, want [--acp --stdio]", gotArgs)
}
gotEnv, _ := opts["env"].(map[string]string)
if len(gotEnv) != 2 || gotEnv["FOO"] != "bar" || gotEnv["COPILOT_VALUE"] != "a=b" {
t.Fatalf("env = %#v, want config env only", gotEnv)
}
if got, _ := opts["auth_method"].(string); got != "cursor_login" {
t.Fatalf("auth_method = %q, want cursor_login", got)
}
if got, _ := opts["display_name"].(string); got != "Copilot ACP" {
t.Fatalf("display_name = %q, want Copilot ACP", got)
}
}
+47
View File
@@ -0,0 +1,47 @@
package acp
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
// Exercises real Cursor CLI "agent acp" when installed (~/.local/bin/agent).
// Requires prior `agent login` (or CURSOR_API_KEY / CURSOR_AUTH_TOKEN). Skips if binary missing.
func TestCursorCLI_ACPHandshake(t *testing.T) {
if testing.Short() {
t.Skip("short mode")
}
if os.Getenv("CI") != "" {
t.Skip("skipping real Cursor CLI ACP handshake in CI (requires local agent and login)")
}
home, err := os.UserHomeDir()
if err != nil {
t.Skip("home dir:", err)
}
agentBin := filepath.Join(home, ".local/bin/agent")
if _, err := os.Stat(agentBin); err != nil {
t.Skip("Cursor agent not at", agentBin)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
s, err := newACPSession(ctx, acpSessionConfig{
command: agentBin,
args: []string{"acp"},
workDir: t.TempDir(),
authMethod: "cursor_login",
})
if err != nil {
t.Fatalf("handshake failed (is `agent login` done?): %v", err)
}
defer func() { _ = s.Close() }()
if s.CurrentSessionID() == "" {
t.Fatal("empty ACP session id after handshake")
}
t.Logf("ACP session id: %s", s.CurrentSessionID())
}
+266
View File
@@ -0,0 +1,266 @@
package acp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/chenhg5/cc-connect/core"
)
// listSessionsProbeTimeout bounds how long we wait for a one-shot
// `session/list` round-trip before giving up. Keep this short — the
// whole point of the probe is that it's quick; if the ACP agent is
// slow we'd rather return nothing than block `/ls` in IM.
var listSessionsProbeTimeout = 15 * time.Second
// acpModeInfo mirrors the ACP `modes.availableModes[]` shape sent by
// servers like `devin acp`, Cursor Agent, Copilot CLI, etc.
type acpModeInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// acpModesBlock mirrors the `modes` object returned inside `session/new`
// and `session/load` responses.
type acpModesBlock struct {
CurrentModeID string `json:"currentModeId"`
AvailableModes []acpModeInfo `json:"availableModes"`
}
// acpInitializeResult is the subset of `initialize` fields this package
// cares about. Additional vendor metadata is ignored.
type acpInitializeResult struct {
ProtocolVersion int `json:"protocolVersion"`
AgentCapabilities struct {
LoadSession bool `json:"loadSession"`
SessionCapabilities struct {
// ACP advertises capabilities as objects (possibly empty);
// treat "field present" as "supported" regardless of contents.
List json.RawMessage `json:"list,omitempty"`
} `json:"sessionCapabilities"`
} `json:"agentCapabilities"`
}
// acpSessionListResult mirrors a `session/list` response.
type acpSessionListResult struct {
Sessions []acpSessionListEntry `json:"sessions"`
}
type acpSessionListEntry struct {
SessionID string `json:"sessionId"`
Cwd string `json:"cwd"`
Title string `json:"title,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
// probeSpawn launches `<cmd> <args...>`, sets up a JSON-RPC transport
// and starts its readLoop. The caller owns the returned `teardown`
// func and must invoke it to reap the child process.
func (a *Agent) probeSpawn(ctx context.Context, cwd string) (*transport, *bytes.Buffer, func(), error) {
cmd := exec.CommandContext(ctx, a.command, a.args...)
cmd.Dir = cwd
cmd.Env = core.MergeEnv(os.Environ(), a.extraEnv)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("acp: probe stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("acp: probe stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = io.MultiWriter(&stderrBuf)
if err := cmd.Start(); err != nil {
return nil, nil, nil, fmt.Errorf("acp: probe start %s: %w", a.command, err)
}
// The server-request handler needs to reference `tr` itself in order
// to respondError; declare via var so the closure captures the
// variable (which is assigned to a *transport below) rather than an
// uninitialised copy.
var tr *transport
tr = newTransport(stdout, stdin,
func(method string, _ json.RawMessage) {
slog.Debug("acp-probe: notification", "method", method)
},
func(_ string, id json.RawMessage, _ json.RawMessage) {
_ = tr.respondError(id, -32601, "probe: method not implemented")
},
)
readCtx, cancelRead := context.WithCancel(ctx)
go tr.readLoop(readCtx)
teardown := func() {
cancelRead()
_ = stdin.Close()
done := make(chan struct{})
go func() {
_ = cmd.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
<-done
}
}
return tr, &stderrBuf, teardown, nil
}
// probeInitialize performs the ACP handshake on an already-spawned
// transport and returns the parsed initialize result.
func probeInitialize(ctx context.Context, tr *transport) (*acpInitializeResult, error) {
raw, err := tr.call(ctx, "initialize", map[string]any{
"protocolVersion": 1,
"clientCapabilities": map[string]any{
"fs": map[string]any{"readTextFile": false, "writeTextFile": false},
"terminal": false,
},
"clientInfo": map[string]any{"name": "cc-connect", "version": "1.0.0"},
})
if err != nil {
return nil, fmt.Errorf("acp: probe initialize: %w", err)
}
var res acpInitializeResult
if err := json.Unmarshal(raw, &res); err != nil {
return nil, fmt.Errorf("acp: probe parse initialize: %w", err)
}
return &res, nil
}
// probeListSessions runs `session/list` on the given transport.
// Returns (nil, nil) if the agent refuses the call with
// method-not-found / invalid-request — callers interpret that as
// "unsupported" rather than "real error".
func probeListSessions(ctx context.Context, tr *transport, cwdFilter string) ([]acpSessionListEntry, error) {
params := map[string]any{}
if cwdFilter != "" {
params["cwd"] = cwdFilter
}
raw, err := tr.call(ctx, "session/list", params)
if err != nil {
if rpcErr, ok := err.(*rpcErrPayload); ok {
if rpcErr.Code == -32601 || rpcErr.Code == -32600 {
return nil, nil
}
}
return nil, err
}
var out acpSessionListResult
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("acp: parse session/list: %w", err)
}
return out.Sessions, nil
}
// ListSessions returns past sessions reported by the ACP agent, scoped
// to the agent's work_dir. If the agent does not advertise
// sessionCapabilities.list or the call soft-fails, returns nil.
//
// This runs a one-shot `<command>` process that performs only
// initialize + session/list, so it does NOT allocate a real session on
// the backend (unlike session/new). Cost is roughly a single ACP
// handshake round-trip (~100-500ms for Devin).
func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) {
if a.listUnsupported.Load() {
// Already learned this agent doesn't support session/list;
// fast-path out to avoid respawning just to rediscover that.
return nil, nil
}
a.mu.RLock()
workDir := a.workDir
a.mu.RUnlock()
absWorkDir, err := filepath.Abs(workDir)
if err != nil {
absWorkDir = workDir
}
probeCtx, cancel := context.WithTimeout(ctx, listSessionsProbeTimeout)
defer cancel()
started := time.Now()
tr, stderrBuf, teardown, err := a.probeSpawn(probeCtx, absWorkDir)
if err != nil {
return nil, err
}
defer teardown()
init, err := probeInitialize(probeCtx, tr)
if err != nil {
if msg := strings.TrimSpace(stderrBuf.String()); msg != "" {
return nil, fmt.Errorf("%w (stderr: %s)", err, truncateForLog(msg, 200))
}
return nil, err
}
if len(init.AgentCapabilities.SessionCapabilities.List) == 0 {
slog.Info("acp: session/list unsupported by agent, caching for fast-path", "command", a.command)
a.listUnsupported.Store(true)
return nil, nil
}
entries, err := probeListSessions(probeCtx, tr, absWorkDir)
if err != nil {
return nil, err
}
if entries == nil {
a.listUnsupported.Store(true)
return nil, nil
}
out := convertSessionList(entries, absWorkDir)
slog.Info("acp: session/list completed",
"total_entries", len(entries),
"matching_cwd", len(out),
"cwd", absWorkDir,
"elapsed", time.Since(started),
)
return out, nil
}
// convertSessionList maps ACP session/list entries to core.AgentSessionInfo.
// If `cwdFilter` is non-empty, entries whose cwd does not match are dropped;
// ACP servers SHOULD filter themselves when the request includes cwd, but
// we defend against servers that ignore the hint (see probe_caps.py output
// against devin acp: filter is respected there, but we still double-check).
func convertSessionList(entries []acpSessionListEntry, cwdFilter string) []core.AgentSessionInfo {
out := make([]core.AgentSessionInfo, 0, len(entries))
for _, e := range entries {
if cwdFilter != "" && e.Cwd != "" && !strings.EqualFold(filepath.Clean(e.Cwd), filepath.Clean(cwdFilter)) {
continue
}
info := core.AgentSessionInfo{
ID: e.SessionID,
Summary: strings.TrimSpace(e.Title),
}
if t, err := time.Parse(time.RFC3339, e.UpdatedAt); err == nil {
info.ModifiedAt = t
} else if t, err := time.Parse(time.RFC3339Nano, e.UpdatedAt); err == nil {
info.ModifiedAt = t
}
out = append(out, info)
}
return out
}
func truncateForLog(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+311
View File
@@ -0,0 +1,311 @@
package acp
import (
"encoding/json"
"strings"
"github.com/chenhg5/cc-connect/core"
)
// mapSessionUpdate turns one ACP session/update payload into zero or more core events.
func mapSessionUpdate(sessionID string, params json.RawMessage) []core.Event {
var wrap struct {
SessionID string `json:"sessionId"`
Update json.RawMessage `json:"update"`
}
if err := json.Unmarshal(params, &wrap); err != nil || len(wrap.Update) == 0 {
return nil
}
sid := wrap.SessionID
if sid == "" {
sid = sessionID
}
var head struct {
SessionUpdate string `json:"sessionUpdate"`
}
if err := json.Unmarshal(wrap.Update, &head); err != nil {
return nil
}
switch head.SessionUpdate {
case "agent_message_chunk":
return mapAgentMessageChunk(sid, wrap.Update)
case "tool_call":
return mapToolCall(sid, wrap.Update)
case "tool_call_update":
return mapToolCallUpdate(sid, wrap.Update)
case "plan":
return mapPlan(sid, wrap.Update)
case "user_message_chunk":
// History replay during session/load — suppress to avoid echoing user input.
return nil
default:
// Optional vendor / future ACP shapes — best-effort text extraction.
return mapSessionUpdateFallback(sid, head.SessionUpdate, wrap.Update)
}
}
func mapAgentMessageChunk(sessionID string, update json.RawMessage) []core.Event {
var u struct {
Content struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(update, &u); err != nil {
return nil
}
if u.Content.Text == "" {
return nil
}
return []core.Event{{
Type: core.EventText,
Content: u.Content.Text,
SessionID: sessionID,
}}
}
func mapToolCall(sessionID string, update json.RawMessage) []core.Event {
var u struct {
ToolCallID string `json:"toolCallId"`
Title string `json:"title"`
Kind string `json:"kind"`
Status string `json:"status"`
RawInput json.RawMessage `json:"rawInput"`
}
if err := json.Unmarshal(update, &u); err != nil {
return nil
}
toolName := u.Title
if toolName == "" {
toolName = u.Kind
}
if toolName == "" {
toolName = "tool"
}
toolInput := summarizeACPToolInput(u.Kind, u.RawInput)
if toolInput == "" {
toolInput = u.Title
}
return []core.Event{{
Type: core.EventToolUse,
ToolName: toolName,
ToolInput: toolInput,
SessionID: sessionID,
}}
}
func mapToolCallUpdate(sessionID string, update json.RawMessage) []core.Event {
var u struct {
Title string `json:"title"`
ToolCallID string `json:"toolCallId"`
Status string `json:"status"`
Content []struct {
Type string `json:"type"`
Content struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
} `json:"content"`
}
if err := json.Unmarshal(update, &u); err != nil {
return nil
}
toolLabel := u.Title
if toolLabel == "" {
toolLabel = u.ToolCallID
}
if toolLabel == "" {
toolLabel = "tool"
}
body := extractToolCallContentText(u.Content)
st := strings.ToLower(strings.TrimSpace(u.Status))
switch {
case st == "completed" || st == "failed":
if body == "" && st == "completed" {
return nil
}
if st == "failed" && body == "" {
body = "(failed)"
}
return []core.Event{{
Type: core.EventToolResult,
ToolName: toolLabel,
Content: truncateRunes(body, 800),
SessionID: sessionID,
}}
case st == "in_progress" || st == "pending":
// Stream intermediate tool output to IM (ACP allows content while not terminal).
if body == "" {
return nil
}
return []core.Event{{
Type: core.EventToolResult,
ToolName: toolLabel,
Content: truncateRunes(body, 800),
SessionID: sessionID,
}}
default:
if body != "" {
return []core.Event{{
Type: core.EventToolResult,
ToolName: toolLabel,
Content: truncateRunes(body, 800),
SessionID: sessionID,
}}
}
return nil
}
}
func extractToolCallContentText(blocks []struct {
Type string `json:"type"`
Content struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}) string {
var b strings.Builder
for _, c := range blocks {
if c.Content.Text != "" {
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(c.Content.Text)
}
}
return b.String()
}
// mapSessionUpdateFallback handles unknown sessionUpdate values (vendor extensions
// that still carry human-readable text). Never guesses auth or tool semantics.
func mapSessionUpdateFallback(sessionID string, kind string, update json.RawMessage) []core.Event {
// Some agents may send reasoning as a dedicated discriminator; map to EventThinking.
switch strings.ToLower(kind) {
case "reasoning", "reasoning_chunk", "thinking", "agent_thinking_chunk":
var u struct {
Content struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Text string `json:"text"`
}
if json.Unmarshal(update, &u) != nil {
return nil
}
t := u.Content.Text
if t == "" {
t = u.Text
}
if t == "" {
return nil
}
return []core.Event{{
Type: core.EventThinking,
Content: t,
SessionID: sessionID,
}}
}
return nil
}
func mapPlan(sessionID string, update json.RawMessage) []core.Event {
var u struct {
Entries []struct {
Content string `json:"content"`
Priority string `json:"priority"`
Status string `json:"status"`
} `json:"entries"`
}
if err := json.Unmarshal(update, &u); err != nil || len(u.Entries) == 0 {
return nil
}
var b strings.Builder
for i, e := range u.Entries {
if i > 0 {
b.WriteByte('\n')
}
line := e.Content
if e.Status != "" {
line = "[" + e.Status + "] " + line
}
b.WriteString(line)
}
return []core.Event{{
Type: core.EventThinking,
Content: b.String(),
SessionID: sessionID,
}}
}
func truncateRunes(s string, maxRunes int) string {
if maxRunes <= 0 {
return s
}
r := []rune(s)
if len(r) <= maxRunes {
return s
}
return string(r[:maxRunes]) + "..."
}
// permissionOption matches ACP session/request_permission option entries.
type permissionOption struct {
OptionID string `json:"optionId"`
Name string `json:"name"`
Kind string `json:"kind"`
}
func pickPermissionOptionID(allow bool, options []permissionOption) string {
if len(options) == 0 {
return ""
}
if allow {
for _, o := range options {
if strings.Contains(strings.ToLower(o.Kind), "allow") {
return o.OptionID
}
}
for _, o := range options {
if strings.Contains(strings.ToLower(o.Name), "allow") {
return o.OptionID
}
}
return options[0].OptionID
}
for _, o := range options {
if strings.Contains(strings.ToLower(o.Kind), "reject") || strings.Contains(strings.ToLower(o.Kind), "deny") {
return o.OptionID
}
}
for _, o := range options {
if strings.Contains(strings.ToLower(o.Name), "reject") || strings.Contains(strings.ToLower(o.Name), "deny") {
return o.OptionID
}
}
return options[len(options)-1].OptionID
}
func buildPermissionResult(allow bool, optionID string) map[string]any {
if !allow {
if optionID == "" {
return map[string]any{
"outcome": map[string]any{"outcome": "cancelled"},
}
}
return map[string]any{
"outcome": map[string]any{
"outcome": "selected",
"optionId": optionID,
},
}
}
return map[string]any{
"outcome": map[string]any{
"outcome": "selected",
"optionId": optionID,
},
}
}
+170
View File
@@ -0,0 +1,170 @@
package acp
import (
"encoding/json"
"testing"
"github.com/chenhg5/cc-connect/core"
)
func TestMapSessionUpdate_agentMessageChunk(t *testing.T) {
params := json.RawMessage(`{
"sessionId": "s1",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "hello"}
}
}`)
evs := mapSessionUpdate("", params)
if len(evs) != 1 || evs[0].Type != core.EventText || evs[0].Content != "hello" {
t.Fatalf("got %+v", evs)
}
}
func TestMapSessionUpdate_toolCallUpdate_inProgress(t *testing.T) {
params := json.RawMessage(`{
"sessionId": "s1",
"update": {
"sessionUpdate": "tool_call_update",
"toolCallId": "c1",
"title": "Run",
"status": "in_progress",
"content": [
{"type": "content", "content": {"type": "text", "text": "partial output"}}
]
}
}`)
evs := mapSessionUpdate("", params)
if len(evs) != 1 || evs[0].Type != core.EventToolResult || evs[0].ToolName != "Run" {
t.Fatalf("got %+v", evs)
}
}
func TestMapSessionUpdate_reasoningChunk(t *testing.T) {
params := json.RawMessage(`{
"sessionId": "s1",
"update": {
"sessionUpdate": "reasoning_chunk",
"content": {"type": "text", "text": "step 1"}
}
}`)
evs := mapSessionUpdate("", params)
if len(evs) != 1 || evs[0].Type != core.EventThinking || evs[0].Content != "step 1" {
t.Fatalf("got %+v", evs)
}
}
func TestMapSessionUpdate_toolCall(t *testing.T) {
params := json.RawMessage(`{
"sessionId": "s1",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "c1",
"title": "Read file",
"kind": "read",
"status": "pending"
}
}`)
evs := mapSessionUpdate("", params)
if len(evs) != 1 || evs[0].Type != core.EventToolUse || evs[0].ToolName != "Read file" {
t.Fatalf("got %+v", evs)
}
}
func TestPickPermissionOptionID(t *testing.T) {
opts := []permissionOption{
{OptionID: "a", Kind: "allow_once"},
{OptionID: "r", Kind: "reject_once"},
}
if pickPermissionOptionID(true, opts) != "a" {
t.Fatal("allow")
}
if pickPermissionOptionID(false, opts) != "r" {
t.Fatal("deny")
}
}
func TestBuildPermissionResult(t *testing.T) {
allow := buildPermissionResult(true, "opt1")
if allow["outcome"].(map[string]any)["optionId"] != "opt1" {
t.Fatalf("%v", allow)
}
denySel := buildPermissionResult(false, "rej")
if denySel["outcome"].(map[string]any)["optionId"] != "rej" {
t.Fatalf("%v", denySel)
}
cancel := buildPermissionResult(false, "")
if cancel["outcome"].(map[string]any)["outcome"] != "cancelled" {
t.Fatalf("%v", cancel)
}
}
func TestMapSessionUpdate_toolCall_withRawInput(t *testing.T) {
params := json.RawMessage(`{
"sessionId": "s1",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "c1",
"title": "Bash",
"kind": "bash",
"status": "pending",
"rawInput": {"command": "ls -la /tmp"}
}
}`)
evs := mapSessionUpdate("", params)
if len(evs) != 1 {
t.Fatalf("expected 1 event, got %d", len(evs))
}
if evs[0].Type != core.EventToolUse {
t.Fatalf("expected EventToolUse, got %s", evs[0].Type)
}
if evs[0].ToolInput != "ls -la /tmp" {
t.Fatalf("expected tool input 'ls -la /tmp', got %q", evs[0].ToolInput)
}
}
func TestSummarizeACPToolInput(t *testing.T) {
tests := []struct {
name string
kind string
raw string
want string
}{
{
name: "bash command",
kind: "bash",
raw: `{"command": "echo hello"}`,
want: "echo hello",
},
{
name: "read file",
kind: "read",
raw: `{"file_path": "/tmp/test.txt"}`,
want: "/tmp/test.txt",
},
{
name: "empty raw",
kind: "bash",
raw: "",
want: "",
},
{
name: "unknown kind falls back to formatted JSON",
kind: "unknown_tool",
raw: `{"key": "value"}`,
want: "{\n \"key\": \"value\"\n}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var raw json.RawMessage
if tt.raw != "" {
raw = json.RawMessage(tt.raw)
}
got := summarizeACPToolInput(tt.kind, raw)
if got != tt.want {
t.Errorf("summarizeACPToolInput(%q, %s) = %q, want %q", tt.kind, tt.raw, got, tt.want)
}
})
}
}
+228
View File
@@ -0,0 +1,228 @@
package acp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"sync"
"sync/atomic"
)
type rpcOutcome struct {
result json.RawMessage
err *rpcErrPayload
}
type rpcErrPayload struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *rpcErrPayload) Error() string {
if e == nil {
return "json-rpc error"
}
return fmt.Sprintf("json-rpc %d: %s", e.Code, e.Message)
}
type rpcNotifyHandler func(method string, params json.RawMessage)
type rpcRequestHandler func(method string, id json.RawMessage, params json.RawMessage)
// transport implements newline-delimited JSON-RPC 2.0 over a pair of streams.
type transport struct {
in *bufio.Reader
out io.Writer
mu sync.Mutex
enc *json.Encoder
nextID atomic.Int64
pendingMu sync.Mutex
pending map[string]chan rpcOutcome
onNotif rpcNotifyHandler
onReq rpcRequestHandler
}
func newTransport(in io.Reader, out io.Writer, onNotif rpcNotifyHandler, onReq rpcRequestHandler) *transport {
return &transport{
in: bufio.NewReader(in),
out: out,
enc: json.NewEncoder(out),
pending: make(map[string]chan rpcOutcome),
onNotif: onNotif,
onReq: onReq,
}
}
func (t *transport) readLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
line, err := t.readLine()
if err != nil {
if err != io.EOF {
slog.Debug("acp: read error", "error", err)
}
t.cancelAll(fmt.Errorf("acp: read closed: %w", err))
return
}
if len(bytes.TrimSpace(line)) == 0 {
continue
}
t.dispatchLine(line)
}
}
func (t *transport) readLine() ([]byte, error) {
line, err := t.in.ReadBytes('\n')
if err != nil {
return line, err
}
return bytes.TrimSuffix(line, []byte("\r")), nil
}
func (t *transport) dispatchLine(line []byte) {
var env struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Error *rpcErrPayload `json:"error"`
}
if err := json.Unmarshal(line, &env); err != nil {
slog.Debug("acp: skip non-json line", "line", string(line))
return
}
if env.Method != "" {
if isJSONRPCIDNullOrAbsent(env.ID) {
if t.onNotif != nil {
t.onNotif(env.Method, env.Params)
}
return
}
if t.onReq != nil {
t.onReq(env.Method, env.ID, env.Params)
}
return
}
if !isJSONRPCIDNullOrAbsent(env.ID) {
t.completePending(env.ID, env.Result, env.Error)
}
}
func isJSONRPCIDNullOrAbsent(id json.RawMessage) bool {
if len(id) == 0 {
return true
}
return bytes.Equal(bytes.TrimSpace(id), []byte("null"))
}
func jsonIDKey(id json.RawMessage) string {
id = bytes.TrimSpace(id)
var n json.Number
if json.Unmarshal(id, &n) == nil {
return string(n)
}
var s string
if json.Unmarshal(id, &s) == nil {
return s
}
return string(id)
}
func (t *transport) completePending(id json.RawMessage, result json.RawMessage, rpcErr *rpcErrPayload) {
key := jsonIDKey(id)
t.pendingMu.Lock()
ch, ok := t.pending[key]
delete(t.pending, key)
t.pendingMu.Unlock()
if !ok {
slog.Debug("acp: unmatched rpc response", "id", key)
return
}
select {
case ch <- rpcOutcome{result: result, err: rpcErr}:
default:
}
}
func (t *transport) cancelAll(err error) {
t.pendingMu.Lock()
defer t.pendingMu.Unlock()
msg := err.Error()
for k, ch := range t.pending {
select {
case ch <- rpcOutcome{err: &rpcErrPayload{Code: -32000, Message: msg}}:
default:
}
delete(t.pending, k)
}
}
func (t *transport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
id := t.nextID.Add(1)
key := fmt.Sprintf("%d", id)
ch := make(chan rpcOutcome, 1)
t.pendingMu.Lock()
t.pending[key] = ch
t.pendingMu.Unlock()
req := map[string]any{
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}
if err := t.writeJSON(req); err != nil {
t.pendingMu.Lock()
delete(t.pending, key)
t.pendingMu.Unlock()
return nil, fmt.Errorf("acp: write %s: %w", method, err)
}
select {
case <-ctx.Done():
t.pendingMu.Lock()
delete(t.pending, key)
t.pendingMu.Unlock()
return nil, ctx.Err()
case out := <-ch:
if out.err != nil {
return nil, out.err
}
return out.result, nil
}
}
func (t *transport) writeJSON(v any) error {
t.mu.Lock()
defer t.mu.Unlock()
return t.enc.Encode(v)
}
type rpcResponseMsg struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcErrPayload `json:"error,omitempty"`
}
func (t *transport) respondSuccess(id json.RawMessage, result any) error {
return t.writeJSON(rpcResponseMsg{JSONRPC: "2.0", ID: id, Result: result})
}
func (t *transport) respondError(id json.RawMessage, code int, message string) error {
return t.writeJSON(rpcResponseMsg{
JSONRPC: "2.0",
ID: id,
Error: &rpcErrPayload{Code: code, Message: message},
})
}
+67
View File
@@ -0,0 +1,67 @@
package acp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"testing"
)
func TestTransportCallRoundTrip(t *testing.T) {
rResp, wMockResp := io.Pipe()
rReq, wTrOut := io.Pipe()
tr := newTransport(rResp, wTrOut, nil, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go tr.readLoop(ctx)
go func() {
defer wMockResp.Close()
sc := bufio.NewScanner(rReq)
for sc.Scan() {
var req map[string]any
if err := json.Unmarshal(sc.Bytes(), &req); err != nil {
continue
}
id := req["id"]
line := fmt.Sprintf(`{"jsonrpc":"2.0","id":%v,"result":{"protocolVersion":1}}`+"\n", id)
if _, err := io.WriteString(wMockResp, line); err != nil {
return
}
}
}()
res, err := tr.call(ctx, "initialize", map[string]any{"protocolVersion": 1})
if err != nil {
t.Fatal(err)
}
var got struct {
ProtocolVersion int `json:"protocolVersion"`
}
if err := json.Unmarshal(res, &got); err != nil {
t.Fatal(err)
}
if got.ProtocolVersion != 1 {
t.Fatalf("protocolVersion = %d", got.ProtocolVersion)
}
cancel()
_ = wTrOut.Close()
}
func TestJSONIDKey(t *testing.T) {
if jsonIDKey(json.RawMessage(`42`)) != "42" {
t.Fatalf("numeric id")
}
if jsonIDKey(json.RawMessage(`"x"`)) != "x" {
t.Fatalf("string id")
}
if !isJSONRPCIDNullOrAbsent(json.RawMessage(`null`)) {
t.Fatalf("null id")
}
if !isJSONRPCIDNullOrAbsent(json.RawMessage(nil)) {
t.Fatalf("absent id")
}
}
+762
View File
@@ -0,0 +1,762 @@
package acp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/chenhg5/cc-connect/core"
)
// toolInputCacheMaxEntries caps toolInputByID growth; beyond this we evict
// roughly half the map (iteration order is arbitrary) to bound memory.
const toolInputCacheMaxEntries = 1000
type acpSession struct {
workDir string
events chan core.Event
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
cmd *exec.Cmd
tr *transport
acpSessMu sync.RWMutex
acpSessID string
sendMu sync.Mutex
permMu sync.Mutex
permByID map[string]permState
toolInputMu sync.Mutex
toolInputByID map[string]string // toolCallId -> summarized tool input
// modesMu guards availableModes and currentMode. Both fields are
// populated on handshake (session/new or session/load response) and
// updated whenever SetLiveMode succeeds or the server announces a
// mode change via session/update.
modesMu sync.RWMutex
availableModes []acpModeInfo
currentMode string
callbacks sessionCallbacks // may be nil (tests, integration harness)
}
type permState struct {
RPCID json.RawMessage
Options []permissionOption
}
// acpSessionConfig bundles the inputs newACPSession needs. It's a
// struct rather than a long positional argument list because we keep
// adding optional knobs (initialMode, callbacks) and would otherwise
// break every call site each time.
type acpSessionConfig struct {
command string
args []string
extraEnv []string
workDir string
resumeSessionID string
authMethod string
initialMode string // if non-empty, applied via session/set_mode after session/new
callbacks sessionCallbacks // may be nil
}
func newACPSession(ctx context.Context, cfg acpSessionConfig) (*acpSession, error) {
absWorkDir, err := filepath.Abs(cfg.workDir)
if err != nil {
absWorkDir = cfg.workDir
}
sessionCtx, cancel := context.WithCancel(ctx)
s := &acpSession{
workDir: absWorkDir,
events: make(chan core.Event, 128),
ctx: sessionCtx,
cancel: cancel,
permByID: make(map[string]permState),
toolInputByID: make(map[string]string),
acpSessID: cfg.resumeSessionID,
callbacks: cfg.callbacks,
}
s.alive.Store(true)
cmd := exec.CommandContext(sessionCtx, cfg.command, cfg.args...)
cmd.Dir = absWorkDir
cmd.Env = core.MergeEnv(os.Environ(), cfg.extraEnv)
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("acp: stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("acp: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = io.MultiWriter(&stderrBuf, os.Stderr)
s.cmd = cmd
s.tr = newTransport(stdout, stdin, s.onNotification, s.onServerRequest)
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("acp: start %s: %w", cfg.command, err)
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.tr.readLoop(sessionCtx)
waitErr := cmd.Wait()
if waitErr != nil {
msg := stderrBuf.String()
if msg != "" {
slog.Error("acp: process exited", "error", waitErr, "stderr", msg)
s.emit(core.Event{Type: core.EventError, Error: fmt.Errorf("%s", strings.TrimSpace(msg))})
} else {
slog.Debug("acp: process exited", "error", waitErr)
}
}
s.alive.Store(false)
}()
if err := s.handshake(cfg.resumeSessionID, cfg.authMethod); err != nil {
_ = s.Close()
return nil, err
}
// Apply the agent-level mode preference now that we have a session
// id. If set_mode fails (e.g. modeId unknown to this backend) we
// log and carry on with whatever mode the server defaulted to —
// the alternative would be to reject the session entirely, which
// is worse UX for a non-critical control.
if strings.TrimSpace(cfg.initialMode) != "" {
if ok := s.SetLiveMode(cfg.initialMode); !ok {
slog.Warn("acp: initial mode could not be applied",
"mode", cfg.initialMode,
"session_id", s.currentACPSessionID(),
)
}
}
return s, nil
}
// handshake runs initialize → optional authenticate → session/load or
// session/new, and caches any modes the server advertises so
// SetLiveMode / PermissionModes can answer correctly.
func (s *acpSession) handshake(resumeSessionID string, authMethod string) error {
initParams := map[string]any{
"protocolVersion": 1,
"clientCapabilities": map[string]any{
"fs": map[string]any{
"readTextFile": false,
"writeTextFile": false,
},
"terminal": false,
},
"clientInfo": map[string]any{
"name": "cc-connect",
"version": "1.0.0",
},
}
res, err := s.tr.call(s.ctx, "initialize", initParams)
if err != nil {
return fmt.Errorf("acp: initialize: %w", err)
}
var initOut acpInitializeResult
if err := json.Unmarshal(res, &initOut); err != nil {
return fmt.Errorf("acp: parse initialize result: %w", err)
}
listSupported := len(initOut.AgentCapabilities.SessionCapabilities.List) > 0
slog.Debug("acp: initialized",
"protocol", initOut.ProtocolVersion,
"load_session", initOut.AgentCapabilities.LoadSession,
"list_sessions", listSupported,
)
if s.callbacks != nil {
s.callbacks.reportListSupported(listSupported)
}
if strings.TrimSpace(authMethod) != "" {
if _, err := s.tr.call(s.ctx, "authenticate", map[string]any{
"methodId": authMethod,
}); err != nil {
return fmt.Errorf("acp: authenticate (%s): %w", authMethod, err)
}
slog.Debug("acp: authenticated", "method_id", authMethod)
}
wantResume := resumeSessionID != "" && resumeSessionID != core.ContinueSession
if wantResume && initOut.AgentCapabilities.LoadSession {
loadParams := map[string]any{
"sessionId": resumeSessionID,
"cwd": s.workDir,
"mcpServers": []any{},
}
loadRes, err := s.tr.call(s.ctx, "session/load", loadParams)
if err != nil {
slog.Warn("acp: session/load failed, starting new session", "error", err)
} else {
var lr struct {
SessionID string `json:"sessionId"`
Modes *acpModesBlock `json:"modes"`
}
if json.Unmarshal(loadRes, &lr) == nil && lr.SessionID != "" {
s.setACPSessionID(lr.SessionID)
s.absorbModes(lr.Modes)
return nil
}
}
}
newParams := map[string]any{
"cwd": s.workDir,
"mcpServers": []any{},
}
newRes, err := s.tr.call(s.ctx, "session/new", newParams)
if err != nil {
return fmt.Errorf("acp: session/new: %w", err)
}
var sn struct {
SessionID string `json:"sessionId"`
Modes *acpModesBlock `json:"modes"`
}
if err := json.Unmarshal(newRes, &sn); err != nil {
return fmt.Errorf("acp: parse session/new: %w", err)
}
if sn.SessionID == "" {
return fmt.Errorf("acp: session/new: empty sessionId")
}
s.setACPSessionID(sn.SessionID)
s.absorbModes(sn.Modes)
return nil
}
// absorbModes copies a modes block into the session's cache and fans
// it out to the parent agent callbacks (if any). Both the session and
// the agent need the information: the session uses it to validate
// SetLiveMode inputs; the agent uses it to render `/mode` menus in IM.
func (s *acpSession) absorbModes(block *acpModesBlock) {
if block == nil || len(block.AvailableModes) == 0 {
return
}
s.modesMu.Lock()
s.availableModes = append(s.availableModes[:0], block.AvailableModes...)
if block.CurrentModeID != "" {
s.currentMode = block.CurrentModeID
}
s.modesMu.Unlock()
if s.callbacks != nil {
s.callbacks.reportModes(*block)
}
}
func (s *acpSession) setACPSessionID(id string) {
s.acpSessMu.Lock()
s.acpSessID = id
s.acpSessMu.Unlock()
}
func (s *acpSession) currentACPSessionID() string {
s.acpSessMu.RLock()
defer s.acpSessMu.RUnlock()
return s.acpSessID
}
// CurrentMode returns the ACP modeId most recently applied or reported
// for this session. Empty when the server never sent a modes block.
func (s *acpSession) CurrentMode() string {
s.modesMu.RLock()
defer s.modesMu.RUnlock()
return s.currentMode
}
// SetLiveMode applies a permission mode change to the running session
// via `session/set_mode`. Returns true on success, false if the mode
// is unknown / the call errors / the session is closed.
//
// This is the implementation of core.LiveModeSwitcher for ACP
// sessions; the engine invokes it when the user runs `/mode <x>`,
// `/plan`, `/bypass`, etc. while a session is active.
//
// Client-side validation is important because at least one ACP server
// (devin acp in 2026.4.9) silently accepts unknown modeIds without
// any error, so a server-only check would let typos go undetected.
func (s *acpSession) SetLiveMode(mode string) bool {
if !s.alive.Load() {
return false
}
sid := s.currentACPSessionID()
if sid == "" {
return false
}
modeID := s.matchAvailableMode(mode)
if modeID == "" {
slog.Debug("acp: SetLiveMode rejected unknown mode",
"mode", mode,
"session_id", sid,
)
return false
}
if _, err := s.tr.call(s.ctx, "session/set_mode", map[string]any{
"sessionId": sid,
"modeId": modeID,
}); err != nil {
slog.Warn("acp: session/set_mode failed",
"mode", modeID,
"session_id", sid,
"error", err,
)
return false
}
s.modesMu.Lock()
s.currentMode = modeID
s.modesMu.Unlock()
if s.callbacks != nil {
// Re-publish current modeId so Agent.GetMode stays in sync.
s.modesMu.RLock()
available := append([]acpModeInfo(nil), s.availableModes...)
s.modesMu.RUnlock()
s.callbacks.reportModes(acpModesBlock{
CurrentModeID: modeID,
AvailableModes: available,
})
}
slog.Info("acp: live mode applied", "mode", modeID, "session_id", sid)
return true
}
// matchAvailableMode resolves a user-typed mode string to a known ACP
// modeId from the cached availableModes list. Matching is case-
// insensitive on both id and display name to accommodate IM input.
// Returns "" if nothing matches or if modes are unknown (first session
// hasn't handshaked yet).
func (s *acpSession) matchAvailableMode(input string) string {
input = strings.TrimSpace(input)
if input == "" {
return ""
}
lower := strings.ToLower(input)
s.modesMu.RLock()
defer s.modesMu.RUnlock()
for _, m := range s.availableModes {
if strings.ToLower(m.ID) == lower || strings.ToLower(m.Name) == lower {
return m.ID
}
}
return ""
}
func (s *acpSession) onNotification(method string, params json.RawMessage) {
if method != "session/update" {
slog.Debug("acp: notification", "method", method)
return
}
s.cacheToolCallInput(params)
s.maybeAbsorbCurrentModeUpdate(params)
sid := s.currentACPSessionID()
for _, ev := range mapSessionUpdate(sid, params) {
s.emit(ev)
}
}
// maybeAbsorbCurrentModeUpdate watches session/update notifications
// for `current_mode_update` (server-driven mode switch, e.g. when the
// user toggles modes via the Windsurf/IDE UI while cc-connect is
// connected). Keeping currentMode in sync here means the IM `/mode`
// indicator reflects the true server state rather than the last
// client-initiated value.
func (s *acpSession) maybeAbsorbCurrentModeUpdate(params json.RawMessage) {
var wrap struct {
Update json.RawMessage `json:"update"`
}
if json.Unmarshal(params, &wrap) != nil || len(wrap.Update) == 0 {
return
}
var head struct {
Kind string `json:"sessionUpdate"`
CurrentModeID string `json:"currentModeId"`
}
if json.Unmarshal(wrap.Update, &head) != nil {
return
}
if head.Kind != "current_mode_update" || head.CurrentModeID == "" {
return
}
s.modesMu.Lock()
s.currentMode = head.CurrentModeID
available := append([]acpModeInfo(nil), s.availableModes...)
s.modesMu.Unlock()
if s.callbacks != nil {
s.callbacks.reportModes(acpModesBlock{
CurrentModeID: head.CurrentModeID,
AvailableModes: available,
})
}
}
// cacheToolCallInput extracts and caches rawInput from tool_call and tool_call_update
// session updates so that handlePermissionRequest can look it up by toolCallId.
// OpenCode ACP bug (#7370): rawInput is empty in tool_call and request_permission,
// but populated in tool_call_update. We cache from both sources.
func (s *acpSession) evictToolInputCacheIfNeededLocked() {
if len(s.toolInputByID) < toolInputCacheMaxEntries {
return
}
target := toolInputCacheMaxEntries / 2
for k := range s.toolInputByID {
if len(s.toolInputByID) <= target {
break
}
delete(s.toolInputByID, k)
}
}
func (s *acpSession) cacheToolCallInput(params json.RawMessage) {
var wrap struct {
Update json.RawMessage `json:"update"`
}
if json.Unmarshal(params, &wrap) != nil || len(wrap.Update) == 0 {
return
}
var head struct {
SessionUpdate string `json:"sessionUpdate"`
}
if json.Unmarshal(wrap.Update, &head) != nil {
return
}
switch head.SessionUpdate {
case "tool_call":
var tc struct {
ToolCallID string `json:"toolCallId"`
Kind string `json:"kind"`
RawInput json.RawMessage `json:"rawInput"`
}
if json.Unmarshal(wrap.Update, &tc) != nil || tc.ToolCallID == "" || len(tc.RawInput) == 0 {
return
}
s.toolInputMu.Lock()
s.evictToolInputCacheIfNeededLocked()
input := summarizeACPToolInput(tc.Kind, tc.RawInput)
s.toolInputByID[tc.ToolCallID] = input
s.toolInputMu.Unlock()
slog.Info("acp: cached tool_call input", "toolCallId", tc.ToolCallID, "kind", tc.Kind, "input", input)
case "tool_call_update":
var tc struct {
ToolCallID string `json:"toolCallId"`
RawInput json.RawMessage `json:"rawInput"`
}
if json.Unmarshal(wrap.Update, &tc) != nil || tc.ToolCallID == "" || len(tc.RawInput) == 0 {
return
}
input := summarizeACPToolInput("", tc.RawInput)
if input == "" {
return
}
s.toolInputMu.Lock()
s.evictToolInputCacheIfNeededLocked()
s.toolInputByID[tc.ToolCallID] = input
s.toolInputMu.Unlock()
slog.Info("acp: cached tool_call_update input", "toolCallId", tc.ToolCallID, "input", input)
}
}
func (s *acpSession) onServerRequest(method string, id json.RawMessage, params json.RawMessage) {
switch method {
case "session/request_permission":
s.handlePermissionRequest(id, params)
case "cursor/ask_question", "cursor/create_plan", "cursor/update_todos", "cursor/task", "cursor/generate_image":
// Cursor CLI extensions — acknowledge so tool flows do not block; IM UX is limited for these.
slog.Debug("acp: cursor extension request (no-op ack)", "method", method)
_ = s.tr.respondSuccess(id, map[string]any{})
default:
if strings.HasPrefix(method, "cursor/") {
slog.Debug("acp: unknown cursor extension, ack empty", "method", method)
_ = s.tr.respondSuccess(id, map[string]any{})
return
}
slog.Info("acp: unhandled server request", "method", method)
_ = s.tr.respondError(id, -32601, "method not implemented")
}
}
func (s *acpSession) handlePermissionRequest(id json.RawMessage, params json.RawMessage) {
var p struct {
SessionID string `json:"sessionId"`
ToolCall struct {
ToolCallID string `json:"toolCallId"`
Title string `json:"title"`
Kind string `json:"kind"`
RawInput json.RawMessage `json:"rawInput"`
} `json:"toolCall"`
Options []permissionOption `json:"options"`
}
if err := json.Unmarshal(params, &p); err != nil {
_ = s.tr.respondError(id, -32602, "invalid params")
return
}
slog.Debug("acp: permission request raw params", "params", string(params))
reqKey := jsonIDKey(id)
toolName := p.ToolCall.Title
if toolName == "" {
toolName = p.ToolCall.Kind
}
if toolName == "" {
toolName = "permission"
}
s.permMu.Lock()
s.permByID[reqKey] = permState{RPCID: id, Options: p.Options}
s.permMu.Unlock()
rawTool := map[string]any{}
_ = json.Unmarshal(params, &rawTool)
// OpenCode ACP bug (#7370): rawInput in request_permission is always {},
// but tool_call_update (which arrives right after) has the real input.
// Emit in a goroutine so we don't block the read loop, and wait briefly
// for tool_call_update to populate the cache.
s.wg.Add(1)
go func() {
defer s.wg.Done()
for i := 0; i < 10; i++ {
s.toolInputMu.Lock()
toolInput := s.toolInputByID[p.ToolCall.ToolCallID]
s.toolInputMu.Unlock()
if toolInput != "" {
break
}
select {
case <-s.ctx.Done():
return
case <-time.After(50 * time.Millisecond):
}
}
s.toolInputMu.Lock()
toolInput := s.toolInputByID[p.ToolCall.ToolCallID]
s.toolInputMu.Unlock()
if toolInput == "" {
toolInput = summarizeACPToolInput(p.ToolCall.Kind, p.ToolCall.RawInput)
}
if toolInput == "" {
toolInput = p.ToolCall.Title
}
if toolInput == "" {
toolInput = p.ToolCall.ToolCallID
}
slog.Info("acp: permission request", "request_id", reqKey, "tool", toolName, "input", toolInput)
s.emit(core.Event{
Type: core.EventPermissionRequest,
RequestID: reqKey,
ToolName: toolName,
ToolInput: toolInput,
ToolInputRaw: rawTool,
SessionID: s.currentACPSessionID(),
})
}()
}
func (s *acpSession) emit(ev core.Event) {
if ev.SessionID == "" {
ev.SessionID = s.currentACPSessionID()
}
select {
case s.events <- ev:
case <-s.ctx.Done():
}
}
func (s *acpSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
if !s.alive.Load() {
return fmt.Errorf("acp: session closed")
}
s.sendMu.Lock()
defer s.sendMu.Unlock()
filePaths := core.SaveFilesToDisk(s.workDir, files)
prompt = core.AppendFileRefs(prompt, filePaths)
if len(images) > 0 {
prompt = s.appendImageRefs(prompt, images)
}
sid := s.currentACPSessionID()
if sid == "" {
return fmt.Errorf("acp: no agent session id")
}
promptBlocks := []any{
map[string]any{"type": "text", "text": prompt},
}
params := map[string]any{
"sessionId": sid,
"prompt": promptBlocks,
}
_, err := s.tr.call(s.ctx, "session/prompt", params)
if err != nil {
s.emit(core.Event{Type: core.EventError, Error: err})
return fmt.Errorf("acp: session/prompt: %w", err)
}
// Text was streamed via session/update; engine aggregates EventText.
s.emit(core.Event{
Type: core.EventResult,
SessionID: sid,
Done: true,
})
return nil
}
func (s *acpSession) appendImageRefs(prompt string, images []core.ImageAttachment) string {
attachDir := filepath.Join(s.workDir, ".cc-connect", "attachments")
if err := os.MkdirAll(attachDir, 0o755); err != nil {
slog.Warn("acp: mkdir attachments failed", "error", err)
return prompt
}
var paths []string
for i, img := range images {
ext := ".bin"
switch img.MimeType {
case "image/jpeg":
ext = ".jpg"
case "image/gif":
ext = ".gif"
case "image/webp":
ext = ".webp"
case "image/png", "":
ext = ".png"
}
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
fpath := filepath.Join(attachDir, fname)
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
slog.Error("acp: save image failed", "error", err)
continue
}
paths = append(paths, fpath)
}
if len(paths) == 0 {
return prompt
}
if prompt == "" {
prompt = "User sent image(s)."
}
return prompt + "\n\n(Image files saved locally: " + strings.Join(paths, ", ") + ")"
}
func (s *acpSession) RespondPermission(requestID string, result core.PermissionResult) error {
if !s.alive.Load() {
return fmt.Errorf("acp: session closed")
}
s.permMu.Lock()
st, ok := s.permByID[requestID]
if ok {
delete(s.permByID, requestID)
}
s.permMu.Unlock()
if !ok {
return fmt.Errorf("acp: unknown permission request %q", requestID)
}
allow := strings.EqualFold(result.Behavior, "allow")
optID := pickPermissionOptionID(allow, st.Options)
if allow && optID == "" {
slog.Warn("acp: allow requested but agent sent no options", "request_id", requestID)
return s.tr.respondError(st.RPCID, -32603, "no permission options from agent")
}
res := buildPermissionResult(allow, optID)
slog.Debug("acp: permission response", "request_id", requestID, "allow", allow, "option_id", optID)
return s.tr.respondSuccess(st.RPCID, res)
}
func (s *acpSession) Events() <-chan core.Event {
return s.events
}
func (s *acpSession) CurrentSessionID() string {
return s.currentACPSessionID()
}
func (s *acpSession) Alive() bool {
return s.alive.Load()
}
func (s *acpSession) Close() error {
s.alive.Store(false)
s.cancel()
if s.cmd != nil && s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
}
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
close(s.events)
case <-time.After(8 * time.Second):
slog.Warn("acp: close timed out waiting for I/O loop")
}
return nil
}
// summarizeACPToolInput extracts a human-readable summary from ACP tool rawInput.
func summarizeACPToolInput(kind string, raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var m map[string]any
if json.Unmarshal(raw, &m) != nil {
return string(raw)
}
if len(m) == 0 {
return ""
}
switch strings.ToLower(kind) {
case "bash", "shell", "terminal", "execute":
if cmd, ok := m["command"].(string); ok {
if desc, ok := m["description"].(string); ok && desc != "" {
return "# " + desc + "\n" + cmd
}
return cmd
}
case "read", "write", "edit":
if fp, ok := m["file_path"].(string); ok {
return fp
}
if fp, ok := m["path"].(string); ok {
return fp
}
}
// Fallback: try extracting command with description before formatting JSON.
if cmd, ok := m["command"].(string); ok {
if desc, ok := m["description"].(string); ok && desc != "" {
return "# " + desc + "\n" + cmd
}
return cmd
}
b, _ := json.MarshalIndent(m, "", " ")
return string(b)
}
+505
View File
@@ -0,0 +1,505 @@
package acp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"sync"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
)
// --- Agent: mode cache & SetMode/GetMode ---------------------------
func TestAgent_PermissionModes_emptyBeforeFirstHandshake(t *testing.T) {
a := &Agent{}
if got := a.PermissionModes(); len(got) != 0 {
t.Fatalf("want empty modes before first handshake, got %v", got)
}
if got := a.GetMode(); got != "" {
t.Fatalf("want empty mode before handshake, got %q", got)
}
}
func TestAgent_reportModes_populatesCache(t *testing.T) {
a := &Agent{}
a.reportModes(acpModesBlock{
CurrentModeID: "plan",
AvailableModes: []acpModeInfo{
{ID: "normal", Name: "Code", Description: "Write and edit code"},
{ID: "plan", Name: "Plan", Description: "Plan changes"},
{ID: "bypass", Name: "Bypass Permissions"},
},
})
modes := a.PermissionModes()
if len(modes) != 3 {
t.Fatalf("got %d modes, want 3", len(modes))
}
if modes[0].Key != "normal" || modes[0].Name != "Code" || modes[0].Desc != "Write and edit code" {
t.Fatalf("modes[0] = %+v", modes[0])
}
if modes[0].NameZh != "Code" || modes[0].DescZh != "Write and edit code" {
t.Fatalf("zh fallback missing on modes[0] = %+v", modes[0])
}
// Nobody has called SetMode, so GetMode falls back to the
// server-reported currentModeId.
if got := a.GetMode(); got != "plan" {
t.Fatalf("GetMode = %q, want plan (fallback to server currentModeId when no explicit SetMode)", got)
}
}
// Regression: after `/mode plan`, cc-connect's engine calls SetMode("plan")
// then reads back GetMode() to decide what to display and apply via
// SetLiveMode. The pending SetMode MUST win over the previously-cached
// currentModeId, otherwise /mode reports the wrong mode name and the
// live switch goes to the old mode.
func TestAgent_GetMode_pendingWinsOverCachedCurrent(t *testing.T) {
a := &Agent{}
// Simulate a first session handshake which reported current=normal.
a.reportModes(acpModesBlock{
CurrentModeID: "normal",
AvailableModes: []acpModeInfo{
{ID: "normal", Name: "Code"},
{ID: "plan", Name: "Plan"},
},
})
if got := a.GetMode(); got != "normal" {
t.Fatalf("pre-SetMode GetMode = %q, want normal", got)
}
a.SetMode("plan")
if got := a.GetMode(); got != "plan" {
t.Fatalf("post-SetMode GetMode = %q, want plan (pending takes precedence)", got)
}
}
func TestAgent_SetMode_normalisesAgainstCache(t *testing.T) {
a := &Agent{}
a.reportModes(acpModesBlock{
CurrentModeID: "normal",
AvailableModes: []acpModeInfo{
{ID: "normal", Name: "Code"},
{ID: "accept-edits", Name: "Accept Edits"},
},
})
// Case-insensitive match on id
a.SetMode("Normal")
a.mu.RLock()
if a.mode != "normal" {
t.Fatalf("pending mode = %q, want normal", a.mode)
}
a.mu.RUnlock()
// Case-insensitive match on display name → canonical id
a.SetMode("accept edits")
a.mu.RLock()
gotPending := a.mode
a.mu.RUnlock()
if gotPending != "accept-edits" {
t.Fatalf("pending mode = %q, want accept-edits (normalised via case-insensitive display-name match)", gotPending)
}
// Unknown input → stored as-is so a later StartSession can try it
// (at which point session/set_mode will soft-fail loudly).
a.SetMode("totally-unknown")
a.mu.RLock()
gotPending = a.mode
a.mu.RUnlock()
if gotPending != "totally-unknown" {
t.Fatalf("pending mode = %q, want totally-unknown (passthrough)", gotPending)
}
}
func TestAgent_GetMode_fallbackToPendingWhenNoSession(t *testing.T) {
a := &Agent{mode: "plan"}
if got := a.GetMode(); got != "plan" {
t.Fatalf("GetMode = %q, want plan (pending, no handshake yet)", got)
}
}
// --- session/list parsing ------------------------------------------
func TestConvertSessionList_cwdFilter(t *testing.T) {
entries := []acpSessionListEntry{
{SessionID: "a", Cwd: "/tmp/proj1", Title: "First", UpdatedAt: "2026-04-18T16:15:29+00:00"},
{SessionID: "b", Cwd: "/tmp/proj2", Title: "Second", UpdatedAt: "2026-04-18T16:10:29+00:00"},
// Entry without cwd passes through regardless of filter
{SessionID: "c", Cwd: "", Title: "Third", UpdatedAt: "2026-04-18T16:05:29+00:00"},
}
got := convertSessionList(entries, "/tmp/proj1")
if len(got) != 2 {
t.Fatalf("got %d, want 2 (proj1 + cwd-less)", len(got))
}
if got[0].ID != "a" || got[0].Summary != "First" {
t.Fatalf("entry[0] = %+v", got[0])
}
if got[1].ID != "c" {
t.Fatalf("entry[1] = %+v, want passthrough of cwd-less entry", got[1])
}
if got[0].ModifiedAt.IsZero() {
t.Fatalf("ModifiedAt not parsed: %+v", got[0])
}
}
func TestConvertSessionList_noCwdFilter(t *testing.T) {
entries := []acpSessionListEntry{
{SessionID: "a", Cwd: "/tmp/proj1"},
{SessionID: "b", Cwd: "/tmp/proj2"},
}
got := convertSessionList(entries, "")
if len(got) != 2 {
t.Fatalf("got %d, want 2 when no filter", len(got))
}
}
func TestConvertSessionList_pathCleanAndCaseInsensitive(t *testing.T) {
entries := []acpSessionListEntry{
{SessionID: "a", Cwd: "/Users/Foo/Proj"},
{SessionID: "b", Cwd: "/users/foo/proj"}, // case-insensitive match expected on case-insensitive FS
{SessionID: "c", Cwd: "/Users/Foo/Proj/sub"},
}
// filter that includes trailing separator to verify Clean
got := convertSessionList(entries, "/Users/Foo/Proj/")
if len(got) != 2 {
t.Fatalf("got %d, want 2 (case-insensitive + Clean match on first two)", len(got))
}
}
// Verifies probeListSessions swallows -32601 (method not found) and
// surfaces other errors.
func TestProbeListSessions_softFailsOnMethodNotFound(t *testing.T) {
rResp, wResp := io.Pipe()
rReq, wReq := io.Pipe()
tr := newTransport(rResp, wReq, nil, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go tr.readLoop(ctx)
// Mock server: respond -32601 for session/list.
go func() {
defer wResp.Close()
sc := bufio.NewScanner(rReq)
for sc.Scan() {
var req map[string]any
if err := json.Unmarshal(sc.Bytes(), &req); err != nil {
continue
}
id := req["id"]
line := fmt.Sprintf(`{"jsonrpc":"2.0","id":%v,"error":{"code":-32601,"message":"method not found"}}`+"\n", id)
_, _ = io.WriteString(wResp, line)
}
}()
entries, err := probeListSessions(ctx, tr, "")
if err != nil {
t.Fatalf("want nil error on method-not-found, got %v", err)
}
if entries != nil {
t.Fatalf("want nil entries on method-not-found, got %v", entries)
}
}
func TestProbeListSessions_propagatesHardError(t *testing.T) {
rResp, wResp := io.Pipe()
rReq, wReq := io.Pipe()
tr := newTransport(rResp, wReq, nil, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go tr.readLoop(ctx)
go func() {
defer wResp.Close()
sc := bufio.NewScanner(rReq)
for sc.Scan() {
var req map[string]any
if err := json.Unmarshal(sc.Bytes(), &req); err != nil {
continue
}
id := req["id"]
line := fmt.Sprintf(`{"jsonrpc":"2.0","id":%v,"error":{"code":-32000,"message":"boom"}}`+"\n", id)
_, _ = io.WriteString(wResp, line)
}
}()
entries, err := probeListSessions(ctx, tr, "")
if err == nil {
t.Fatalf("want error on hard failure, got entries=%v", entries)
}
if !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected wrapped error to contain 'boom', got %v", err)
}
}
func TestProbeListSessions_parsesSessions(t *testing.T) {
rResp, wResp := io.Pipe()
rReq, wReq := io.Pipe()
tr := newTransport(rResp, wReq, nil, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go tr.readLoop(ctx)
go func() {
defer wResp.Close()
sc := bufio.NewScanner(rReq)
for sc.Scan() {
var req map[string]any
if err := json.Unmarshal(sc.Bytes(), &req); err != nil {
continue
}
id := req["id"]
line := fmt.Sprintf(`{"jsonrpc":"2.0","id":%v,"result":{"sessions":[{"sessionId":"s1","cwd":"/tmp","title":"hi","updatedAt":"2026-04-18T16:15:29+00:00"}]}}`+"\n", id)
_, _ = io.WriteString(wResp, line)
}
}()
entries, err := probeListSessions(ctx, tr, "/tmp")
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].SessionID != "s1" || entries[0].Title != "hi" {
t.Fatalf("unexpected entries: %+v", entries)
}
}
// --- session: SetLiveMode + callbacks ------------------------------
// fakeCallbacks captures reportModes / reportListSupported invocations
// so tests can assert on them deterministically.
type fakeCallbacks struct {
mu sync.Mutex
modes []acpModesBlock
listCalls []bool
}
func (f *fakeCallbacks) reportModes(b acpModesBlock) {
f.mu.Lock()
f.modes = append(f.modes, b)
f.mu.Unlock()
}
func (f *fakeCallbacks) reportListSupported(supported bool) {
f.mu.Lock()
f.listCalls = append(f.listCalls, supported)
f.mu.Unlock()
}
func (f *fakeCallbacks) lastModes() (acpModesBlock, bool) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.modes) == 0 {
return acpModesBlock{}, false
}
return f.modes[len(f.modes)-1], true
}
// newTestSession builds an acpSession with a pipe-backed transport
// (no real subprocess). The second return value is a writer the test
// uses to inject server-side RPC responses.
func newTestSession(t *testing.T, cb sessionCallbacks) (*acpSession, *io.PipeWriter, *io.PipeReader) {
t.Helper()
rResp, wResp := io.Pipe() // server → client
rReq, wReq := io.Pipe() // client → server
s := &acpSession{
workDir: t.TempDir(),
events: make(chan core.Event, 32),
permByID: make(map[string]permState),
toolInputByID: make(map[string]string),
callbacks: cb,
}
s.ctx, s.cancel = context.WithCancel(context.Background())
s.alive.Store(true)
s.acpSessID = "test-session-id"
s.tr = newTransport(rResp, wReq, s.onNotification, s.onServerRequest)
go s.tr.readLoop(s.ctx)
t.Cleanup(func() {
s.cancel()
wResp.Close()
rReq.Close()
})
return s, wResp, rReq
}
func TestSession_SetLiveMode_success(t *testing.T) {
cb := &fakeCallbacks{}
s, wResp, rReq := newTestSession(t, cb)
// Pre-populate availableModes so SetLiveMode validates OK.
s.absorbModes(&acpModesBlock{
CurrentModeID: "normal",
AvailableModes: []acpModeInfo{
{ID: "normal", Name: "Code"},
{ID: "plan", Name: "Plan"},
},
})
// Mock server: read one request, verify it, respond success.
done := make(chan struct{})
go func() {
defer close(done)
sc := bufio.NewScanner(rReq)
for sc.Scan() {
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params struct {
SessionID string `json:"sessionId"`
ModeID string `json:"modeId"`
} `json:"params"`
}
if err := json.Unmarshal(sc.Bytes(), &req); err != nil {
continue
}
if req.Method != "session/set_mode" {
continue
}
if req.Params.SessionID != "test-session-id" || req.Params.ModeID != "plan" {
_, _ = fmt.Fprintf(wResp, `{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"bad params"}}`+"\n", req.ID)
return
}
_, _ = fmt.Fprintf(wResp, `{"jsonrpc":"2.0","id":%s,"result":{}}`+"\n", req.ID)
return
}
}()
if !s.SetLiveMode("plan") {
t.Fatal("SetLiveMode returned false for valid mode")
}
<-done
if got := s.CurrentMode(); got != "plan" {
t.Fatalf("CurrentMode = %q, want plan", got)
}
// Callback should have been re-fired with currentModeId=plan.
time.Sleep(10 * time.Millisecond) // small grace for goroutine
last, ok := cb.lastModes()
if !ok {
t.Fatalf("expected callback invocation after successful set_mode")
}
if last.CurrentModeID != "plan" {
t.Fatalf("callback currentModeId = %q, want plan", last.CurrentModeID)
}
}
func TestSession_SetLiveMode_rejectsUnknownMode(t *testing.T) {
cb := &fakeCallbacks{}
s, wResp, rReq := newTestSession(t, cb)
_ = wResp
_ = rReq
s.absorbModes(&acpModesBlock{
CurrentModeID: "normal",
AvailableModes: []acpModeInfo{
{ID: "normal", Name: "Code"},
},
})
if s.SetLiveMode("plan") {
t.Fatal("SetLiveMode should refuse unknown mode without making RPC")
}
// currentMode unchanged.
if got := s.CurrentMode(); got != "normal" {
t.Fatalf("CurrentMode drifted: %q", got)
}
}
func TestSession_SetLiveMode_caseInsensitive(t *testing.T) {
cb := &fakeCallbacks{}
s, wResp, rReq := newTestSession(t, cb)
s.absorbModes(&acpModesBlock{
AvailableModes: []acpModeInfo{
{ID: "accept-edits", Name: "Accept Edits"},
},
})
// Mock server: unconditionally OK.
go func() {
sc := bufio.NewScanner(rReq)
for sc.Scan() {
var env struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params struct {
ModeID string `json:"modeId"`
} `json:"params"`
}
if err := json.Unmarshal(sc.Bytes(), &env); err != nil {
continue
}
if env.Method != "session/set_mode" {
continue
}
if env.Params.ModeID != "accept-edits" {
// Test asserts canonicalisation happened before RPC.
_, _ = fmt.Fprintf(wResp, `{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"wrong id %q"}}`+"\n", env.ID, env.Params.ModeID)
return
}
_, _ = fmt.Fprintf(wResp, `{"jsonrpc":"2.0","id":%s,"result":{}}`+"\n", env.ID)
return
}
}()
// User types "ACCEPT EDITS" with wrong case
if !s.SetLiveMode("ACCEPT EDITS") {
t.Fatal("SetLiveMode should accept case-variant of display name and canonicalise to id")
}
}
func TestSession_absorbModes_reportsViaCallback(t *testing.T) {
cb := &fakeCallbacks{}
s, _, _ := newTestSession(t, cb)
s.absorbModes(&acpModesBlock{
CurrentModeID: "plan",
AvailableModes: []acpModeInfo{
{ID: "normal"},
{ID: "plan"},
},
})
got, ok := cb.lastModes()
if !ok {
t.Fatal("expected callback")
}
if got.CurrentModeID != "plan" || len(got.AvailableModes) != 2 {
t.Fatalf("unexpected callback block: %+v", got)
}
}
func TestSession_maybeAbsorbCurrentModeUpdate(t *testing.T) {
cb := &fakeCallbacks{}
s, _, _ := newTestSession(t, cb)
s.absorbModes(&acpModesBlock{
AvailableModes: []acpModeInfo{{ID: "normal"}, {ID: "plan"}},
})
// Simulate a server-sent current_mode_update notification
params := json.RawMessage(`{
"sessionId": "test-session-id",
"update": {
"sessionUpdate": "current_mode_update",
"currentModeId": "plan"
}
}`)
s.maybeAbsorbCurrentModeUpdate(params)
if got := s.CurrentMode(); got != "plan" {
t.Fatalf("currentMode = %q, want plan", got)
}
last, ok := cb.lastModes()
if !ok || last.CurrentModeID != "plan" {
t.Fatalf("callback should have been fired with currentModeId=plan, got %+v ok=%v", last, ok)
}
}