初始化仓库
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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] + "…"
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user