初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+540
View File
@@ -0,0 +1,540 @@
package copilot
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/chenhg5/cc-connect/core"
)
func init() {
core.RegisterAgent("copilot", New)
}
// Agent drives GitHub Copilot CLI using --headless --stdio --no-auto-update
// for persistent JSON-RPC 2.0 communication over Content-Length framed stdio.
//
// Permission modes:
// - "default": every tool call requires user approval
// - "bypassPermissions": auto-approve everything (alias: yolo)
type Agent struct {
workDir string
cliBin string // CLI binary name or path (default: "copilot")
model string
mode string // "default" | "bypassPermissions"
providers []core.ProviderConfig
activeIdx int // -1 = no provider set
sessionEnv []string
mu sync.RWMutex
}
func New(opts map[string]any) (core.Agent, error) {
workDir, _ := opts["work_dir"].(string)
if workDir == "" {
workDir = "."
}
cliBin := "copilot"
if cliPath, _ := opts["cli_path"].(string); strings.TrimSpace(cliPath) != "" {
cliBin = strings.TrimSpace(cliPath)
}
model, _ := opts["model"].(string)
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
if _, err := exec.LookPath(cliBin); err != nil {
return nil, fmt.Errorf("copilot: %q CLI not found in PATH, please install it first", cliBin)
}
return &Agent{
workDir: workDir,
cliBin: cliBin,
model: model,
mode: mode,
activeIdx: -1,
}, nil
}
func normalizeMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "bypasspermissions", "bypass-permissions", "bypass_permissions", "yolo":
return "bypassPermissions"
default:
return "default"
}
}
func (a *Agent) Name() string { return "copilot" }
func (a *Agent) CLIBinaryName() string { return a.cliBin }
func (a *Agent) CLIDisplayName() string { return "GitHub Copilot" }
func (a *Agent) SetWorkDir(dir string) {
a.mu.Lock()
defer a.mu.Unlock()
a.workDir = dir
slog.Info("copilot: work_dir changed", "work_dir", dir)
}
func (a *Agent) GetWorkDir() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.workDir
}
func (a *Agent) SetModel(model string) {
a.mu.Lock()
defer a.mu.Unlock()
a.model = model
slog.Info("copilot: model changed", "model", model)
}
func (a *Agent) GetModel() string {
a.mu.RLock()
defer a.mu.RUnlock()
return core.GetProviderModel(a.providers, a.activeIdx, a.model)
}
func (a *Agent) AvailableModels(_ context.Context) []core.ModelOption {
if models := a.configuredModels(); len(models) > 0 {
return models
}
return []core.ModelOption{
{Name: "gpt-4.1", Desc: "GPT-4.1"},
{Name: "claude-sonnet-4.6", Desc: "Claude Sonnet 4.6"},
{Name: "o3", Desc: "O3"},
}
}
func (a *Agent) configuredModels() []core.ModelOption {
a.mu.RLock()
defer a.mu.RUnlock()
return core.GetProviderModels(a.providers, a.activeIdx)
}
func (a *Agent) SetSessionEnv(env []string) {
a.mu.Lock()
defer a.mu.Unlock()
a.sessionEnv = env
}
func (a *Agent) SetMode(mode string) {
a.mu.Lock()
defer a.mu.Unlock()
a.mode = normalizeMode(mode)
slog.Info("copilot: permission mode changed", "mode", a.mode)
}
func (a *Agent) GetMode() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.mode
}
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
return []core.PermissionModeInfo{
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Ask permission for every tool call", DescZh: "每次工具调用都需确认"},
{Key: "bypassPermissions", Name: "YOLO", NameZh: "YOLO 模式", Desc: "Auto-approve everything", DescZh: "全部自动通过"},
}
}
// StartSession creates a persistent interactive Copilot session.
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
a.mu.RLock()
model := a.model
mode := a.mode
workDir := a.workDir
cliBin := a.cliBin
extraEnv := a.providerEnvLocked()
extraEnv = append(extraEnv, a.sessionEnv...)
provider := a.providerConfigLocked()
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
model = m
}
}
a.mu.RUnlock()
return newCopilotSession(ctx, workDir, cliBin, model, mode, sessionID, extraEnv, provider)
}
// listSessionsProbeTimeout bounds how long we wait for a session.list probe.
var listSessionsProbeTimeout = 15 * time.Second
// copilotSessionMetadata represents one session entry returned by session.list.
type copilotSessionMetadata struct {
SessionID string `json:"sessionId"`
StartTime string `json:"startTime"`
ModifiedTime string `json:"modifiedTime"`
Summary *string `json:"summary,omitempty"`
}
// copilotListSessionsResponse is the session.list response payload.
type copilotListSessionsResponse struct {
Sessions []copilotSessionMetadata `json:"sessions"`
}
// copilotDeleteSessionResponse is the session.delete response payload.
type copilotDeleteSessionResponse struct {
Success bool `json:"success"`
Error *string `json:"error,omitempty"`
}
// probeSession is a short-lived copilot probe process with a managed read loop.
type probeSession struct {
rpc *rpcClient
cancel context.CancelFunc
done chan struct{}
}
type probeSnapshot struct {
cliBin string
workDir string
env []string
}
// newProbeSession spawns a copilot --headless --stdio probe process, starts a
// read loop, and returns a probeSession. Caller must call close() when done.
func newProbeSession(ctx context.Context, snapshot probeSnapshot) (*probeSession, error) {
probeCtx, cancel := context.WithCancel(ctx)
cmd := exec.CommandContext(probeCtx, snapshot.cliBin, "--headless", "--stdio", "--no-auto-update")
cmd.Dir = snapshot.workDir
cmd.Env = snapshot.env
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("copilot probe: stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("copilot probe: stdout pipe: %w", err)
}
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("copilot probe: start: %w", err)
}
rpc := newRPCClient(stdin)
reader := newLSPReader(stdout)
done := make(chan struct{})
go func() {
defer func() {
_ = stdin.Close()
_ = cmd.Wait()
close(done)
}()
for {
body, err := reader.readMessage()
if err != nil {
return
}
var resp jsonRPCResponse
if json.Unmarshal(body, &resp) == nil &&
len(resp.ID) > 0 && string(resp.ID) != "null" {
rpc.dispatch(&resp)
}
}
}()
return &probeSession{rpc: rpc, cancel: cancel, done: done}, nil
}
// call sends a JSON-RPC request and blocks until a response or ctx expires.
func (ps *probeSession) call(ctx context.Context, method string, params any) (*jsonRPCResponse, error) {
_, ch := ps.rpc.call(method, params)
select {
case resp := <-ch:
return resp, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// close cancels the probe context, terminating the process and read loop.
func (ps *probeSession) close() {
ps.cancel()
<-ps.done
}
// ListSessions returns past sessions by spawning a short-lived copilot probe
// that performs ping + session.list, then exits. Returns nil gracefully if
// the binary is missing or the RPC fails.
func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) {
a.mu.RLock()
snapshot := a.probeSnapshotLocked()
a.mu.RUnlock()
if _, err := exec.LookPath(snapshot.cliBin); err != nil {
return nil, nil
}
probeCtx, cancel := context.WithTimeout(ctx, listSessionsProbeTimeout)
defer cancel()
probe, err := newProbeSession(probeCtx, snapshot)
if err != nil {
slog.Debug("copilot: ListSessions probe spawn failed", "error", err)
return nil, nil
}
defer probe.close()
// Ping
pingResp, err := probe.call(probeCtx, "ping", nil)
if err != nil || pingResp.Error != nil {
slog.Debug("copilot: ListSessions ping failed", "error", err)
return nil, nil
}
// session.list
listResp, err := probe.call(probeCtx, "session.list", map[string]any{})
if err != nil {
return nil, nil
}
if listResp.Error != nil {
slog.Debug("copilot: session.list RPC error", "code", listResp.Error.Code, "msg", listResp.Error.Message)
return nil, nil
}
var result copilotListSessionsResponse
if err := json.Unmarshal(listResp.Result, &result); err != nil {
slog.Debug("copilot: session.list parse error", "error", err)
return nil, nil
}
out := make([]core.AgentSessionInfo, 0, len(result.Sessions))
for _, s := range result.Sessions {
info := core.AgentSessionInfo{ID: s.SessionID}
if s.Summary != nil {
info.Summary = *s.Summary
}
if t, err := time.Parse(time.RFC3339, s.ModifiedTime); err == nil {
info.ModifiedAt = t
} else if t, err := time.Parse(time.RFC3339Nano, s.ModifiedTime); err == nil {
info.ModifiedAt = t
}
out = append(out, info)
}
slog.Info("copilot: ListSessions", "count", len(out))
return out, nil
}
// WorkspaceAgentOptions implements core.WorkspaceSnapshotter.
// Returns the options needed to recreate an equivalent session for workspace reuse.
func (a *Agent) WorkspaceAgentOptions() map[string]any {
a.mu.RLock()
defer a.mu.RUnlock()
opts := map[string]any{
"mode": a.mode,
}
if a.model != "" {
opts["model"] = a.model
}
if a.cliBin != "copilot" && a.cliBin != "" {
opts["cli_path"] = a.cliBin
}
return opts
}
func (a *Agent) Stop() error { return nil }
// DeleteSession implements core.SessionDeleter by calling session.delete
// via a short-lived probe process. Returns nil gracefully if the binary is
// missing or the RPC is unsupported.
func (a *Agent) DeleteSession(ctx context.Context, sessionID string) error {
if sessionID == "" {
return nil
}
a.mu.RLock()
snapshot := a.probeSnapshotLocked()
a.mu.RUnlock()
if _, err := exec.LookPath(snapshot.cliBin); err != nil {
return nil
}
probeCtx, cancel := context.WithTimeout(ctx, listSessionsProbeTimeout)
defer cancel()
probe, err := newProbeSession(probeCtx, snapshot)
if err != nil {
slog.Debug("copilot: DeleteSession probe spawn failed", "error", err)
return nil
}
defer probe.close()
pingResp, err := probe.call(probeCtx, "ping", nil)
if err != nil || pingResp.Error != nil {
slog.Debug("copilot: DeleteSession ping failed", "error", err)
return nil
}
delResp, err := probe.call(probeCtx, "session.delete", map[string]any{"sessionId": sessionID})
if err != nil {
return nil
}
if delResp.Error != nil {
// method-not-found or invalid-request means unsupported
if delResp.Error.Code == -32601 || delResp.Error.Code == -32600 {
return nil
}
return fmt.Errorf("copilot: session.delete: %s", delResp.Error.Message)
}
var result copilotDeleteSessionResponse
if err := json.Unmarshal(delResp.Result, &result); err != nil {
// Ignore parse errors - treat as success
return nil
}
if !result.Success {
if result.Error != nil {
return fmt.Errorf("copilot: session.delete failed: %s", *result.Error)
}
return fmt.Errorf("copilot: session.delete failed: unknown error")
}
slog.Info("copilot: session deleted", "sessionId", sessionID)
return nil
}
// GetSessionHistory implements core.HistoryProvider.
// Copilot does not expose a history RPC; return empty gracefully.
func (a *Agent) GetSessionHistory(_ context.Context, _ string, _ int) ([]core.HistoryEntry, error) {
return nil, nil
}
// CompressCommand implements core.ContextCompressor.
// Copilot has no built-in compact/compress command.
func (a *Agent) CompressCommand() string { return "" }
// ── ProviderSwitcher implementation ──────────────────────────
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
a.mu.Lock()
defer a.mu.Unlock()
a.providers = providers
}
func (a *Agent) SetActiveProvider(name string) bool {
a.mu.Lock()
defer a.mu.Unlock()
if name == "" {
a.activeIdx = -1
slog.Info("copilot: provider cleared")
return true
}
for i, p := range a.providers {
if p.Name == name {
a.activeIdx = i
slog.Info("copilot: provider switched", "provider", name)
return true
}
}
return false
}
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
a.mu.RLock()
defer a.mu.RUnlock()
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
return &p
}
func (a *Agent) ListProviders() []core.ProviderConfig {
a.mu.RLock()
defer a.mu.RUnlock()
result := make([]core.ProviderConfig, len(a.providers))
copy(result, a.providers)
return result
}
func (a *Agent) providerEnvLocked() []string {
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
var env []string
if p.BaseURL != "" {
env = append(env, "COPILOT_PROVIDER_BASE_URL="+p.BaseURL)
}
if p.APIKey != "" {
env = append(env, "COPILOT_PROVIDER_API_KEY="+p.APIKey)
}
if p.Model != "" {
env = append(env, "COPILOT_MODEL="+p.Model)
}
if p.CodexWireAPI != "" {
env = append(env, "COPILOT_PROVIDER_WIRE_API="+p.CodexWireAPI)
}
for k, v := range p.Env {
env = append(env, k+"="+v)
}
return env
}
func (a *Agent) probeSnapshotLocked() probeSnapshot {
env := os.Environ()
if extraEnv := a.providerEnvLocked(); len(extraEnv) > 0 {
env = core.MergeEnv(env, extraEnv)
}
if len(a.sessionEnv) > 0 {
env = core.MergeEnv(env, a.sessionEnv)
}
return probeSnapshot{cliBin: a.cliBin, workDir: a.workDir, env: env}
}
func (a *Agent) providerConfigLocked() *copilotWireProviderConfig {
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
if p.BaseURL == "" {
return nil
}
provider := &copilotWireProviderConfig{
Type: "openai",
BaseURL: p.BaseURL,
APIKey: p.APIKey,
ModelID: p.Model,
Headers: p.CodexHTTPHeaders,
WireAPI: p.CodexWireAPI,
}
if typ := strings.TrimSpace(p.Env["COPILOT_PROVIDER_TYPE"]); typ != "" {
provider.Type = typ
}
if wireModel := strings.TrimSpace(p.Env["COPILOT_PROVIDER_WIRE_MODEL"]); wireModel != "" {
provider.WireModel = wireModel
}
if bearer := strings.TrimSpace(p.Env["COPILOT_PROVIDER_BEARER_TOKEN"]); bearer != "" {
provider.BearerToken = bearer
}
return provider
}
// Compile-time interface assertions.
var (
_ core.Agent = (*Agent)(nil)
_ core.AgentDoctorInfo = (*Agent)(nil)
_ core.WorkspaceAgentOptionSnapshotter = (*Agent)(nil)
_ core.SessionDeleter = (*Agent)(nil)
_ core.HistoryProvider = (*Agent)(nil)
_ core.ContextCompressor = (*Agent)(nil)
_ core.ProviderSwitcher = (*Agent)(nil)
)
+369
View File
@@ -0,0 +1,369 @@
package copilot
import (
"context"
"os"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
)
func TestNew_MissingBinary(t *testing.T) {
_, err := New(map[string]any{
"cli_path": "copilot-nonexistent-binary-xyz",
})
if err == nil {
t.Fatal("expected error for missing binary")
}
}
func TestAgent_Name(t *testing.T) {
a := &Agent{}
if a.Name() != "copilot" {
t.Fatalf("Name() = %q, want copilot", a.Name())
}
}
func TestNormalizeMode(t *testing.T) {
tests := []struct {
input string
want string
}{
{"default", "default"},
{"bypassPermissions", "bypassPermissions"},
{"bypass-permissions", "bypassPermissions"},
{"bypass_permissions", "bypassPermissions"},
{"yolo", "bypassPermissions"},
{"YOLO", "bypassPermissions"},
{"", "default"},
{"unknown", "default"},
}
for _, tt := range tests {
got := normalizeMode(tt.input)
if got != tt.want {
t.Errorf("normalizeMode(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestAgent_SetGetModel(t *testing.T) {
a := &Agent{}
a.SetModel("gpt-4o")
if got := a.GetModel(); got != "gpt-4o" {
t.Fatalf("GetModel() = %q, want gpt-4o", got)
}
}
func TestAgent_SetGetMode(t *testing.T) {
a := &Agent{}
a.SetMode("yolo")
if got := a.GetMode(); got != "bypassPermissions" {
t.Fatalf("GetMode() = %q, want bypassPermissions", got)
}
a.SetMode("default")
if got := a.GetMode(); got != "default" {
t.Fatalf("GetMode() = %q, want default", got)
}
}
func TestAgent_SetGetWorkDir(t *testing.T) {
a := &Agent{}
a.SetWorkDir("/tmp/test")
if got := a.GetWorkDir(); got != "/tmp/test" {
t.Fatalf("GetWorkDir() = %q, want /tmp/test", got)
}
}
func TestAgent_PermissionModes(t *testing.T) {
a := &Agent{}
modes := a.PermissionModes()
if len(modes) != 2 {
t.Fatalf("PermissionModes() len = %d, want 2", len(modes))
}
if modes[0].Key != "default" {
t.Errorf("modes[0].Key = %q, want default", modes[0].Key)
}
if modes[1].Key != "bypassPermissions" {
t.Errorf("modes[1].Key = %q, want bypassPermissions", modes[1].Key)
}
}
func TestAgent_DeleteSession(t *testing.T) {
a := &Agent{}
err := a.DeleteSession(context.Background(), "some-session-id")
if err != nil {
t.Fatalf("DeleteSession() = %v, want nil", err)
}
}
func TestAgent_GetSessionHistory(t *testing.T) {
a := &Agent{}
entries, err := a.GetSessionHistory(context.Background(), "some-session-id", 10)
if err != nil {
t.Fatalf("GetSessionHistory() error = %v", err)
}
if entries != nil {
t.Fatalf("GetSessionHistory() = %v, want nil", entries)
}
}
func TestAgent_CompressCommand(t *testing.T) {
a := &Agent{}
if got := a.CompressCommand(); got != "" {
t.Fatalf("CompressCommand() = %q, want empty", got)
}
}
func TestAgent_AvailableModels(t *testing.T) {
a := &Agent{}
models := a.AvailableModels(context.Background())
if len(models) == 0 {
t.Fatal("AvailableModels() returned empty")
}
}
func TestAgent_ProviderSwitcher(t *testing.T) {
a := &Agent{activeIdx: -1}
if p := a.GetActiveProvider(); p != nil {
t.Fatalf("GetActiveProvider() = %v, want nil", p)
}
if got := a.SetActiveProvider("nonexistent"); got {
t.Fatal("SetActiveProvider(nonexistent) = true, want false")
}
}
func TestAgent_ProviderConfigLocked(t *testing.T) {
a := &Agent{activeIdx: -1}
a.SetProviders([]core.ProviderConfig{
{
Name: "byok",
APIKey: "sk-test",
BaseURL: "https://provider.example/v1",
Model: "gpt-5.2",
CodexWireAPI: "responses",
CodexHTTPHeaders: map[string]string{"X-Provider": "test"},
Env: map[string]string{
"COPILOT_PROVIDER_TYPE": "anthropic",
"COPILOT_PROVIDER_WIRE_MODEL": "deployment-1",
"COPILOT_PROVIDER_BEARER_TOKEN": "bearer-token",
},
},
})
if !a.SetActiveProvider("byok") {
t.Fatal("SetActiveProvider(byok) = false, want true")
}
provider := a.providerConfigLocked()
if provider == nil {
t.Fatal("providerConfigLocked() = nil")
}
if provider.Type != "anthropic" {
t.Fatalf("Type = %q, want anthropic", provider.Type)
}
if provider.BaseURL != "https://provider.example/v1" {
t.Fatalf("BaseURL = %q", provider.BaseURL)
}
if provider.APIKey != "sk-test" {
t.Fatalf("APIKey = %q", provider.APIKey)
}
if provider.ModelID != "gpt-5.2" {
t.Fatalf("ModelID = %q", provider.ModelID)
}
if provider.WireAPI != "responses" {
t.Fatalf("WireAPI = %q", provider.WireAPI)
}
if provider.WireModel != "deployment-1" {
t.Fatalf("WireModel = %q", provider.WireModel)
}
if provider.BearerToken != "bearer-token" {
t.Fatalf("BearerToken = %q", provider.BearerToken)
}
if provider.Headers["X-Provider"] != "test" {
t.Fatalf("Headers = %v", provider.Headers)
}
}
func TestAgent_ProviderEnvLocked(t *testing.T) {
a := &Agent{activeIdx: -1}
a.SetProviders([]core.ProviderConfig{
{
Name: "byok",
APIKey: "sk-test",
BaseURL: "https://provider.example/v1",
Model: "gpt-5.2",
CodexWireAPI: "responses",
Env: map[string]string{"EXTRA": "1"},
},
})
if !a.SetActiveProvider("byok") {
t.Fatal("SetActiveProvider(byok) = false, want true")
}
env := envMap(a.providerEnvLocked())
if env["COPILOT_PROVIDER_BASE_URL"] != "https://provider.example/v1" {
t.Fatalf("COPILOT_PROVIDER_BASE_URL = %q", env["COPILOT_PROVIDER_BASE_URL"])
}
if env["COPILOT_PROVIDER_API_KEY"] != "sk-test" {
t.Fatalf("COPILOT_PROVIDER_API_KEY = %q", env["COPILOT_PROVIDER_API_KEY"])
}
if env["COPILOT_MODEL"] != "gpt-5.2" {
t.Fatalf("COPILOT_MODEL = %q", env["COPILOT_MODEL"])
}
if env["COPILOT_PROVIDER_WIRE_API"] != "responses" {
t.Fatalf("COPILOT_PROVIDER_WIRE_API = %q", env["COPILOT_PROVIDER_WIRE_API"])
}
if env["EXTRA"] != "1" {
t.Fatalf("EXTRA = %q", env["EXTRA"])
}
}
func TestAgent_ListSessions(t *testing.T) {
a := &Agent{}
sessions, err := a.ListSessions(context.Background())
if err != nil {
t.Fatalf("ListSessions() error = %v", err)
}
if sessions != nil {
t.Fatalf("ListSessions() = %v, want nil", sessions)
}
}
func TestAgent_CLIBinaryName(t *testing.T) {
a := &Agent{cliBin: "copilot"}
if got := a.CLIBinaryName(); got != "copilot" {
t.Fatalf("CLIBinaryName() = %q, want copilot", got)
}
}
func TestAgent_CLIDisplayName(t *testing.T) {
a := &Agent{}
if got := a.CLIDisplayName(); got != "GitHub Copilot" {
t.Fatalf("CLIDisplayName() = %q, want 'GitHub Copilot'", got)
}
}
func TestAgent_WorkspaceAgentOptions(t *testing.T) {
a := &Agent{mode: "bypassPermissions", model: "gpt-4o", cliBin: "copilot"}
opts := a.WorkspaceAgentOptions()
if opts["mode"] != "bypassPermissions" {
t.Fatalf("mode = %v, want bypassPermissions", opts["mode"])
}
if opts["model"] != "gpt-4o" {
t.Fatalf("model = %v, want gpt-4o", opts["model"])
}
// cliBin is "copilot" (default) so cli_path should not be set
if _, ok := opts["cli_path"]; ok {
t.Fatal("cli_path should not be set for default binary name")
}
// Custom CLI path should be included
a2 := &Agent{mode: "default", cliBin: "/custom/copilot"}
opts2 := a2.WorkspaceAgentOptions()
if opts2["cli_path"] != "/custom/copilot" {
t.Fatalf("cli_path = %v, want /custom/copilot", opts2["cli_path"])
}
}
// ---------------------------------------------------------------------------
// ListSessions / DeleteSession tests using a fake process helper
// ---------------------------------------------------------------------------
// TestAgent_ListSessions_NoBinary verifies graceful nil return when binary is missing.
func TestAgent_ListSessions_NoBinary(t *testing.T) {
a := &Agent{cliBin: "copilot-nonexistent-xyz", workDir: "."}
sessions, err := a.ListSessions(context.Background())
if err != nil {
t.Fatalf("ListSessions with missing binary returned error: %v", err)
}
if sessions != nil {
t.Fatalf("expected nil sessions, got %v", sessions)
}
}
// TestAgent_DeleteSession_NoBinary verifies graceful nil return when binary is missing.
func TestAgent_DeleteSession_NoBinary(t *testing.T) {
a := &Agent{cliBin: "copilot-nonexistent-xyz", workDir: "."}
if err := a.DeleteSession(context.Background(), "some-session-id"); err != nil {
t.Fatalf("DeleteSession with missing binary returned error: %v", err)
}
}
// TestAgent_DeleteSession_EmptyID verifies empty session ID returns nil immediately.
func TestAgent_DeleteSession_EmptyID(t *testing.T) {
a := &Agent{cliBin: "copilot-nonexistent-xyz", workDir: "."}
if err := a.DeleteSession(context.Background(), ""); err != nil {
t.Fatalf("DeleteSession with empty ID returned error: %v", err)
}
}
// TestAgent_ListSessions_RPC tests ListSessions by using a real fake-process
// approach: we re-exec the test binary as a "mock copilot" that speaks the
// JSON-RPC protocol over stdio and responds to ping + session.list.
//
// The fake process is selected by the CC_MOCK_COPILOT_MODE env var.
func TestAgent_ListSessions_RPC(t *testing.T) {
bin, err := os.Executable()
if err != nil {
t.Skip("cannot get test executable path")
}
// Point agent at the test binary itself acting as a mock copilot
a := &Agent{
cliBin: bin,
workDir: ".",
}
t.Setenv("CC_MOCK_COPILOT_MODE", "list_sessions")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sessions, err := a.ListSessions(ctx)
if err != nil {
t.Fatalf("ListSessions returned error: %v", err)
}
if len(sessions) != 2 {
t.Fatalf("expected 2 sessions, got %d: %v", len(sessions), sessions)
}
if sessions[0].ID != "sess-001" {
t.Fatalf("sessions[0].ID = %q, want sess-001", sessions[0].ID)
}
if sessions[1].ID != "sess-002" {
t.Fatalf("sessions[1].ID = %q, want sess-002", sessions[1].ID)
}
}
// TestAgent_DeleteSession_RPC tests DeleteSession using the same mock process approach.
func TestAgent_DeleteSession_RPC(t *testing.T) {
bin, err := os.Executable()
if err != nil {
t.Skip("cannot get test executable path")
}
a := &Agent{
cliBin: bin,
workDir: ".",
}
t.Setenv("CC_MOCK_COPILOT_MODE", "delete_session")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := a.DeleteSession(ctx, "sess-001"); err != nil {
t.Fatalf("DeleteSession returned error: %v", err)
}
}
func envMap(env []string) map[string]string {
out := make(map[string]string, len(env))
for _, entry := range env {
for i := range entry {
if entry[i] == '=' {
out[entry[:i]] = entry[i+1:]
break
}
}
}
return out
}
+69
View File
@@ -0,0 +1,69 @@
package copilot
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
)
func TestIntegration_CopilotFlow(t *testing.T) {
if os.Getenv("COPILOT_INTEGRATION") != "1" {
t.Skip("set COPILOT_INTEGRATION=1 to run")
}
if _, err := exec.LookPath("copilot"); err != nil {
t.Skip("copilot CLI not in PATH, skipping")
}
agent, err := New(map[string]any{
"work_dir": t.TempDir(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
session, err := agent.StartSession(ctx, "integration-test")
if err != nil {
t.Fatalf("StartSession: %v", err)
}
defer func() { _ = session.Close() }()
// Send a deterministic prompt
err = session.Send("Reply with exactly 'integration-ok' and nothing else", nil, nil)
if err != nil {
t.Fatalf("Send: %v", err)
}
// Collect events until done
var result string
timeout := time.After(25 * time.Second)
for {
select {
case ev, ok := <-session.Events():
if !ok {
goto done
}
switch ev.Type {
case core.EventText:
result += ev.Content
case core.EventResult:
result += ev.Content
goto done
}
case <-timeout:
t.Fatal("timeout waiting for response")
}
}
done:
t.Logf("Response: %s", result)
if !strings.Contains(strings.ToLower(result), "integration-ok") {
t.Errorf("expected 'integration-ok' in response, got: %s", result)
}
}
+233
View File
@@ -0,0 +1,233 @@
package copilot
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"sync"
"sync/atomic"
)
// jsonRPCRequest is a JSON-RPC 2.0 request.
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
// jsonRPCNotification is a JSON-RPC 2.0 notification (server push, no id).
type jsonRPCNotification struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// jsonRPCResponse is a JSON-RPC 2.0 response.
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
}
// jsonRPCError is the error object in a JSON-RPC 2.0 response.
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
func (e *jsonRPCError) Error() string {
return fmt.Sprintf("JSON-RPC error %d: %s", e.Code, e.Message)
}
// lspWriter writes Content-Length framed JSON-RPC messages.
type lspWriter struct {
w io.Writer
mu sync.Mutex
}
func newLSPWriter(w io.Writer) *lspWriter {
return &lspWriter{w: w}
}
func (lw *lspWriter) writeMessage(v any) error {
data, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data))
lw.mu.Lock()
defer lw.mu.Unlock()
if _, err := io.WriteString(lw.w, header); err != nil {
return fmt.Errorf("write header: %w", err)
}
if _, err := lw.w.Write(data); err != nil {
return fmt.Errorf("write body: %w", err)
}
return nil
}
// lspReader reads Content-Length framed JSON-RPC messages.
type lspReader struct {
reader *bufio.Reader
}
func newLSPReader(r io.Reader) *lspReader {
return &lspReader{reader: bufio.NewReaderSize(r, 64*1024)}
}
// readMessage reads one Content-Length framed message and returns the raw JSON body.
func (lr *lspReader) readMessage() ([]byte, error) {
contentLength := -1
for {
line, err := lr.reader.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("read header: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
// End of headers
break
}
if strings.HasPrefix(line, "Content-Length: ") {
val := strings.TrimPrefix(line, "Content-Length: ")
n, err := strconv.Atoi(strings.TrimSpace(val))
if err != nil {
return nil, fmt.Errorf("invalid Content-Length: %w", err)
}
contentLength = n
}
// Ignore other headers (Content-Type, etc.)
}
if contentLength < 0 {
return nil, fmt.Errorf("missing Content-Length header")
}
if contentLength > 10*1024*1024 {
return nil, fmt.Errorf("Content-Length too large: %d", contentLength)
}
body := make([]byte, contentLength)
if _, err := io.ReadFull(lr.reader, body); err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return body, nil
}
// rpcClient manages JSON-RPC request IDs and pending responses.
type rpcClient struct {
writer *lspWriter
nextID atomic.Int64
pendingMu sync.Mutex
pending map[int64]chan *jsonRPCResponse
}
func newRPCClient(w io.Writer) *rpcClient {
c := &rpcClient{
writer: newLSPWriter(w),
pending: make(map[int64]chan *jsonRPCResponse),
}
c.nextID.Store(1)
return c
}
// call sends a JSON-RPC request and returns the response channel.
func (c *rpcClient) call(method string, params any) (int64, <-chan *jsonRPCResponse) {
id := c.nextID.Add(1) - 1
ch := make(chan *jsonRPCResponse, 1)
c.pendingMu.Lock()
c.pending[id] = ch
c.pendingMu.Unlock()
req := jsonRPCRequest{
JSONRPC: "2.0",
ID: id,
Method: method,
Params: params,
}
if err := c.writer.writeMessage(req); err != nil {
c.pendingMu.Lock()
delete(c.pending, id)
c.pendingMu.Unlock()
ch <- &jsonRPCResponse{Error: &jsonRPCError{Code: -1, Message: err.Error()}}
return id, ch
}
return id, ch
}
// notify sends a JSON-RPC notification (no response expected).
func (c *rpcClient) notify(method string, params any) error {
// Notification: no ID field. We use a separate struct to omit it.
type notification struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
return c.writer.writeMessage(notification{
JSONRPC: "2.0",
Method: method,
Params: params,
})
}
// dispatch routes a response to the pending caller.
func (c *rpcClient) dispatch(resp *jsonRPCResponse) bool {
var id int64
if err := json.Unmarshal(resp.ID, &id); err != nil {
return false
}
c.pendingMu.Lock()
ch, ok := c.pending[id]
if ok {
delete(c.pending, id)
}
c.pendingMu.Unlock()
if ok {
ch <- resp
return true
}
return false
}
// respond sends a JSON-RPC 2.0 response to a server-to-client request.
func (c *rpcClient) respond(id json.RawMessage, result any, rpcErr *jsonRPCError) error {
type response struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
}
return c.writer.writeMessage(response{
JSONRPC: "2.0",
ID: id,
Result: result,
Error: rpcErr,
})
}
// cancelAll cancels all pending requests with the given error.
func (c *rpcClient) cancelAll(err error) {
c.pendingMu.Lock()
pending := c.pending
c.pending = make(map[int64]chan *jsonRPCResponse)
c.pendingMu.Unlock()
resp := &jsonRPCResponse{
Error: &jsonRPCError{Code: -1, Message: err.Error()},
}
for _, ch := range pending {
ch <- resp
}
}
+176
View File
@@ -0,0 +1,176 @@
package copilot
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/chenhg5/cc-connect/core"
)
func TestLSPWriter_WriteMessage(t *testing.T) {
var buf bytes.Buffer
w := newLSPWriter(&buf)
msg := jsonRPCRequest{JSONRPC: "2.0", ID: 1, Method: "ping"}
if err := w.writeMessage(msg); err != nil {
t.Fatalf("writeMessage: %v", err)
}
output := buf.String()
if !strings.HasPrefix(output, "Content-Length: ") {
t.Fatalf("output missing Content-Length header: %q", output)
}
if !strings.Contains(output, "\r\n\r\n") {
t.Fatalf("output missing header separator: %q", output)
}
// Verify JSON body
parts := strings.SplitN(output, "\r\n\r\n", 2)
var decoded jsonRPCRequest
if err := json.Unmarshal([]byte(parts[1]), &decoded); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if decoded.Method != "ping" {
t.Fatalf("method = %q, want ping", decoded.Method)
}
}
func TestLSPReader_ReadMessage(t *testing.T) {
body := `{"jsonrpc":"2.0","method":"test"}`
frame := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body)
r := newLSPReader(strings.NewReader(frame))
got, err := r.readMessage()
if err != nil {
t.Fatalf("readMessage: %v", err)
}
if string(got) != body {
t.Fatalf("body = %q, want %q", got, body)
}
}
func TestLSPReader_MultipleMessages(t *testing.T) {
body1 := `{"id":1}`
body2 := `{"id":2}`
frame := fmt.Sprintf("Content-Length: %d\r\n\r\n%sContent-Length: %d\r\n\r\n%s",
len(body1), body1, len(body2), body2)
r := newLSPReader(strings.NewReader(frame))
got1, err := r.readMessage()
if err != nil {
t.Fatalf("readMessage 1: %v", err)
}
if string(got1) != body1 {
t.Fatalf("msg1 = %q, want %q", got1, body1)
}
got2, err := r.readMessage()
if err != nil {
t.Fatalf("readMessage 2: %v", err)
}
if string(got2) != body2 {
t.Fatalf("msg2 = %q, want %q", got2, body2)
}
}
func TestLSPReader_MissingContentLength(t *testing.T) {
frame := "Some-Header: value\r\n\r\n{}"
r := newLSPReader(strings.NewReader(frame))
_, err := r.readMessage()
if err == nil {
t.Fatal("expected error for missing Content-Length")
}
}
func TestLSPReader_TooLargeContentLength(t *testing.T) {
frame := "Content-Length: 99999999999\r\n\r\n"
r := newLSPReader(strings.NewReader(frame))
_, err := r.readMessage()
if err == nil {
t.Fatal("expected error for oversized Content-Length")
}
}
func TestRPCClient_CallAndDispatch(t *testing.T) {
var buf bytes.Buffer
c := newRPCClient(&buf)
id, ch := c.call("test.method", map[string]string{"key": "val"})
// Simulate response
resp := &jsonRPCResponse{
JSONRPC: "2.0",
Result: json.RawMessage(`{"ok":true}`),
}
idBytes, _ := json.Marshal(id)
resp.ID = idBytes
if !c.dispatch(resp) {
t.Fatal("dispatch returned false")
}
got := <-ch
if got.Error != nil {
t.Fatalf("unexpected error: %v", got.Error)
}
if string(got.Result) != `{"ok":true}` {
t.Fatalf("result = %s, want {\"ok\":true}", got.Result)
}
}
func TestRPCClient_CancelAll(t *testing.T) {
var buf bytes.Buffer
c := newRPCClient(&buf)
_, ch := c.call("foo", nil)
c.cancelAll(fmt.Errorf("test cancel"))
got := <-ch
if got.Error == nil {
t.Fatal("expected error after cancelAll")
}
}
func TestRPCClient_Notify(t *testing.T) {
var buf bytes.Buffer
c := newRPCClient(&buf)
err := c.notify("permission.respond", map[string]string{"requestId": "123"})
if err != nil {
t.Fatalf("notify: %v", err)
}
output := buf.String()
if !strings.Contains(output, "permission.respond") {
t.Fatalf("output missing method: %q", output)
}
// Notifications should NOT have an id field
if strings.Contains(output, `"id"`) {
t.Fatalf("notification should not have id field: %q", output)
}
}
func TestSummarizeToolInput(t *testing.T) {
got := summarizeToolInput("shell", map[string]any{"command": "ls -la"})
if !strings.Contains(got, "ls -la") {
t.Fatalf("summarizeToolInput = %q, want to contain 'ls -la'", got)
}
// Nil input
if got := summarizeToolInput("test", nil); got != "" {
t.Fatalf("summarizeToolInput(nil) = %q, want empty", got)
}
// Long input gets truncated
longInput := map[string]any{"data": strings.Repeat("x", 300)}
got = summarizeToolInput("test", longInput)
if len(got) > 210 {
t.Fatalf("summarizeToolInput length = %d, should be truncated", len(got))
}
}
// Verify interface compliance at compile time
var _ core.ContextUsageReporter = (*copilotSession)(nil)
+118
View File
@@ -0,0 +1,118 @@
package copilot
import (
"encoding/json"
"fmt"
"io"
"os"
"testing"
"time"
)
// TestMain intercepts test binary re-executions used as a fake copilot process.
// When CC_MOCK_COPILOT_MODE is set, we run the mock server instead of tests.
func TestMain(m *testing.M) {
mode := os.Getenv("CC_MOCK_COPILOT_MODE")
switch mode {
case "list_sessions":
runMockCopilot(handleListSessions)
os.Exit(0)
case "delete_session":
runMockCopilot(handleDeleteSession)
os.Exit(0)
default:
os.Exit(m.Run())
}
}
// mockCopilotHandler is called for each incoming JSON-RPC request.
// It should write a framed JSON-RPC response to stdout.
type mockCopilotHandler func(method string, id json.RawMessage, params json.RawMessage, w *lspWriter)
// runMockCopilot reads Content-Length framed JSON-RPC messages from stdin,
// dispatches them to handler, and exits on EOF.
func runMockCopilot(handler mockCopilotHandler) {
reader := newLSPReader(os.Stdin)
writer := newLSPWriter(os.Stdout)
for {
body, err := reader.readMessage()
if err != nil {
if err == io.EOF {
return
}
return
}
var probe struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if json.Unmarshal(body, &probe) != nil {
continue
}
// Only handle requests (have ID), skip notifications
if len(probe.ID) == 0 {
continue
}
handler(probe.Method, probe.ID, body, writer)
}
}
func writeResponse(w *lspWriter, id json.RawMessage, result any) {
res, _ := json.Marshal(result)
_ = w.writeMessage(map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": json.RawMessage(res),
})
}
func writeError(w *lspWriter, id json.RawMessage, code int, msg string) {
_ = w.writeMessage(map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{"code": code, "message": msg},
})
}
// handleListSessions is the mock that responds to ping + session.list.
func handleListSessions(method string, id json.RawMessage, _ json.RawMessage, w *lspWriter) {
switch method {
case "ping":
writeResponse(w, id, map[string]any{"pong": true})
case "session.list":
summary1 := "First session"
summary2 := "Second session"
writeResponse(w, id, map[string]any{
"sessions": []map[string]any{
{
"sessionId": "sess-001",
"startTime": time.Now().Format(time.RFC3339),
"modifiedTime": time.Now().Format(time.RFC3339),
"summary": &summary1,
},
{
"sessionId": "sess-002",
"startTime": time.Now().Format(time.RFC3339),
"modifiedTime": time.Now().Format(time.RFC3339),
"summary": &summary2,
},
},
})
default:
writeError(w, id, -32601, fmt.Sprintf("method not found: %s", method))
}
}
// handleDeleteSession is the mock that responds to ping + session.delete.
func handleDeleteSession(method string, id json.RawMessage, _ json.RawMessage, w *lspWriter) {
switch method {
case "ping":
writeResponse(w, id, map[string]any{"pong": true})
case "session.delete":
writeResponse(w, id, map[string]any{"success": true})
default:
writeError(w, id, -32601, fmt.Sprintf("method not found: %s", method))
}
}
+37
View File
@@ -0,0 +1,37 @@
//go:build unix
package copilot
import (
"errors"
"os"
"os/exec"
"syscall"
)
func prepareCmdForKill(cmd *exec.Cmd) {
if cmd == nil {
return
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
func terminateCmd(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
}
func forceKillCmd(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, os.ErrProcessDone) && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
//go:build windows
package copilot
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
)
func prepareCmdForKill(cmd *exec.Cmd) {
if cmd == nil {
return
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP
}
func terminateCmd(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Signal(os.Interrupt)
}
func forceKillCmd(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
killCmd := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid))
output, err := killCmd.CombinedOutput()
if err == nil {
return nil
}
if bytes.Contains(bytes.ToLower(output), []byte("there is no running instance")) {
return nil
}
if bytes.Contains(bytes.ToLower(output), []byte("not found")) {
return nil
}
if killErr := cmd.Process.Kill(); killErr == nil || errors.Is(killErr, os.ErrProcessDone) {
return nil
} else {
return fmt.Errorf("taskkill failed: %w: %s; process kill fallback failed: %w", err, processKillOutput(output), killErr)
}
}
func processKillOutput(output []byte) string {
trimmed := strings.TrimSpace(string(output))
if trimmed == "" {
return "(empty output)"
}
return trimmed
}
+891
View File
@@ -0,0 +1,891 @@
package copilot
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/chenhg5/cc-connect/core"
)
// copilotSession manages a long-running Copilot CLI process using
// JSON-RPC 2.0 over Content-Length framed stdio.
type copilotSession struct {
cmd *exec.Cmd
rpc *rpcClient
reader *lspReader
events chan core.Event
sessionID atomic.Value // stores string - Copilot session ID
mode string // permission mode
model string
provider *copilotWireProviderConfig
workDir string
ctx context.Context
cancel context.CancelFunc
done chan struct{}
alive atomic.Bool
// autoApprove is set when mode == "bypassPermissions"
autoApprove atomic.Bool
// pendingPermissions maps Copilot requestId to JSON-RPC request ids
// for permission.request server-to-client RPC calls. eventPermissions
// tracks permission.requested session events, which are answered via
// session.permissions.handlePendingPermissionRequest.
pendingPermMu sync.Mutex
pendingPermissions map[string]json.RawMessage
eventPermissions map[string]struct{}
// context usage tracking
contextMu sync.RWMutex
contextUsage *core.ContextUsage
}
type copilotWireProviderConfig struct {
Type string `json:"type,omitempty"`
WireAPI string `json:"wireApi,omitempty"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey,omitempty"`
BearerToken string `json:"bearerToken,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
ModelID string `json:"modelId,omitempty"`
WireModel string `json:"wireModel,omitempty"`
}
type copilotSessionConfig struct {
SessionID string `json:"sessionId,omitempty"`
ClientName string `json:"clientName,omitempty"`
Model string `json:"model,omitempty"`
Provider *copilotWireProviderConfig `json:"provider,omitempty"`
RequestPermission *bool `json:"requestPermission,omitempty"`
WorkingDirectory string `json:"workingDirectory,omitempty"`
Streaming *bool `json:"streaming,omitempty"`
IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"`
EnvValueMode string `json:"envValueMode,omitempty"`
}
type copilotPermissionResult struct {
Kind string `json:"kind"`
Rules []any `json:"rules,omitempty"`
}
func newCopilotSession(ctx context.Context, workDir, cliBin, model, mode, resumeSessionID string, extraEnv []string, provider *copilotWireProviderConfig) (*copilotSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
args := []string{"--headless", "--stdio", "--no-auto-update"}
slog.Debug("copilotSession: starting", "bin", cliBin, "args", args, "dir", workDir)
cmd := exec.CommandContext(sessionCtx, cliBin, args...)
cmd.Dir = workDir
prepareCmdForKill(cmd)
env := os.Environ()
if len(extraEnv) > 0 {
env = core.MergeEnv(env, extraEnv)
}
cmd.Env = env
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("copilotSession: stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("copilotSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("copilotSession: start: %w", err)
}
cs := &copilotSession{
cmd: cmd,
rpc: newRPCClient(stdin),
reader: newLSPReader(stdout),
events: make(chan core.Event, 64),
mode: mode,
model: model,
provider: provider,
workDir: workDir,
ctx: sessionCtx,
cancel: cancel,
done: make(chan struct{}),
pendingPermissions: make(map[string]json.RawMessage),
eventPermissions: make(map[string]struct{}),
}
cs.alive.Store(true)
cs.autoApprove.Store(mode == "bypassPermissions")
if resumeSessionID != "" && resumeSessionID != core.ContinueSession {
cs.sessionID.Store(resumeSessionID)
}
// Start reading loop
go cs.readLoop(&stderrBuf)
// Perform handshake: ping then create/resume session
if err := cs.handshake(resumeSessionID); err != nil {
_ = cs.Close()
return nil, fmt.Errorf("copilotSession: handshake failed: %w", err)
}
return cs, nil
}
func (cs *copilotSession) handshake(resumeSessionID string) error {
// Step 1: Ping
_, pingCh := cs.rpc.call("ping", nil)
select {
case resp := <-pingCh:
if resp.Error != nil {
return fmt.Errorf("ping: %w", resp.Error)
}
slog.Debug("copilotSession: ping OK")
case <-time.After(10 * time.Second):
return fmt.Errorf("ping timeout")
case <-cs.ctx.Done():
return cs.ctx.Err()
}
// Step 2: Create or resume session
if resumeSessionID != "" && resumeSessionID != core.ContinueSession {
_, resumeCh := cs.rpc.call("session.resume", cs.sessionConfig(resumeSessionID))
select {
case resp := <-resumeCh:
if resp.Error != nil {
slog.Warn("copilotSession: resume failed, creating new session", "error", resp.Error)
return cs.createSession()
}
cs.sessionID.Store(resumeSessionID)
slog.Info("copilotSession: session resumed", "sessionId", resumeSessionID)
case <-time.After(10 * time.Second):
return fmt.Errorf("session.resume timeout")
case <-cs.ctx.Done():
return cs.ctx.Err()
}
} else {
return cs.createSession()
}
return nil
}
func (cs *copilotSession) createSession() error {
_, createCh := cs.rpc.call("session.create", cs.sessionConfig(newCopilotSessionID()))
select {
case resp := <-createCh:
if resp.Error != nil {
return fmt.Errorf("session.create: %w", resp.Error)
}
var result struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(resp.Result, &result); err != nil {
return fmt.Errorf("session.create decode: %w", err)
}
cs.sessionID.Store(result.SessionID)
slog.Info("copilotSession: session created", "sessionId", result.SessionID)
case <-time.After(10 * time.Second):
return fmt.Errorf("session.create timeout")
case <-cs.ctx.Done():
return cs.ctx.Err()
}
return nil
}
func (cs *copilotSession) sessionConfig(sessionID string) copilotSessionConfig {
requestPermission := true
streaming := true
includeSubAgentStreaming := true
return copilotSessionConfig{
SessionID: sessionID,
ClientName: "cc-connect",
Model: strings.TrimSpace(cs.model),
Provider: cs.provider,
RequestPermission: &requestPermission,
WorkingDirectory: cs.workDir,
Streaming: &streaming,
IncludeSubAgentStreamingEvents: &includeSubAgentStreaming,
EnvValueMode: "direct",
}
}
func newCopilotSessionID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("cc-connect-%d", time.Now().UnixNano())
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
func (cs *copilotSession) readLoop(stderrBuf *bytes.Buffer) {
defer func() {
cs.alive.Store(false)
cs.rpc.cancelAll(fmt.Errorf("process exited"))
// Wait for process exit
if err := cs.cmd.Wait(); err != nil {
stderrMsg := strings.TrimSpace(stderrBuf.String())
if stderrMsg != "" {
slog.Error("copilotSession: process failed", "error", err, "stderr", stderrMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
}
}
}
close(cs.events)
close(cs.done)
}()
for {
body, err := cs.reader.readMessage()
if err != nil {
if cs.ctx.Err() != nil {
return
}
slog.Error("copilotSession: read error", "error", err)
return
}
cs.handleMessage(body)
}
}
func (cs *copilotSession) handleMessage(body []byte) {
var probe struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if err := json.Unmarshal(body, &probe); err != nil {
slog.Debug("copilotSession: invalid JSON", "error", err)
return
}
hasID := len(probe.ID) > 0 && string(probe.ID) != "null"
hasMethod := probe.Method != ""
// server-to-client request: has both id AND method
if hasID && hasMethod {
cs.handleServerRequest(probe.ID, probe.Method, body)
return
}
// Response: has id but no method
if hasID && !hasMethod {
var resp jsonRPCResponse
if err := json.Unmarshal(body, &resp); err == nil {
cs.rpc.dispatch(&resp)
}
return
}
// Notification: no id
var notif jsonRPCNotification
if err := json.Unmarshal(body, &notif); err != nil {
slog.Debug("copilotSession: failed to parse notification", "error", err)
return
}
cs.handleNotification(notif)
}
// handleServerRequest handles server-to-client RPC requests (have both id and method).
func (cs *copilotSession) handleServerRequest(rpcID json.RawMessage, method string, body []byte) {
slog.Debug("copilotSession: server-to-client request", "method", method)
switch method {
case "session.event":
var req struct {
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(body, &req); err != nil {
slog.Debug("copilotSession: failed to parse session.event request", "error", err)
_ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32602, Message: "invalid params"})
return
}
cs.handleSessionEvent(req.Params)
_ = cs.rpc.respond(rpcID, map[string]any{}, nil)
case "permission.request":
var req struct {
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(body, &req); err != nil {
slog.Debug("copilotSession: failed to parse permission.request", "error", err)
_ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32602, Message: "invalid params"})
return
}
permReq, err := parsePermissionRequest(req.Params)
if err != nil || permReq.RequestID == "" {
slog.Debug("copilotSession: failed to parse permission.request params", "error", err)
_ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32602, Message: "invalid params"})
return
}
if permReq.Tool == "" {
permReq.Tool = permReq.ToolName
}
if permReq.Input == nil {
permReq.Input = permReq.Arguments
}
cs.pendingPermMu.Lock()
cs.pendingPermissions[permReq.RequestID] = rpcID
cs.pendingPermMu.Unlock()
cs.handlePermissionRequest(req.Params)
default:
// Send method-not-found response for unhandled server-to-client requests
_ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32601, Message: "method not found"})
}
}
func (cs *copilotSession) handleNotification(notif jsonRPCNotification) {
slog.Debug("copilotSession: notification", "method", notif.Method)
switch notif.Method {
case "session.event":
cs.handleSessionEvent(notif.Params)
case "permission.request":
// Copilot may send permission.request as a notification (no id).
cs.handlePermissionRequest(notif.Params)
default:
slog.Debug("copilotSession: unhandled notification", "method", notif.Method)
}
}
// sessionEvent wraps Copilot's session.event notification params.
type sessionEvent struct {
SessionID string `json:"sessionId"`
Event sessionEventInner `json:"event"`
}
type sessionEventInner struct {
Type string `json:"type"`
Kind string `json:"kind"`
Data json.RawMessage `json:"data"`
}
func (cs *copilotSession) handleSessionEvent(params json.RawMessage) {
var evt sessionEvent
if err := json.Unmarshal(params, &evt); err != nil {
slog.Debug("copilotSession: failed to parse session.event", "error", err)
return
}
eventType := evt.Event.Type
if eventType == "" {
eventType = evt.Event.Kind
}
eventType = normalizeCopilotEventType(eventType)
slog.Debug("copilotSession: session event", "type", eventType, "sessionId", evt.SessionID)
switch eventType {
case "assistant.message_delta":
content := copilotEventText(evt.Event.Data)
if content != "" {
e := core.Event{Type: core.EventText, Content: content}
select {
case cs.events <- e:
case <-cs.ctx.Done():
}
}
case "assistant.message":
usage := copilotEventUsage(evt.Event.Data)
if len(evt.Event.Data) > 0 {
cs.addContextUsage(usage.inputTokens, usage.outputTokens)
e := core.Event{
Type: core.EventResult,
SessionID: cs.CurrentSessionID(),
Done: true,
OutputTokens: usage.outputTokens,
}
select {
case cs.events <- e:
case <-cs.ctx.Done():
}
}
case "assistant.usage":
usage := copilotEventUsage(evt.Event.Data)
cs.addContextUsage(usage.inputTokens, usage.outputTokens)
case "permission.requested":
cs.handlePermissionRequestedEvent(evt.Event.Data)
case "assistant.turn_start":
slog.Debug("copilotSession: turn started")
case "assistant.turn_end":
slog.Debug("copilotSession: turn ended")
case "session.idle":
slog.Debug("copilotSession: session idle")
case "session.skills_loaded", "session.mcp_servers_loaded", "session.tools_updated", "session.usage_info", "session.title_changed":
slog.Debug("copilotSession: capabilities updated", "type", eventType)
case "system.message":
// System prompt notification, can be ignored
slog.Debug("copilotSession: system message received")
case "pending_messages.modified", "custom_agents_updated", "session.start", "assistant.message_start", "assistant.streaming_delta", "response", "messages_snapshot":
// Known informational events; no action required
slog.Debug("copilotSession: informational event", "type", eventType)
default:
slog.Debug("copilotSession: unhandled session event", "type", eventType)
}
}
// permissionRequest represents Copilot's permission.request notification.
type permissionRequest struct {
SessionID string `json:"sessionId"`
RequestID string `json:"requestId"`
Tool string `json:"tool"`
ToolName string `json:"toolName"`
Kind string `json:"kind"`
Input map[string]any `json:"input"`
Arguments map[string]any `json:"arguments"`
}
func (cs *copilotSession) handlePermissionRequest(params json.RawMessage) {
req, err := parsePermissionRequest(params)
if err != nil {
slog.Debug("copilotSession: failed to parse permission request", "error", err)
return
}
cs.emitPermissionRequest(req)
}
func normalizeCopilotEventType(eventType string) string {
switch eventType {
case "assistant_message":
return "assistant.message"
case "assistant_usage":
return "assistant.usage"
case "assistant_turn_start":
return "assistant.turn_start"
case "assistant_turn_end":
return "assistant.turn_end"
case "assistant_message_start":
return "assistant.message_start"
case "assistant_message_delta":
return "assistant.message_delta"
case "assistant_streaming_delta":
return "assistant.streaming_delta"
}
return eventType
}
func copilotEventText(raw json.RawMessage) string {
var data struct {
DeltaContent string `json:"deltaContent"`
Content string `json:"content"`
Text string `json:"text"`
Delta string `json:"delta"`
}
if err := json.Unmarshal(raw, &data); err != nil {
return ""
}
for _, s := range []string{data.DeltaContent, data.Content, data.Text, data.Delta} {
if s != "" {
return s
}
}
return ""
}
type copilotUsage struct {
inputTokens int
outputTokens int
}
func copilotEventUsage(raw json.RawMessage) copilotUsage {
var data struct {
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
Usage struct {
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(raw, &data); err != nil {
return copilotUsage{}
}
usage := copilotUsage{inputTokens: data.InputTokens, outputTokens: data.OutputTokens}
if usage.inputTokens == 0 {
usage.inputTokens = data.PromptTokens
}
if usage.outputTokens == 0 {
usage.outputTokens = data.CompletionTokens
}
if usage.inputTokens == 0 {
usage.inputTokens = data.Usage.InputTokens
}
if usage.inputTokens == 0 {
usage.inputTokens = data.Usage.PromptTokens
}
if usage.outputTokens == 0 {
usage.outputTokens = data.Usage.OutputTokens
}
if usage.outputTokens == 0 {
usage.outputTokens = data.Usage.CompletionTokens
}
return usage
}
func (cs *copilotSession) addContextUsage(inputTokens, outputTokens int) {
if inputTokens == 0 && outputTokens == 0 {
return
}
cs.contextMu.Lock()
defer cs.contextMu.Unlock()
if cs.contextUsage == nil {
cs.contextUsage = &core.ContextUsage{}
}
cs.contextUsage.OutputTokens += outputTokens
cs.contextUsage.InputTokens += inputTokens
cs.contextUsage.TotalTokens = cs.contextUsage.InputTokens + cs.contextUsage.OutputTokens
cs.contextUsage.UsedTokens = cs.contextUsage.TotalTokens
}
func (cs *copilotSession) handlePermissionRequestedEvent(data json.RawMessage) {
var evt struct {
RequestID string `json:"requestId"`
PermissionRequest json.RawMessage `json:"permissionRequest"`
}
if err := json.Unmarshal(data, &evt); err != nil || evt.RequestID == "" || len(evt.PermissionRequest) == 0 {
slog.Debug("copilotSession: failed to parse permission.requested event", "error", err)
return
}
req, err := parsePermissionRequest(evt.PermissionRequest)
if err != nil {
slog.Debug("copilotSession: failed to parse permission.requested payload", "error", err)
return
}
req.RequestID = evt.RequestID
if req.Input == nil {
req.Input = rawJSONMap(evt.PermissionRequest)
}
cs.pendingPermMu.Lock()
if cs.eventPermissions == nil {
cs.eventPermissions = make(map[string]struct{})
}
cs.eventPermissions[req.RequestID] = struct{}{}
cs.pendingPermMu.Unlock()
cs.emitPermissionRequest(req)
}
func (cs *copilotSession) emitPermissionRequest(req permissionRequest) {
if req.Tool == "" {
req.Tool = req.ToolName
}
if req.Tool == "" {
req.Tool = strings.ReplaceAll(req.Kind, "_", ".")
}
if req.Input == nil {
req.Input = req.Arguments
}
slog.Info("copilotSession: permission request", "requestId", req.RequestID, "tool", req.Tool)
if cs.autoApprove.Load() {
slog.Debug("copilotSession: auto-approving", "requestId", req.RequestID, "tool", req.Tool)
_ = cs.RespondPermission(req.RequestID, core.PermissionResult{
Behavior: "allow",
UpdatedInput: req.Input,
})
return
}
inputSummary := summarizeToolInput(req.Tool, req.Input)
evt := core.Event{
Type: core.EventPermissionRequest,
RequestID: req.RequestID,
ToolName: req.Tool,
ToolInput: inputSummary,
ToolInputRaw: req.Input,
}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
}
}
func parsePermissionRequest(params json.RawMessage) (permissionRequest, error) {
var req permissionRequest
if err := json.Unmarshal(params, &req); err != nil {
return permissionRequest{}, err
}
if req.RequestID != "" || req.Tool != "" || req.ToolName != "" || req.Kind != "" {
return req, nil
}
var wrapped struct {
SessionID string `json:"sessionId"`
PermissionRequest permissionRequest `json:"permissionRequest"`
}
if err := json.Unmarshal(params, &wrapped); err != nil {
return permissionRequest{}, err
}
wrapped.PermissionRequest.SessionID = wrapped.SessionID
return wrapped.PermissionRequest, nil
}
func rawJSONMap(raw json.RawMessage) map[string]any {
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return nil
}
return out
}
func summarizeToolInput(tool string, input map[string]any) string {
if input == nil {
return ""
}
b, err := json.Marshal(input)
if err != nil {
return ""
}
s := string(b)
if len(s) > 200 {
s = s[:200] + "..."
}
return s
}
// Send sends a user message to the running Copilot process.
func (cs *copilotSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
if !cs.alive.Load() {
return fmt.Errorf("session process is not running")
}
// Handle images: save to temp dir and append file references
if len(images) > 0 {
imgPaths, err := saveImagesToTempDir(cs.workDir, images)
if err != nil {
slog.Warn("copilotSession: failed to save images", "error", err)
} else {
prompt = core.AppendFileRefs(prompt, imgPaths)
}
}
// Handle files
if len(files) > 0 {
filePaths := core.SaveFilesToDisk(cs.workDir, files)
prompt = core.AppendFileRefs(prompt, filePaths)
}
sid := cs.CurrentSessionID()
if sid == "" {
return fmt.Errorf("no active session")
}
params := map[string]any{
"sessionId": sid,
"prompt": prompt,
}
_, sendCh := cs.rpc.call("session.send", params)
// Don't block - just validate the send was accepted
go func() {
select {
case resp := <-sendCh:
if resp.Error != nil {
slog.Error("copilotSession: send failed", "error", resp.Error)
if cs.alive.Load() {
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("send: %s", resp.Error.Message)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
}
}
} else {
slog.Debug("copilotSession: send accepted")
}
case <-cs.ctx.Done():
}
}()
return nil
}
// RespondPermission sends a permission decision back to the Copilot process.
// If the permission request came as a server-to-client RPC request (has a JSON-RPC id),
// a proper JSON-RPC response is sent; otherwise a notification is used.
func (cs *copilotSession) RespondPermission(requestID string, result core.PermissionResult) error {
if !cs.alive.Load() {
return fmt.Errorf("session process is not running")
}
decision := copilotPermissionResult{Kind: copilotPermissionKind(result)}
// Check if this permission came as a server-to-client RPC request
cs.pendingPermMu.Lock()
rpcID, hasRPCID := cs.pendingPermissions[requestID]
if hasRPCID {
delete(cs.pendingPermissions, requestID)
}
_, hasEventPermission := cs.eventPermissions[requestID]
if hasEventPermission {
delete(cs.eventPermissions, requestID)
}
cs.pendingPermMu.Unlock()
if hasRPCID {
return cs.rpc.respond(rpcID, decision, nil)
}
if hasEventPermission {
_, ch := cs.rpc.call("session.permissions.handlePendingPermissionRequest", map[string]any{
"sessionId": cs.CurrentSessionID(),
"requestId": requestID,
"result": decision,
})
go func() {
select {
case resp := <-ch:
if resp.Error != nil {
slog.Error("copilotSession: permission response failed", "error", resp.Error)
}
case <-cs.ctx.Done():
}
}()
return nil
}
return cs.rpc.notify("permission.respond", map[string]any{
"requestId": requestID,
"result": decision,
})
}
func copilotPermissionKind(result core.PermissionResult) string {
if result.Behavior == "allow" {
return "approve-once"
}
if strings.TrimSpace(result.Message) == "denied: no active user turn" {
return "user-not-available"
}
return "reject"
}
func (cs *copilotSession) Events() <-chan core.Event {
return cs.events
}
func (cs *copilotSession) CurrentSessionID() string {
v, _ := cs.sessionID.Load().(string)
return v
}
func (cs *copilotSession) Alive() bool {
return cs.alive.Load()
}
// GetContextUsage implements core.ContextUsageReporter.
func (cs *copilotSession) GetContextUsage() *core.ContextUsage {
cs.contextMu.RLock()
defer cs.contextMu.RUnlock()
if cs.contextUsage == nil {
return nil
}
cu := *cs.contextUsage
return &cu
}
var (
copilotGracefulTimeout = 3 * time.Second
copilotSigtermWait = 5 * time.Second
)
func (cs *copilotSession) Close() error {
// Close stdin to signal EOF
if w, ok := cs.rpc.writer.w.(io.Closer); ok {
_ = w.Close()
}
select {
case <-cs.done:
slog.Info("copilotSession: exited cleanly after stdin close")
return nil
case <-time.After(copilotGracefulTimeout):
slog.Warn("copilotSession: graceful stop timed out, sending SIGTERM")
}
terminateCmd(cs.cmd)
select {
case <-cs.done:
slog.Info("copilotSession: exited after SIGTERM")
return nil
case <-time.After(copilotSigtermWait):
slog.Warn("copilotSession: SIGTERM timed out, sending SIGKILL")
}
cs.cancel()
_ = forceKillCmd(cs.cmd)
<-cs.done
return nil
}
// saveImagesToTempDir saves image attachments to a temp directory under workDir
// and returns their file paths for inclusion in the prompt.
func saveImagesToTempDir(workDir string, images []core.ImageAttachment) ([]string, error) {
imgDir := filepath.Join(workDir, ".cc-connect", "images")
if err := os.MkdirAll(imgDir, 0o755); err != nil {
return nil, fmt.Errorf("saveImagesToTempDir: mkdir: %w", err)
}
paths := make([]string, 0, len(images))
for i, img := range images {
ext := imageExt(img.MimeType)
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
fpath := filepath.Join(imgDir, fname)
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
return nil, fmt.Errorf("saveImagesToTempDir: write %s: %w", fname, err)
}
paths = append(paths, fpath)
}
return paths, nil
}
func imageExt(mimeType string) string {
switch strings.ToLower(mimeType) {
case "image/png":
return ".png"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
default:
return ".jpg"
}
}
+608
View File
@@ -0,0 +1,608 @@
package copilot
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/chenhg5/cc-connect/core"
)
func TestHandleSessionEvent_AssistantMessage(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &copilotSession{
events: make(chan core.Event, 10),
ctx: ctx,
cancel: cancel,
}
cs.sessionID.Store("test-session")
cs.alive.Store(true)
data, _ := json.Marshal(map[string]any{
"content": "Hello, world!",
"outputTokens": 42,
"inputTokens": 100,
})
cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{
SessionID: "test-session",
Event: sessionEventInner{Type: "assistant.message", Data: data},
})))
select {
case evt := <-cs.events:
if evt.Type != core.EventResult {
t.Fatalf("event type = %v, want EventResult", evt.Type)
}
if evt.Content != "" {
t.Fatalf("content = %q, want empty final content because text was streamed", evt.Content)
}
if evt.OutputTokens != 42 {
t.Fatalf("outputTokens = %d, want 42", evt.OutputTokens)
}
default:
t.Fatal("no event emitted")
}
// Verify context usage was updated
usage := cs.GetContextUsage()
if usage == nil {
t.Fatal("context usage is nil")
}
if usage.OutputTokens != 42 {
t.Fatalf("usage.OutputTokens = %d, want 42", usage.OutputTokens)
}
if usage.InputTokens != 100 {
t.Fatalf("usage.InputTokens = %d, want 100", usage.InputTokens)
}
if usage.TotalTokens != 142 {
t.Fatalf("usage.TotalTokens = %d, want 142", usage.TotalTokens)
}
}
func TestHandleSessionEvent_MessageDelta(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &copilotSession{
events: make(chan core.Event, 10),
ctx: ctx,
cancel: cancel,
}
cs.alive.Store(true)
data, _ := json.Marshal(map[string]any{
"deltaContent": "chunk",
})
cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{
Event: sessionEventInner{Type: "assistant.message_delta", Data: data},
})))
select {
case evt := <-cs.events:
if evt.Type != core.EventText {
t.Fatalf("event type = %v, want EventText", evt.Type)
}
if evt.Content != "chunk" {
t.Fatalf("content = %q, want 'chunk'", evt.Content)
}
default:
t.Fatal("no event emitted")
}
}
func TestHandleSessionEvent_KindAndCurrentCopilotEvents(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &copilotSession{events: make(chan core.Event, 10), ctx: ctx, cancel: cancel}
cs.sessionID.Store("test-session")
delta, _ := json.Marshal(map[string]any{"content": "integration-ok"})
cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{
SessionID: "test-session",
Event: sessionEventInner{Kind: "assistant.message_delta", Data: delta},
})))
select {
case evt := <-cs.events:
if evt.Type != core.EventText || evt.Content != "integration-ok" {
t.Fatalf("event = %+v, want integration text", evt)
}
default:
t.Fatal("no streaming event emitted")
}
usage, _ := json.Marshal(map[string]any{"usage": map[string]any{"prompt_tokens": 12, "completion_tokens": 3}})
cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{
SessionID: "test-session",
Event: sessionEventInner{Kind: "assistant.usage", Data: usage},
})))
final, _ := json.Marshal(map[string]any{"content": "integration-ok"})
cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{
SessionID: "test-session",
Event: sessionEventInner{Kind: "assistant.message", Data: final},
})))
select {
case evt := <-cs.events:
if evt.Type != core.EventResult || !evt.Done {
t.Fatalf("event = %+v, want done result", evt)
}
default:
t.Fatal("no final event emitted")
}
got := cs.GetContextUsage()
if got == nil || got.InputTokens != 12 || got.OutputTokens != 3 {
t.Fatalf("usage = %+v, want 12 input and 3 output", got)
}
}
func TestHandlePermissionRequest_AutoApprove(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &copilotSession{
events: make(chan core.Event, 10),
ctx: ctx,
cancel: cancel,
}
cs.alive.Store(true)
cs.autoApprove.Store(true)
// rpc needs to be set for RespondPermission to work - but in auto-approve
// mode we call notify which writes to rpc.writer. Use a no-op writer.
var nopBuf nopWriter
cs.rpc = newRPCClient(&nopBuf)
params, _ := json.Marshal(permissionRequest{
RequestID: "req-1",
Tool: "shell",
Input: map[string]any{"command": "ls"},
})
cs.handlePermissionRequest(params)
// In auto-approve mode, no event should be forwarded to the engine
select {
case evt := <-cs.events:
t.Fatalf("unexpected event in auto-approve mode: %v", evt)
default:
// expected - no event
}
}
func TestHandlePermissionRequest_AskUser(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &copilotSession{
events: make(chan core.Event, 10),
ctx: ctx,
cancel: cancel,
}
cs.alive.Store(true)
cs.autoApprove.Store(false)
params, _ := json.Marshal(permissionRequest{
RequestID: "req-2",
Tool: "file_write",
Input: map[string]any{"path": "/tmp/test.txt"},
})
cs.handlePermissionRequest(params)
select {
case evt := <-cs.events:
if evt.Type != core.EventPermissionRequest {
t.Fatalf("event type = %v, want EventPermissionRequest", evt.Type)
}
if evt.RequestID != "req-2" {
t.Fatalf("requestID = %q, want req-2", evt.RequestID)
}
if evt.ToolName != "file_write" {
t.Fatalf("toolName = %q, want file_write", evt.ToolName)
}
default:
t.Fatal("no event emitted for permission request")
}
}
func TestHandlePermissionRequest_WrappedCopilotShape(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &copilotSession{
events: make(chan core.Event, 10),
ctx: ctx,
cancel: cancel,
}
cs.alive.Store(true)
params, _ := json.Marshal(map[string]any{
"sessionId": "sess-1",
"permissionRequest": map[string]any{
"requestId": "req-3",
"toolName": "write",
"arguments": map[string]any{"path": "README.md"},
},
})
cs.handlePermissionRequest(params)
select {
case evt := <-cs.events:
if evt.RequestID != "req-3" {
t.Fatalf("requestID = %q, want req-3", evt.RequestID)
}
if evt.ToolName != "write" {
t.Fatalf("toolName = %q, want write", evt.ToolName)
}
if evt.ToolInputRaw["path"] != "README.md" {
t.Fatalf("raw input = %v, want path README.md", evt.ToolInputRaw)
}
default:
t.Fatal("no event emitted for wrapped permission request")
}
}
func TestHandleServerRequest_PermissionRequestWrappedCopilotShape(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var buf bytes.Buffer
cs := &copilotSession{
events: make(chan core.Event, 10),
ctx: ctx,
cancel: cancel,
rpc: newRPCClient(&buf),
pendingPermissions: make(map[string]json.RawMessage),
}
cs.alive.Store(true)
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 11,
"method": "permission.request",
"params": map[string]any{
"sessionId": "sess-1",
"permissionRequest": map[string]any{
"requestId": "req-4",
"toolName": "shell",
"arguments": map[string]any{"command": "pwd"},
},
},
})
cs.handleServerRequest(json.RawMessage(`11`), "permission.request", body)
select {
case evt := <-cs.events:
if evt.RequestID != "req-4" {
t.Fatalf("requestID = %q, want req-4", evt.RequestID)
}
if evt.ToolName != "shell" {
t.Fatalf("toolName = %q, want shell", evt.ToolName)
}
default:
t.Fatal("no event emitted for server permission request")
}
cs.pendingPermMu.Lock()
_, ok := cs.pendingPermissions["req-4"]
cs.pendingPermMu.Unlock()
if !ok {
t.Fatal("pending permission req-4 not recorded")
}
}
func TestHandleSessionEvent_PermissionRequestedEvent(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cs := &copilotSession{
events: make(chan core.Event, 10),
ctx: ctx,
cancel: cancel,
eventPermissions: make(map[string]struct{}),
}
cs.alive.Store(true)
data, _ := json.Marshal(map[string]any{
"requestId": "req-event-1",
"permissionRequest": map[string]any{
"kind": "shell",
"command": "pwd",
},
})
cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{
SessionID: "sess-1",
Event: sessionEventInner{Type: "permission.requested", Data: data},
})))
select {
case evt := <-cs.events:
if evt.Type != core.EventPermissionRequest {
t.Fatalf("event type = %v, want EventPermissionRequest", evt.Type)
}
if evt.RequestID != "req-event-1" {
t.Fatalf("requestID = %q, want req-event-1", evt.RequestID)
}
if evt.ToolName != "shell" {
t.Fatalf("toolName = %q, want shell", evt.ToolName)
}
if evt.ToolInputRaw["command"] != "pwd" {
t.Fatalf("raw input = %v, want command pwd", evt.ToolInputRaw)
}
default:
t.Fatal("no event emitted for permission.requested")
}
cs.pendingPermMu.Lock()
_, ok := cs.eventPermissions["req-event-1"]
cs.pendingPermMu.Unlock()
if !ok {
t.Fatal("event permission req-event-1 not recorded")
}
}
func TestRespondPermission_EventPermissionUsesSessionScopedRPC(t *testing.T) {
var buf bytes.Buffer
cs := &copilotSession{
rpc: newRPCClient(&buf),
eventPermissions: map[string]struct{}{"req-event-2": {}},
ctx: context.Background(),
}
cs.alive.Store(true)
cs.sessionID.Store("sess-2")
if err := cs.RespondPermission("req-event-2", core.PermissionResult{Behavior: "allow"}); err != nil {
t.Fatalf("RespondPermission: %v", err)
}
_, body := decodeFramedMessage(t, buf.String())
var req struct {
Method string `json:"method"`
Params struct {
SessionID string `json:"sessionId"`
RequestID string `json:"requestId"`
Result struct {
Kind string `json:"kind"`
} `json:"result"`
} `json:"params"`
}
if err := json.Unmarshal([]byte(body), &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if req.Method != "session.permissions.handlePendingPermissionRequest" {
t.Fatalf("method = %q", req.Method)
}
if req.Params.SessionID != "sess-2" || req.Params.RequestID != "req-event-2" {
t.Fatalf("params = %+v", req.Params)
}
if req.Params.Result.Kind != "approve-once" {
t.Fatalf("kind = %q, want approve-once", req.Params.Result.Kind)
}
}
func TestSessionConfig_MatchesCopilotCreateResumeShape(t *testing.T) {
cs := &copilotSession{
model: "gpt-5.2",
workDir: "/work/project",
provider: &copilotWireProviderConfig{
Type: "anthropic",
BaseURL: "https://api.anthropic.com",
APIKey: "sk-ant",
ModelID: "claude-sonnet-4.6",
WireAPI: "responses",
Headers: map[string]string{"X-Test": "1"},
WireModel: "deployment-a",
},
}
cfg := cs.sessionConfig("sess-1")
if cfg.SessionID != "sess-1" {
t.Fatalf("SessionID = %q, want sess-1", cfg.SessionID)
}
if cfg.ClientName != "cc-connect" {
t.Fatalf("ClientName = %q, want cc-connect", cfg.ClientName)
}
if cfg.Model != "gpt-5.2" {
t.Fatalf("Model = %q, want gpt-5.2", cfg.Model)
}
if cfg.WorkingDirectory != "/work/project" {
t.Fatalf("WorkingDirectory = %q, want /work/project", cfg.WorkingDirectory)
}
if cfg.RequestPermission == nil || !*cfg.RequestPermission {
t.Fatalf("RequestPermission = %v, want true", cfg.RequestPermission)
}
if cfg.Streaming == nil || !*cfg.Streaming {
t.Fatalf("Streaming = %v, want true", cfg.Streaming)
}
if cfg.IncludeSubAgentStreamingEvents == nil || !*cfg.IncludeSubAgentStreamingEvents {
t.Fatalf("IncludeSubAgentStreamingEvents = %v, want true", cfg.IncludeSubAgentStreamingEvents)
}
if cfg.EnvValueMode != "direct" {
t.Fatalf("EnvValueMode = %q, want direct", cfg.EnvValueMode)
}
if cfg.Provider == nil || cfg.Provider.Type != "anthropic" || cfg.Provider.WireModel != "deployment-a" {
t.Fatalf("Provider = %+v, want anthropic/deployment-a", cfg.Provider)
}
}
func TestRespondPermission_RPCUsesCopilotResultShape(t *testing.T) {
var buf bytes.Buffer
cs := &copilotSession{
rpc: newRPCClient(&buf),
pendingPermissions: map[string]json.RawMessage{"req-1": json.RawMessage(`7`)},
}
cs.alive.Store(true)
if err := cs.RespondPermission("req-1", core.PermissionResult{Behavior: "allow"}); err != nil {
t.Fatalf("RespondPermission: %v", err)
}
_, body := decodeFramedMessage(t, buf.String())
var resp struct {
ID int `json:"id"`
Result struct {
Kind string `json:"kind"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(body), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp.ID != 7 {
t.Fatalf("id = %d, want 7", resp.ID)
}
if resp.Result.Kind != "approve-once" {
t.Fatalf("kind = %q, want approve-once", resp.Result.Kind)
}
}
func TestRespondPermission_NotifyUsesCopilotResultShape(t *testing.T) {
var buf bytes.Buffer
cs := &copilotSession{rpc: newRPCClient(&buf)}
cs.alive.Store(true)
if err := cs.RespondPermission("req-2", core.PermissionResult{Behavior: "deny", Message: "no"}); err != nil {
t.Fatalf("RespondPermission: %v", err)
}
_, body := decodeFramedMessage(t, buf.String())
var notif struct {
Method string `json:"method"`
Params struct {
RequestID string `json:"requestId"`
Result struct {
Kind string `json:"kind"`
} `json:"result"`
} `json:"params"`
}
if err := json.Unmarshal([]byte(body), &notif); err != nil {
t.Fatalf("unmarshal notification: %v", err)
}
if notif.Method != "permission.respond" {
t.Fatalf("method = %q, want permission.respond", notif.Method)
}
if notif.Params.RequestID != "req-2" {
t.Fatalf("requestID = %q, want req-2", notif.Params.RequestID)
}
if notif.Params.Result.Kind != "reject" {
t.Fatalf("kind = %q, want reject", notif.Params.Result.Kind)
}
}
func TestCopilotSession_CurrentSessionID(t *testing.T) {
cs := &copilotSession{}
if got := cs.CurrentSessionID(); got != "" {
t.Fatalf("CurrentSessionID() = %q, want empty", got)
}
cs.sessionID.Store("abc-123")
if got := cs.CurrentSessionID(); got != "abc-123" {
t.Fatalf("CurrentSessionID() = %q, want abc-123", got)
}
}
func TestCopilotSession_GetContextUsage_Nil(t *testing.T) {
cs := &copilotSession{}
if got := cs.GetContextUsage(); got != nil {
t.Fatalf("GetContextUsage() = %v, want nil", got)
}
}
// helpers
func mustMarshal(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
func decodeFramedMessage(t *testing.T, frame string) (string, string) {
t.Helper()
parts := bytes.SplitN([]byte(frame), []byte("\r\n\r\n"), 2)
if len(parts) != 2 {
t.Fatalf("frame missing header separator: %q", frame)
}
return string(parts[0]), string(parts[1])
}
type nopWriter struct{}
func (nopWriter) Write(p []byte) (int, error) { return len(p), nil }
func TestSession_SendWithFiles(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tmpDir := t.TempDir()
var nopBuf nopWriter
cs := &copilotSession{
events: make(chan core.Event, 10),
ctx: ctx,
cancel: cancel,
workDir: tmpDir,
rpc: newRPCClient(&nopBuf),
}
cs.alive.Store(true)
cs.sessionID.Store("test-session")
files := []core.FileAttachment{
{FileName: "test.txt", Data: []byte("hello file")},
}
// Send returns error because rpc.call tries to write to nopBuf then
// the goroutine gets a nil channel. Just check it doesn't panic.
// The file should still be saved before Send tries to contact the process.
_ = cs.Send("please review", nil, files)
}
func TestSession_SendWithImages(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tmpDir := t.TempDir()
var nopBuf nopWriter
cs := &copilotSession{
events: make(chan core.Event, 10),
ctx: ctx,
cancel: cancel,
workDir: tmpDir,
rpc: newRPCClient(&nopBuf),
}
cs.alive.Store(true)
cs.sessionID.Store("test-session")
images := []core.ImageAttachment{
{MimeType: "image/png", Data: []byte{0x89, 0x50, 0x4e, 0x47}},
}
// Just ensure no panic and images dir gets created
_ = cs.Send("describe image", images, nil)
imgDir := tmpDir + "/.cc-connect/images"
entries, _ := os.ReadDir(imgDir)
if len(entries) != 1 {
t.Fatalf("expected 1 image file, got %d", len(entries))
}
}
func TestSaveImagesToTempDir(t *testing.T) {
tmpDir := t.TempDir()
images := []core.ImageAttachment{
{MimeType: "image/jpeg", Data: []byte{0xff, 0xd8}},
{MimeType: "image/png", Data: []byte{0x89, 0x50}},
{MimeType: "image/webp", Data: []byte{0x52, 0x49}},
{MimeType: "image/gif", Data: []byte{0x47, 0x49}},
}
paths, err := saveImagesToTempDir(tmpDir, images)
if err != nil {
t.Fatalf("saveImagesToTempDir() error = %v", err)
}
if len(paths) != 4 {
t.Fatalf("expected 4 paths, got %d", len(paths))
}
exts := []string{".jpg", ".png", ".webp", ".gif"}
for i, p := range paths {
if filepath.Ext(p) != exts[i] {
t.Errorf("path[%d] ext = %q, want %q", i, filepath.Ext(p), exts[i])
}
if _, err := os.Stat(p); err != nil {
t.Errorf("file %s not found: %v", p, err)
}
}
}