diff --git a/docs/feature-copilot/copilot-session-issues-and-improvements.md b/docs/feature-copilot/copilot-session-issues-and-improvements.md new file mode 100644 index 000000000..fcdddcec3 --- /dev/null +++ b/docs/feature-copilot/copilot-session-issues-and-improvements.md @@ -0,0 +1,191 @@ +# Copilot Session: Issues & Improvement Notes + +> 来源:实际使用 agent-deck 编排 5 个并行 copilot 会话(bid-sys 项目)时的实测记录。 +> 日期:2026-05-02 + +--- + +## 一、会话创建问题 + +### 1.1 `-c` 参数的行为差异 + +`-c` 传入方式不同,会导致 agent-deck 识别出不同的 `tool` 类型,行为有本质区别: + +| 写法 | 识别 tool 类型 | `-g ` 可用? | 备注 | +|------|--------------|---------------------|------| +| `-c copilot` | `tool:copilot` | ❌ 报 "path does not exist" | group 名被当成路径解析 | +| `-c "copilot --allow-all --model claude-opus-4.6"` | `tool:shell` | ✅(从 path 自动推断) | 带空格的字符串走 shell 路径 | + +**根因**:命令字符串带空格时,agent-deck 将其视为 shell 命令(`tool:shell`),单词时才走 `tool:copilot` 专属路径。两条路径的 group 解析逻辑不同。 + +**当前可用写法**: +```bash +agent-deck add \ + -t "my-session" \ + -c "copilot --allow-all --model claude-opus-4.6" + # 不要传 -g,让 agent-deck 从 path 自动推断 group +``` + +--- + +### 1.2 `-g ` 解析失败 + +**现象**:从 conductor session(cwd = `~/.agent-deck/conductor/ops`)执行: +```bash +agent-deck add /path/to/worktree -t "title" -c copilot -g "bid-sys" +# Error: path does not exist +``` + +**根因**:agent-deck 将 `-g` 的值当作相对于当前 cwd 的**路径**去解析,而不是 group 标识符。从 conductor cwd 出发找不到 `bid-sys` 路径,因此报错。 + +**解决方案**:不传 `-g`,直接传 worktree 的完整绝对路径作为 ``,agent-deck 会从路径的 grandparent 目录名自动推断 group(例如 `.worktrees/` 的父目录 `bid-sys`)。 + +**建议改进**:`-g` 应优先尝试按 group 名称匹配已有 group,失败再尝试路径解析;或明确区分 `--group-name` 和 `--group-path` 两个 flag。 + +--- + +### 1.3 `-extra-arg` 不支持 copilot + +**现象**:尝试通过 `-extra-arg` 向 copilot 传递 `--allow-all --model claude-opus-4.6`,不生效。 + +**根因**:`-extra-arg` 仅对 `-c claude` 路径有效,不支持其他 tool 类型。 + +**当前绕过方案**:将参数直接内嵌到 `-c` 字符串中:`-c "copilot --allow-all --model claude-opus-4.6"` + +**建议改进**:`-extra-arg`(或等价的 `--args`)应对所有 tool 类型生效,或提供 per-tool config block(如 `[tools.copilot]`)。 + +--- + +### 1.4 `agent-deck launch` vs `agent-deck add` + +**现象**:在 conductor cwd(`~/.agent-deck/conductor/ops`)执行 `agent-deck launch -g "bid-sys"` 时因上述 group 路径解析问题报错。 + +**解决方案**:改用分步操作: +```bash +agent-deck add -t "title" -c "copilot --allow-all --model claude-opus-4.6" +agent-deck session start +# 通信靠 tmux send-keys,不能用 session send +``` + +--- + +## 二、会话通信问题 + +### 2.1 `agent-deck session send` 对 copilot 会话无效 + +**现象**: +```bash +agent-deck session send my-copilot-session "do something" +# 命令返回成功,但 copilot 收不到消息 +``` + +**根因**:copilot CLI 没有 ACP(Agent Communication Protocol)集成。 +根据 [issue #556](https://github.com/asheshgoplani/agent-deck/issues/556),agent-deck 对 claude/codex/gemini 实现了 hook-based lifecycle tracking(activity detection、session-id capture、`--resume` 等),但 copilot 只是基础进程 spawn,没有这些 hook。 + +**当前绕过方案**:用 `tmux send-keys` 直接向 copilot TUI 注入按键: +```bash +tmux send-keys -t <tmux_session_name> "your message here" Enter +``` + +**建议改进**:为 copilot 实现 hook 集成(参考 issue #556 中 owner 的回复),或至少支持 stdin 管道注入。 + +--- + +### 2.2 `tmux send-keys` 的 Enter 不被 copilot TUI 处理(关键 blocker) + +**现象**: +```bash +tmux send-keys -t sess "Read AGENTS.md and implement all tasks" Enter +# 文字出现在 copilot 输入框,但按下 Enter 后消息不被提交 +``` + +**根因**:copilot TUI 使用类 readline 的交互界面,对来自 tmux 控制模式(control mode)的虚拟 `Enter` key event 处理方式不同于真实键盘输入。 + +**已知可行的绕过方案**(按可靠性排序): + +1. **用 `\r` 替代 `Enter`**(carriage return): + ```bash + tmux send-keys -t sess "your message" $'\r' + # 或 + tmux send-keys -t sess "your message"$'\r' + ``` + +2. **先 `C-c` 清空输入框,逐字符发送后用 `\r` 提交**: + ```bash + tmux send-keys -t sess C-c + sleep 0.2 + # 逐字符发送(对特殊字符更安全) + echo "your message" | while IFS= read -rn1 char; do + tmux send-keys -t sess "$char" + sleep 0.02 + done + tmux send-keys -t sess $'\r' + ``` + +3. **使用 `/task` 命令前缀**(如 copilot CLI 支持): + ```bash + tmux send-keys -t sess "/task Read AGENTS.md and implement all tasks" $'\r' + ``` + +4. **改用非交互模式**(最可靠,但失去持续对话能力): + ```bash + copilot -p "Read AGENTS.md and implement all tasks" + ``` + +--- + +## 三、首次启动问题 + +### 3.1 新目录下的文件夹访问授权弹窗 + +**现象**:copilot 在新目录首次启动时,会弹出交互式授权弹窗: +``` +Do you want to allow access to this directory? +❯ 1. Yes + 2. Yes, and don't ask again for `<tool>` in this directory + 3. No +``` + +脚本化场景下,如果不处理这个弹窗,后续所有操作都会阻塞。 + +**处理方式**: +```bash +# 启动后等待弹窗出现,发送 "2" 选择"记住该目录" +sleep 3 +tmux send-keys -t sess "2" Enter +``` + +--- + +## 四、模型参数问题 + +### 4.1 模型名称格式 + +**有效格式**(在 status bar 中显示为 "Claude Opus 4.6"): +```bash +-c "copilot --allow-all --model claude-opus-4.6" +``` + +**无效格式**:`claude-opus-4.5`(当时没有此模型的额度/支持时报错) + +**建议**:agent-deck 可以在会话详情中暴露 `model` 字段(目前只能从 tmux status bar 目视确认),便于脚本化验证。 + +--- + +## 五、改进建议汇总 + +| 优先级 | 问题 | 建议 | +|--------|------|------| +| P0 | `session send` 对 copilot 无效 | 实现 copilot hook 集成(stdin 管道或 ACP) | +| P0 | `tmux send-keys` Enter 不提交 | 文档说明用 `\r`;或 agent-deck 内部改用 `\r` | +| P1 | `-g` 解析逻辑混乱 | 区分 group name 和 group path,或先按 name 匹配 | +| P1 | `-extra-arg` 仅限 claude | 扩展到所有 tool 类型,或支持 per-tool config block | +| P2 | 首次启动弹窗无法自动化 | 支持 `--allow-all` 时自动接受目录授权,或提供 `--no-interactive` flag | +| P2 | model 字段不可查询 | 在 `session show --json` 中暴露 `model` 字段 | + +--- + +## 六、参考 + +- [issue #556: Add support for GitHub Copilot CLI](https://github.com/asheshgoplani/agent-deck/issues/556) +- [GitHub Copilot CLI best practices: configure allowed tools](https://docs.github.com/en/copilot/how-tos/copilot-cli/cli-best-practices#configure-allowed-tools) diff --git a/internal/session/copilot_hooks.go b/internal/session/copilot_hooks.go new file mode 100644 index 000000000..3994cfd6a --- /dev/null +++ b/internal/session/copilot_hooks.go @@ -0,0 +1,300 @@ +package session + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// getCopilotHomeDir returns the Copilot CLI config/state directory. +// Respects COPILOT_CONFIG_DIR env var, falling back to ~/.copilot. +func getCopilotHomeDir() string { + if dir := strings.TrimSpace(os.Getenv("COPILOT_CONFIG_DIR")); dir != "" { + return dir + } + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), ".copilot") + } + return filepath.Join(home, ".copilot") +} + +// getCopilotSessionStateDir returns the session state directory. +func getCopilotSessionStateDir() string { + return filepath.Join(getCopilotHomeDir(), "session-state") +} + +// copilotSessionStartEvent represents the first event in a Copilot events.jsonl. +type copilotSessionStartEvent struct { + Type string `json:"type"` + Data struct { + SessionID string `json:"sessionId"` + Context struct { + CWD string `json:"cwd"` + } `json:"context"` + StartTime string `json:"startTime"` // ISO 8601 + } `json:"data"` + Timestamp string `json:"timestamp"` // ISO 8601 +} + +// detectCopilotSessionFromDisk scans ~/.copilot/session-state/ for the most +// recent session started from the given working directory after startedAfter. +// Returns the session ID or empty string if no match. +func detectCopilotSessionFromDisk(cwd string, startedAfter time.Time) string { + sessionsDir := getCopilotSessionStateDir() + entries, err := os.ReadDir(sessionsDir) + if err != nil { + return "" + } + + type candidate struct { + sessionID string + startTime time.Time + } + var candidates []candidate + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + sessionID := entry.Name() + eventsPath := filepath.Join(sessionsDir, sessionID, "events.jsonl") + + // Quick stat check — skip old sessions without reading + info, err := os.Stat(eventsPath) + if err != nil || info.ModTime().Before(startedAfter) { + continue + } + + evt := readCopilotSessionStart(eventsPath) + if evt == nil || evt.Data.SessionID == "" { + continue + } + + // Parse start time + ts, err := time.Parse(time.RFC3339Nano, evt.Data.StartTime) + if err != nil { + ts, err = time.Parse(time.RFC3339, evt.Data.StartTime) + if err != nil { + continue + } + } + + if ts.Before(startedAfter) { + continue + } + + // Match by working directory (normalize trailing slashes) + eventCWD := strings.TrimRight(evt.Data.Context.CWD, "/") + targetCWD := strings.TrimRight(cwd, "/") + if eventCWD != targetCWD { + continue + } + + candidates = append(candidates, candidate{ + sessionID: evt.Data.SessionID, + startTime: ts, + }) + } + + if len(candidates) == 0 { + return "" + } + + // Return the most recent session + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].startTime.After(candidates[j].startTime) + }) + return candidates[0].sessionID +} + +// readCopilotSessionStart reads the first line of events.jsonl and parses +// the session.start event. Returns nil if the file is missing, empty, or +// the first event is not session.start. +func readCopilotSessionStart(eventsPath string) *copilotSessionStartEvent { + f, err := os.Open(eventsPath) + if err != nil { + return nil + } + defer f.Close() + + // Read only the first line (session.start is always first) + buf := make([]byte, 0, 4096) + tmp := make([]byte, 1) + for { + n, err := f.Read(tmp) + if n > 0 { + if tmp[0] == '\n' { + break + } + buf = append(buf, tmp[0]) + } + if err != nil { + break + } + // Safety limit: first line should not exceed 32KB + if len(buf) > 32*1024 { + return nil + } + } + + if len(buf) == 0 { + return nil + } + + var evt copilotSessionStartEvent + if err := json.Unmarshal(buf, &evt); err != nil { + return nil + } + + if evt.Type != "session.start" { + return nil + } + + return &evt +} + +// copilotSessionHasConversationData checks whether a Copilot session has +// meaningful conversation (user turns) in its events.jsonl. Used to decide +// whether --resume is appropriate vs starting fresh. +func copilotSessionHasConversationData(sessionID string) bool { + if sessionID == "" { + return false + } + eventsPath := filepath.Join(getCopilotSessionStateDir(), sessionID, "events.jsonl") + info, err := os.Stat(eventsPath) + if err != nil { + return false + } + // A fresh session has ~2-4KB for session.start + system.message. + // Any user interaction adds user.message + assistant.* events. + // Use 8KB as heuristic — anything larger likely has conversation data. + return info.Size() > 8*1024 +} + +// buildCopilotCommand builds the copilot CLI command for an Instance. +// Handles new sessions, resume, model selection, and auto-approve mode. +func buildCopilotCommand(i *Instance) string { + envPrefix := i.buildEnvSourceCommand() + + // Determine model flag + modelFlag := "" + if i.CopilotModel != "" { + modelFlag = " --model " + i.CopilotModel + } else if i.CopilotSessionID == "" { + // Only apply default model for NEW sessions (not resumes) + userConfig, _ := LoadUserConfig() + if userConfig != nil && userConfig.Copilot.DefaultModel != "" { + modelFlag = " --model " + userConfig.Copilot.DefaultModel + } + } + + // Determine allow-all flag + allowAllFlag := "" + if i.CopilotAllowAll { + allowAllFlag = " --allow-all" + } else { + userConfig, _ := LoadUserConfig() + if userConfig != nil && userConfig.Copilot.AllowAll { + allowAllFlag = " --allow-all" + } + } + + baseCmd := i.Command + if baseCmd == "" { + baseCmd = "copilot" + } + + // If we already have a session ID with conversation data, resume + if i.CopilotSessionID != "" && copilotSessionHasConversationData(i.CopilotSessionID) { + sessionLog.Info("copilot resume", + slog.String("instance_id", i.ID), + slog.String("session_id", i.CopilotSessionID), + slog.String("reason", "conversation_data_present"), + ) + return envPrefix + fmt.Sprintf( + "%s --resume %s%s%s", + baseCmd, + i.CopilotSessionID, + modelFlag, + allowAllFlag, + ) + } + + // Start fresh + sessionLog.Info("copilot fresh", + slog.String("instance_id", i.ID), + slog.String("reason", "fresh_session"), + ) + return envPrefix + fmt.Sprintf( + "%s%s%s", + baseCmd, + modelFlag, + allowAllFlag, + ) +} + +// detectCopilotSessionAsync detects the Copilot session ID asynchronously +// after the copilot process has started. Scans ~/.copilot/session-state/ +// for the most recent session matching this instance's working directory. +func (i *Instance) detectCopilotSessionAsync() { + // Wait for copilot to initialize and write session.start + time.Sleep(2 * time.Second) + + cwd := i.EffectiveWorkingDir() + startedAfter := time.Now().Add(-30 * time.Second) // generous window + if i.CopilotStartedAt > 0 { + startedAfter = time.UnixMilli(i.CopilotStartedAt).Add(-2 * time.Second) + } + + delays := []time.Duration{0, 2 * time.Second, 3 * time.Second} + for attempt, delay := range delays { + if delay > 0 { + time.Sleep(delay) + } + + sessionID := detectCopilotSessionFromDisk(cwd, startedAfter) + if sessionID != "" { + i.CopilotSessionID = sessionID + i.CopilotDetectedAt = time.Now() + + // Propagate to tmux env for restart + if i.tmuxSession != nil { + if err := i.tmuxSession.SetEnvironment("COPILOT_SESSION_ID", sessionID); err != nil { + sessionLog.Warn("copilot_set_env_failed", slog.String("error", err.Error())) + } + } + + sessionLog.Debug("copilot_session_detected", + slog.String("session_id", sessionID), + slog.Int("attempt", attempt+1), + ) + return + } + + sessionLog.Debug("copilot_session_not_found", + slog.Int("attempt", attempt+1), + slog.Int("total", len(delays)), + ) + } + + sessionLog.Warn("copilot_detection_failed", slog.Int("attempts", len(delays))) +} + +// GetCopilotOptions returns CopilotOptions from the instance's tool options. +func (i *Instance) GetCopilotOptions() *CopilotOptions { + if len(i.ToolOptionsJSON) == 0 { + return nil + } + opts, err := UnmarshalCopilotOptions(i.ToolOptionsJSON) + if err != nil { + return nil + } + return opts +} diff --git a/internal/session/copilot_session_test.go b/internal/session/copilot_session_test.go new file mode 100644 index 000000000..60daf3f30 --- /dev/null +++ b/internal/session/copilot_session_test.go @@ -0,0 +1,353 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDetectCopilotSessionFromDisk(t *testing.T) { + // Create a temporary session-state directory + tmpDir := t.TempDir() + + // Override getCopilotHomeDir for testing + origEnv := os.Getenv("COPILOT_CONFIG_DIR") + os.Setenv("COPILOT_CONFIG_DIR", tmpDir) + defer os.Setenv("COPILOT_CONFIG_DIR", origEnv) + + sessionID := "test-session-12345678" + cwd := "/Users/testuser/projects/myapp" + startTime := time.Now().Add(-5 * time.Second) + + // Create session directory with events.jsonl + sessionDir := filepath.Join(tmpDir, "session-state", sessionID) + if err := os.MkdirAll(sessionDir, 0755); err != nil { + t.Fatal(err) + } + + event := map[string]interface{}{ + "type": "session.start", + "data": map[string]interface{}{ + "sessionId": sessionID, + "context": map[string]interface{}{ + "cwd": cwd, + }, + "startTime": startTime.Format(time.RFC3339Nano), + }, + "timestamp": startTime.Format(time.RFC3339Nano), + } + eventJSON, _ := json.Marshal(event) + eventsPath := filepath.Join(sessionDir, "events.jsonl") + if err := os.WriteFile(eventsPath, append(eventJSON, '\n'), 0644); err != nil { + t.Fatal(err) + } + + // Test: should find the session + found := detectCopilotSessionFromDisk(cwd, startTime.Add(-10*time.Second)) + if found != sessionID { + t.Errorf("expected session %q, got %q", sessionID, found) + } + + // Test: different cwd should not match + found = detectCopilotSessionFromDisk("/Users/testuser/other", startTime.Add(-10*time.Second)) + if found != "" { + t.Errorf("expected empty, got %q", found) + } + + // Test: startedAfter in the future should not match + found = detectCopilotSessionFromDisk(cwd, time.Now().Add(1*time.Hour)) + if found != "" { + t.Errorf("expected empty for future startedAfter, got %q", found) + } +} + +func TestDetectCopilotSessionFromDisk_MostRecent(t *testing.T) { + tmpDir := t.TempDir() + + origEnv := os.Getenv("COPILOT_CONFIG_DIR") + os.Setenv("COPILOT_CONFIG_DIR", tmpDir) + defer os.Setenv("COPILOT_CONFIG_DIR", origEnv) + + cwd := "/Users/testuser/projects/myapp" + oldTime := time.Now().Add(-10 * time.Second) + newTime := time.Now().Add(-2 * time.Second) + + // Create two sessions with the same cwd + for _, tc := range []struct { + id string + time time.Time + }{ + {"old-session-aaaa", oldTime}, + {"new-session-bbbb", newTime}, + } { + sessionDir := filepath.Join(tmpDir, "session-state", tc.id) + if err := os.MkdirAll(sessionDir, 0755); err != nil { + t.Fatal(err) + } + event := map[string]interface{}{ + "type": "session.start", + "data": map[string]interface{}{ + "sessionId": tc.id, + "context": map[string]interface{}{"cwd": cwd}, + "startTime": tc.time.Format(time.RFC3339Nano), + }, + "timestamp": tc.time.Format(time.RFC3339Nano), + } + eventJSON, _ := json.Marshal(event) + eventsPath := filepath.Join(sessionDir, "events.jsonl") + if err := os.WriteFile(eventsPath, append(eventJSON, '\n'), 0644); err != nil { + t.Fatal(err) + } + } + + // Should return the most recent + found := detectCopilotSessionFromDisk(cwd, oldTime.Add(-1*time.Second)) + if found != "new-session-bbbb" { + t.Errorf("expected new-session-bbbb, got %q", found) + } +} + +func TestReadCopilotSessionStart(t *testing.T) { + tmpDir := t.TempDir() + eventsPath := filepath.Join(tmpDir, "events.jsonl") + + // Valid session.start event + event := `{"type":"session.start","data":{"sessionId":"abc-123","context":{"cwd":"/tmp/test"},"startTime":"2026-05-01T10:00:00.000Z"},"timestamp":"2026-05-01T10:00:00.000Z"}` + if err := os.WriteFile(eventsPath, []byte(event+"\n"), 0644); err != nil { + t.Fatal(err) + } + + evt := readCopilotSessionStart(eventsPath) + if evt == nil { + t.Fatal("expected non-nil event") + } + if evt.Data.SessionID != "abc-123" { + t.Errorf("expected abc-123, got %q", evt.Data.SessionID) + } + if evt.Data.Context.CWD != "/tmp/test" { + t.Errorf("expected /tmp/test, got %q", evt.Data.Context.CWD) + } +} + +func TestReadCopilotSessionStart_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + eventsPath := filepath.Join(tmpDir, "events.jsonl") + + if err := os.WriteFile(eventsPath, []byte("not json\n"), 0644); err != nil { + t.Fatal(err) + } + + evt := readCopilotSessionStart(eventsPath) + if evt != nil { + t.Error("expected nil for invalid JSON") + } +} + +func TestReadCopilotSessionStart_WrongType(t *testing.T) { + tmpDir := t.TempDir() + eventsPath := filepath.Join(tmpDir, "events.jsonl") + + event := `{"type":"user.message","data":{"content":"hello"}}` + if err := os.WriteFile(eventsPath, []byte(event+"\n"), 0644); err != nil { + t.Fatal(err) + } + + evt := readCopilotSessionStart(eventsPath) + if evt != nil { + t.Error("expected nil for non-session.start event") + } +} + +func TestReadCopilotSessionStart_EmptyFile(t *testing.T) { + tmpDir := t.TempDir() + eventsPath := filepath.Join(tmpDir, "events.jsonl") + + if err := os.WriteFile(eventsPath, []byte(""), 0644); err != nil { + t.Fatal(err) + } + + evt := readCopilotSessionStart(eventsPath) + if evt != nil { + t.Error("expected nil for empty file") + } +} + +func TestCopilotSessionHasConversationData(t *testing.T) { + tmpDir := t.TempDir() + + origEnv := os.Getenv("COPILOT_CONFIG_DIR") + os.Setenv("COPILOT_CONFIG_DIR", tmpDir) + defer os.Setenv("COPILOT_CONFIG_DIR", origEnv) + + sessionID := "conv-test-session" + sessionDir := filepath.Join(tmpDir, "session-state", sessionID) + if err := os.MkdirAll(sessionDir, 0755); err != nil { + t.Fatal(err) + } + + eventsPath := filepath.Join(sessionDir, "events.jsonl") + + // Small file (no conversation) + if err := os.WriteFile(eventsPath, make([]byte, 1024), 0644); err != nil { + t.Fatal(err) + } + if copilotSessionHasConversationData(sessionID) { + t.Error("expected false for small file") + } + + // Large file (has conversation) + if err := os.WriteFile(eventsPath, make([]byte, 16*1024), 0644); err != nil { + t.Fatal(err) + } + if !copilotSessionHasConversationData(sessionID) { + t.Error("expected true for large file") + } + + // Empty session ID + if copilotSessionHasConversationData("") { + t.Error("expected false for empty session ID") + } +} + +func TestCopilotOptions_ToArgs_Extended(t *testing.T) { + tests := []struct { + name string + opts CopilotOptions + expected []string + }{ + { + name: "new with model", + opts: CopilotOptions{SessionMode: "new", Model: "claude-opus-4.6"}, + expected: []string{"--model", "claude-opus-4.6"}, + }, + { + name: "new with allow-all", + opts: CopilotOptions{SessionMode: "new", AllowAll: true}, + expected: []string{"--allow-all"}, + }, + { + name: "resume with model and allow-all", + opts: CopilotOptions{SessionMode: "resume", ResumeSessionID: "s1", Model: "gpt-5", AllowAll: true}, + expected: []string{"--resume", "s1", "--model", "gpt-5", "--allow-all"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := tt.opts.ToArgs() + if len(args) == 0 && len(tt.expected) == 0 { + return + } + if len(args) != len(tt.expected) { + t.Errorf("expected %d args, got %d: %v", len(tt.expected), len(args), args) + return + } + for i, a := range args { + if a != tt.expected[i] { + t.Errorf("arg[%d]: expected %q, got %q", i, tt.expected[i], a) + } + } + }) + } +} + +func TestNewCopilotOptions_WithModelAndAllowAll(t *testing.T) { + config := &UserConfig{} + config.Copilot.DefaultModel = "claude-opus-4.6" + config.Copilot.AllowAll = true + + opts := NewCopilotOptions(config) + if opts.Model != "claude-opus-4.6" { + t.Errorf("expected model claude-opus-4.6, got %q", opts.Model) + } + if !opts.AllowAll { + t.Error("expected AllowAll true") + } + if opts.SessionMode != "new" { + t.Errorf("expected session mode new, got %q", opts.SessionMode) + } +} + +func TestNewCopilotOptions_NilConfig(t *testing.T) { + opts := NewCopilotOptions(nil) + if opts.Model != "" { + t.Errorf("expected empty model, got %q", opts.Model) + } + if opts.AllowAll { + t.Error("expected AllowAll false") + } +} + +func TestBuildCopilotCommand_Fresh(t *testing.T) { + inst := &Instance{ + Tool: "copilot", + Command: "copilot", + } + + cmd := buildCopilotCommand(inst) + if !strings.HasSuffix(cmd, "copilot") { + t.Errorf("expected command ending with 'copilot', got %q", cmd) + } +} + +func TestBuildCopilotCommand_WithModel(t *testing.T) { + inst := &Instance{ + Tool: "copilot", + Command: "copilot", + CopilotModel: "claude-opus-4.6", + } + + cmd := buildCopilotCommand(inst) + if !strings.HasSuffix(cmd, "copilot --model claude-opus-4.6") { + t.Errorf("expected command ending with 'copilot --model claude-opus-4.6', got %q", cmd) + } +} + +func TestBuildCopilotCommand_WithAllowAll(t *testing.T) { + inst := &Instance{ + Tool: "copilot", + Command: "copilot", + CopilotAllowAll: true, + } + + cmd := buildCopilotCommand(inst) + if !strings.HasSuffix(cmd, "copilot --allow-all") { + t.Errorf("expected command ending with 'copilot --allow-all', got %q", cmd) + } +} + +func TestBuildCopilotCommand_Resume(t *testing.T) { + tmpDir := t.TempDir() + origEnv := os.Getenv("COPILOT_CONFIG_DIR") + os.Setenv("COPILOT_CONFIG_DIR", tmpDir) + defer os.Setenv("COPILOT_CONFIG_DIR", origEnv) + + // Create session with enough data + sessionID := "resume-session-test" + sessionDir := filepath.Join(tmpDir, "session-state", sessionID) + if err := os.MkdirAll(sessionDir, 0755); err != nil { + t.Fatal(err) + } + eventsPath := filepath.Join(sessionDir, "events.jsonl") + // Write a large file to simulate conversation data + if err := os.WriteFile(eventsPath, make([]byte, 16*1024), 0644); err != nil { + t.Fatal(err) + } + + inst := &Instance{ + Tool: "copilot", + Command: "copilot", + CopilotSessionID: sessionID, + CopilotModel: "gpt-5", + CopilotAllowAll: true, + } + + cmd := buildCopilotCommand(inst) + expected := "copilot --resume " + sessionID + " --model gpt-5 --allow-all" + if !strings.HasSuffix(cmd, expected) { + t.Errorf("expected command ending with %q, got %q", expected, cmd) + } +} diff --git a/internal/session/copilot_test.go b/internal/session/copilot_test.go index 50924032b..3166f5e0a 100644 --- a/internal/session/copilot_test.go +++ b/internal/session/copilot_test.go @@ -72,7 +72,11 @@ func TestNewCopilotOptions_Defaults(t *testing.T) { func TestNewCopilotOptions_WithConfig(t *testing.T) { cfg := &UserConfig{ - Copilot: CopilotSettings{EnvFile: "/tmp/copilot.env"}, + Copilot: CopilotSettings{ + EnvFile: "/tmp/copilot.env", + DefaultModel: "gpt-5", + AllowAll: true, + }, } opts := NewCopilotOptions(cfg) if opts == nil { @@ -81,6 +85,12 @@ func TestNewCopilotOptions_WithConfig(t *testing.T) { if opts.SessionMode != "new" { t.Errorf("SessionMode = %q, want %q", opts.SessionMode, "new") } + if opts.Model != "gpt-5" { + t.Errorf("Model = %q, want %q", opts.Model, "gpt-5") + } + if !opts.AllowAll { + t.Error("AllowAll = false, want true") + } } func TestCopilotOptions_MarshalUnmarshalRoundtrip(t *testing.T) { diff --git a/internal/session/instance.go b/internal/session/instance.go index 2d634306f..c3982b18d 100644 --- a/internal/session/instance.go +++ b/internal/session/instance.go @@ -141,6 +141,13 @@ type Instance struct { // It is intentionally transient and never persisted. pendingCodexRestartWarning string `json:"-"` + // GitHub Copilot CLI integration + CopilotSessionID string `json:"copilot_session_id,omitempty"` + CopilotDetectedAt time.Time `json:"copilot_detected_at,omitempty"` + CopilotStartedAt int64 `json:"-"` // Unix millis when we started Copilot (for session matching, not persisted) + CopilotModel string `json:"copilot_model,omitempty"` // Active model for this session + CopilotAllowAll bool `json:"copilot_allow_all,omitempty"` // Per-session --allow-all override + // Latest user input for context (extracted from session files) LatestPrompt string `json:"latest_prompt,omitempty"` Notes string `json:"notes,omitempty"` @@ -2318,6 +2325,10 @@ func (i *Instance) Start() error { } case i.Tool == "gemini": command = i.buildGeminiCommand(i.Command) + case i.Tool == "copilot": + command = buildCopilotCommand(i) + // Record start time for session ID detection (Unix millis) + i.CopilotStartedAt = time.Now().UnixMilli() case i.Tool == "opencode": command = i.buildOpenCodeCommand(i.Command) // Record start time for session ID detection (Unix millis) @@ -2395,6 +2406,10 @@ func (i *Instance) Start() error { } // OpenCode and Codex IDs are detected asynchronously; SyncSessionIDsToTmux() handles // propagation once they are available. + // Copilot session ID propagation (if already known from prior session) + if i.CopilotSessionID != "" { + _ = i.tmuxSession.SetEnvironment("COPILOT_SESSION_ID", i.CopilotSessionID) + } // Propagate COLORFGBG into the tmux session environment so that any new // shell or process spawned inside the session inherits the correct @@ -2429,6 +2444,12 @@ func (i *Instance) Start() error { go i.detectCodexSessionAsync() } + // Start async session ID detection for Copilot + // This runs in background and captures the session ID from events.jsonl + if i.Tool == "copilot" && i.CopilotSessionID == "" { + go i.detectCopilotSessionAsync() + } + return nil } @@ -3503,6 +3524,23 @@ func (i *Instance) PostStartSync(maxWait time.Duration) { i.WaitForClaudeSession(maxWait) case i.Tool == "gemini": i.UpdateGeminiSession(nil) + case i.Tool == "copilot": + // Copilot uses async detection via detectCopilotSessionAsync(). + // If the session was not yet detected, attempt a quick sync check. + if i.CopilotSessionID == "" { + cwd := i.EffectiveWorkingDir() + startedAfter := time.Now().Add(-30 * time.Second) + if i.CopilotStartedAt > 0 { + startedAfter = time.UnixMilli(i.CopilotStartedAt).Add(-2 * time.Second) + } + if sid := detectCopilotSessionFromDisk(cwd, startedAfter); sid != "" { + i.CopilotSessionID = sid + i.CopilotDetectedAt = time.Now() + if i.tmuxSession != nil { + _ = i.tmuxSession.SetEnvironment("COPILOT_SESSION_ID", sid) + } + } + } } // OpenCode/Codex: async detection already started by Start(), skip here } @@ -3587,6 +3625,11 @@ func (i *Instance) SyncSessionIDsToTmux() { if i.CodexSessionID != "" { _ = i.tmuxSession.SetEnvironment("CODEX_SESSION_ID", i.CodexSessionID) } + + // Sync CopilotSessionID + if i.CopilotSessionID != "" { + _ = i.tmuxSession.SetEnvironment("COPILOT_SESSION_ID", i.CopilotSessionID) + } } func (i *Instance) clearSessionBindingForFreshStart() { @@ -3616,6 +3659,12 @@ func (i *Instance) clearSessionBindingForFreshStart() { i.pendingCodexRestartWarning = "" i.mu.Unlock() } + + if i.Tool == "copilot" { + i.CopilotSessionID = "" + i.CopilotDetectedAt = time.Time{} + i.CopilotStartedAt = 0 + } } func (i *Instance) recreateTmuxSession() { @@ -3679,6 +3728,13 @@ func (i *Instance) SyncSessionIDsFromTmux() { if id, err := i.tmuxSession.GetEnvironment("CODEX_SESSION_ID"); err == nil && id != "" { i.CodexSessionID = id } + + if id, err := i.tmuxSession.GetEnvironment("COPILOT_SESSION_ID"); err == nil && id != "" { + i.CopilotSessionID = id + if i.CopilotDetectedAt.IsZero() { + i.CopilotDetectedAt = time.Now() + } + } } // ResponseOutput represents a parsed response from an agent session diff --git a/internal/session/tooloptions.go b/internal/session/tooloptions.go index fd559100f..4965c8bd6 100644 --- a/internal/session/tooloptions.go +++ b/internal/session/tooloptions.go @@ -294,6 +294,13 @@ type CopilotOptions struct { // ResumeSessionID is the Copilot session ID for --resume (only used // when SessionMode == "resume"). ResumeSessionID string `json:"resume_session_id,omitempty"` + // Model overrides the default Copilot model (e.g., "claude-opus-4.6", + // "gpt-5.2"). Passed as --model <value>. + Model string `json:"model,omitempty"` + // AllowAll enables --allow-all (equivalent to --allow-all-tools + // --allow-all-paths --allow-all-urls). Required for non-interactive + // scripting scenarios. + AllowAll bool `json:"allow_all,omitempty"` } // ToolName returns "copilot" @@ -310,15 +317,26 @@ func (o *CopilotOptions) ToArgs() []string { args = append(args, o.ResumeSessionID) } } + if o.Model != "" { + args = append(args, "--model", o.Model) + } + if o.AllowAll { + args = append(args, "--allow-all") + } return args } // NewCopilotOptions creates CopilotOptions with defaults from config func NewCopilotOptions(config *UserConfig) *CopilotOptions { opts := &CopilotOptions{SessionMode: "new"} - // No config-sourced defaults today; the struct exists so future - // settings (e.g., default model, auto-approve) have a home. - _ = config + if config != nil { + if config.Copilot.DefaultModel != "" { + opts.Model = config.Copilot.DefaultModel + } + if config.Copilot.AllowAll { + opts.AllowAll = true + } + } return opts } diff --git a/internal/session/userconfig.go b/internal/session/userconfig.go index 3706397bd..e2c2f9ce3 100644 --- a/internal/session/userconfig.go +++ b/internal/session/userconfig.go @@ -814,6 +814,15 @@ type CopilotSettings struct { // EnvFile is a .env file specific to Copilot sessions (sourced before // the `copilot` command runs, like [gemini].env_file). Optional. EnvFile string `toml:"env_file"` + + // DefaultModel sets the Copilot model for new sessions (e.g., "claude-opus-4.6", + // "gpt-5.2"). Passed as --model <value>. Can be overridden per-session. + DefaultModel string `toml:"default_model"` + + // AllowAll enables --allow-all by default for new sessions (equivalent to + // --allow-all-tools --allow-all-paths --allow-all-urls). Can be overridden + // per-session. + AllowAll bool `toml:"allow_all"` } // WorktreeSettings contains git worktree preferences. diff --git a/internal/tmux/patterns.go b/internal/tmux/patterns.go index fa8b519fb..767e02d9b 100644 --- a/internal/tmux/patterns.go +++ b/internal/tmux/patterns.go @@ -90,18 +90,24 @@ func DefaultRawPatterns(toolName string) *RawPatterns { } case "copilot": // GitHub Copilot CLI (the standalone `copilot` binary, Issue #556). - // Busy/prompt strings are conservative; can be tuned via user config - // overrides once more real-world transcripts are collected. + // Patterns are based on real-world Copilot CLI TUI transcripts. return &RawPatterns{ BusyPatterns: []string{ "esc to interrupt", "ctrl+c to interrupt", "thinking", + "Thinking", + "Generating", + "Reading", + "Searching", + "Running", + `re:(?m)^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s`, }, PromptPatterns: []string{ "How can I help", `re:(?m)^\s*copilot>\s*`, `re:(?m)^\s*›\s`, + `re:(?m)^\s*>\s*$`, }, } case "shell":