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