diff --git a/cmd/entire/cli/agent/copilot/copilot.go b/cmd/entire/cli/agent/copilot/copilot.go new file mode 100644 index 000000000..0f79589bb --- /dev/null +++ b/cmd/entire/cli/agent/copilot/copilot.go @@ -0,0 +1,240 @@ +// Package copilot implements the Agent interface for GitHub Copilot CLI. +package copilot + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameCopilot, NewCopilotAgent) +} + +// CopilotAgent implements the Agent interface for GitHub Copilot CLI. +// +//nolint:revive // CopilotAgent is clearer than Agent in this context +type CopilotAgent struct{} + +// NewCopilotAgent creates a new CopilotAgent. +func NewCopilotAgent() agent.Agent { + return &CopilotAgent{} +} + +// Name returns the agent registry key. +func (c *CopilotAgent) Name() agent.AgentName { + return agent.AgentNameCopilot +} + +// Type returns the agent type identifier. +func (c *CopilotAgent) Type() agent.AgentType { + return agent.AgentTypeCopilot +} + +// Description returns a human-readable description. +func (c *CopilotAgent) Description() string { + return "GitHub Copilot - GitHub's AI coding assistant" +} + +// IsPreview returns true as the Copilot integration is in preview. +func (c *CopilotAgent) IsPreview() bool { return true } + +// DetectPresence checks if GitHub Copilot is configured in the repository. +func (c *CopilotAgent) DetectPresence() (bool, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + // Check if our hooks are installed + if c.AreHooksInstalled() { + return true, nil + } + + // Check for .github/hooks directory with copilot config + configPath := filepath.Join(repoRoot, ".github", "hooks", CopilotConfigFileName) + if _, err := os.Stat(configPath); err == nil { + return true, nil + } + + return false, nil +} + +// GetHookConfigPath returns the path to Copilot's hook config file. +func (c *CopilotAgent) GetHookConfigPath() string { + return filepath.Join(".github", "hooks", CopilotConfigFileName) +} + +// SupportsHooks returns true as GitHub Copilot supports lifecycle hooks. +func (c *CopilotAgent) SupportsHooks() bool { + return true +} + +// ParseHookInput parses Copilot hook input from stdin. +func (c *CopilotAgent) ParseHookInput(hookType agent.HookType, reader io.Reader) (*agent.HookInput, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read input: %w", err) + } + + if len(data) == 0 { + return nil, errors.New("empty input") + } + + input := &agent.HookInput{ + HookType: hookType, + Timestamp: time.Now(), + RawData: make(map[string]any), + } + + switch hookType { + case agent.HookSessionStart: + var raw sessionStartRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse hook input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TranscriptPath + input.RawData["cwd"] = raw.Cwd + input.RawData["source"] = raw.Source + + case agent.HookSessionEnd: + var raw sessionEndRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse hook input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TranscriptPath + input.RawData["cwd"] = raw.Cwd + input.RawData["reason"] = raw.Reason + + case agent.HookStop: + var raw agentStopRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse hook input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TranscriptPath + input.RawData["cwd"] = raw.Cwd + input.RawData["stopReason"] = raw.StopReason + + case agent.HookUserPromptSubmit: + var raw userPromptRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse hook input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TranscriptPath + input.RawData["cwd"] = raw.Cwd + if raw.Prompt != "" { + input.UserPrompt = raw.Prompt + input.RawData["prompt"] = raw.Prompt + } + + case agent.HookPreToolUse, agent.HookPostToolUse: + var raw toolUseRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse tool hook input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TranscriptPath + input.ToolName = raw.ToolName + input.ToolInput = raw.ToolArgs + if hookType == agent.HookPostToolUse { + input.ToolResponse = raw.ToolResult + } + input.RawData["cwd"] = raw.Cwd + } + + if input.SessionID == "" { + return nil, ErrMissingSessionID + } + + return input, nil +} + +// GetSessionID extracts the session ID from hook input. +func (c *CopilotAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// ProtectedDirs returns directories that Copilot uses. +// .github is a shared directory (not solely ours), so we don't protect it. +func (c *CopilotAgent) ProtectedDirs() []string { return []string{} } + +// GetSessionDir returns the directory where Copilot stores session transcripts. +func (c *CopilotAgent) GetSessionDir(_ string) (string, error) { + if override := os.Getenv("ENTIRE_TEST_COPILOT_SESSION_DIR"); override != "" { + return override, nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + return filepath.Join(homeDir, ".copilot", "session-state"), nil +} + +// ResolveSessionFile returns the path to a Copilot session file. +func (c *CopilotAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID, "events.jsonl") +} + +// ReadSession reads a session from Copilot's storage. +func (c *CopilotAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: c.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + }, nil +} + +// WriteSession writes a session to Copilot's storage. +func (c *CopilotAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + if session.AgentName != "" && session.AgentName != c.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, c.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume a Copilot session. +func (c *CopilotAgent) FormatResumeCommand(_ string) string { + return "copilot --resume" +} diff --git a/cmd/entire/cli/agent/copilot/copilot_test.go b/cmd/entire/cli/agent/copilot/copilot_test.go new file mode 100644 index 000000000..8683394af --- /dev/null +++ b/cmd/entire/cli/agent/copilot/copilot_test.go @@ -0,0 +1,571 @@ +package copilot + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestNewCopilotAgent(t *testing.T) { + t.Parallel() + + ag := NewCopilotAgent() + if ag == nil { + t.Fatal("NewCopilotAgent() returned nil") + } + + cp, ok := ag.(*CopilotAgent) + if !ok { + t.Fatal("NewCopilotAgent() didn't return *CopilotAgent") + } + if cp == nil { + t.Fatal("NewCopilotAgent() returned nil agent") + } +} + +func TestName(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + if name := ag.Name(); name != agent.AgentNameCopilot { + t.Errorf("Name() = %q, want %q", name, agent.AgentNameCopilot) + } +} + +func TestType(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + if tp := ag.Type(); tp != agent.AgentTypeCopilot { + t.Errorf("Type() = %q, want %q", tp, agent.AgentTypeCopilot) + } +} + +func TestDescription(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + desc := ag.Description() + if desc == "" { + t.Error("Description() returned empty string") + } +} + +func TestIsPreview(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + if !ag.IsPreview() { + t.Error("IsPreview() = false, want true") + } +} + +func TestDetectPresence_NoConfig(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CopilotAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if present { + t.Error("DetectPresence() = true, want false") + } +} + +func TestDetectPresence_WithConfig(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create .github/hooks/copilot-setup.json + hooksDir := filepath.Join(tempDir, ".github", "hooks") + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + t.Fatalf("failed to create hooks dir: %v", err) + } + if err := os.WriteFile(filepath.Join(hooksDir, CopilotConfigFileName), []byte(`{"version":1}`), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + ag := &CopilotAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } +} + +func TestGetHookConfigPath(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + path := ag.GetHookConfigPath() + if path != filepath.Join(".github", "hooks", CopilotConfigFileName) { + t.Errorf("GetHookConfigPath() = %q, want .github/hooks/%s", path, CopilotConfigFileName) + } +} + +func TestSupportsHooks(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + if !ag.SupportsHooks() { + t.Error("SupportsHooks() = false, want true") + } +} + +func TestProtectedDirs(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + dirs := ag.ProtectedDirs() + if len(dirs) != 0 { + t.Errorf("ProtectedDirs() = %v, want empty", dirs) + } +} + +func TestGetSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + input := &agent.HookInput{SessionID: "test-session-456"} + + id := ag.GetSessionID(input) + if id != "test-session-456" { + t.Errorf("GetSessionID() = %q, want test-session-456", id) + } +} + +func TestGetSessionDir(t *testing.T) { + ag := &CopilotAgent{} + + t.Setenv("ENTIRE_TEST_COPILOT_SESSION_DIR", "/test/override") + + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if dir != "/test/override" { + t.Errorf("GetSessionDir() = %q, want /test/override", dir) + } +} + +func TestGetSessionDir_DefaultPath(t *testing.T) { + ag := &CopilotAgent{} + + t.Setenv("ENTIRE_TEST_COPILOT_SESSION_DIR", "") + + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + + if !filepath.IsAbs(dir) { + t.Errorf("GetSessionDir() should return absolute path, got %q", dir) + } +} + +func TestResolveSessionFile(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + result := ag.ResolveSessionFile("/tmp/sessions", "abc123") + expected := filepath.Join("/tmp/sessions", "abc123", "events.jsonl") + if result != expected { + t.Errorf("ResolveSessionFile() = %q, want %q", result, expected) + } +} + +func TestFormatResumeCommand(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + cmd := ag.FormatResumeCommand("abc123") + if cmd != "copilot --resume" { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, "copilot --resume") + } +} + +func TestParseHookInput_SessionStart_WithoutSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // Copilot sessionStart without sessionId (current behavior) + input := `{"timestamp":1771465283476,"cwd":"/project","source":"new","initialPrompt":"hello"}` + + _, err := ag.ParseHookInput(agent.HookSessionStart, bytes.NewReader([]byte(input))) + + if !errors.Is(err, ErrMissingSessionID) { + t.Errorf("expected ErrMissingSessionID, got %v", err) + } +} + +func TestParseHookInput_SessionStart_WithSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // sessionStart with sessionId (future Copilot behavior) + // See: https://github.com/github/copilot-cli/issues/1425 + input := `{"timestamp":1771465283476,"cwd":"/project","source":"new","initialPrompt":"hello","sessionId":"` + testSessionID + `","transcriptPath":"` + testTranscriptPath + `"}` + + hookInput, err := ag.ParseHookInput(agent.HookSessionStart, bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if hookInput.SessionID != testSessionID { + t.Errorf("SessionID = %q, want %q", hookInput.SessionID, testSessionID) + } + if hookInput.SessionRef != testTranscriptPath { + t.Errorf("SessionRef = %q, want %q", hookInput.SessionRef, testTranscriptPath) + } +} + +func TestParseHookInput_UserPrompt_WithoutSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // Copilot userPromptSubmitted without sessionId (current behavior) + input := `{"timestamp":1771465282293,"cwd":"/project","prompt":"Fix the bug"}` + + _, err := ag.ParseHookInput(agent.HookUserPromptSubmit, bytes.NewReader([]byte(input))) + + if !errors.Is(err, ErrMissingSessionID) { + t.Errorf("expected ErrMissingSessionID, got %v", err) + } +} + +func TestParseHookInput_UserPrompt_WithSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // userPromptSubmitted with sessionId (future Copilot behavior) + // See: https://github.com/github/copilot-cli/issues/1425 + input := `{"timestamp":1771465282293,"cwd":"/project","prompt":"Fix the bug","sessionId":"` + testSessionID + `","transcriptPath":"` + testTranscriptPath + `"}` + + hookInput, err := ag.ParseHookInput(agent.HookUserPromptSubmit, bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if hookInput.UserPrompt != "Fix the bug" { + t.Errorf("UserPrompt = %q, want 'Fix the bug'", hookInput.UserPrompt) + } + if hookInput.SessionID != testSessionID { + t.Errorf("SessionID = %q, want %q", hookInput.SessionID, testSessionID) + } + if hookInput.SessionRef != testTranscriptPath { + t.Errorf("SessionRef = %q, want %q", hookInput.SessionRef, testTranscriptPath) + } +} + +func TestParseHookInput_ToolUse_WithoutSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // Copilot preToolUse without sessionId (current behavior) + input := `{"timestamp":1771465284000,"cwd":"/project","toolName":"edit","toolArgs":{"file_path":"test.go"}}` + + _, err := ag.ParseHookInput(agent.HookPreToolUse, bytes.NewReader([]byte(input))) + + if !errors.Is(err, ErrMissingSessionID) { + t.Errorf("expected ErrMissingSessionID, got %v", err) + } +} + +func TestParseHookInput_ToolUse_WithSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // preToolUse with sessionId (future Copilot behavior) + input := `{"timestamp":1771465284000,"cwd":"/project","toolName":"edit","toolArgs":{"file_path":"test.go"},"sessionId":"` + testSessionID + `","transcriptPath":"` + testTranscriptPath + `"}` + + hookInput, err := ag.ParseHookInput(agent.HookPreToolUse, bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if hookInput.ToolName != "edit" { + t.Errorf("ToolName = %q, want edit", hookInput.ToolName) + } + if hookInput.ToolInput == nil { + t.Error("ToolInput is nil") + } + if hookInput.SessionID != testSessionID { + t.Errorf("SessionID = %q, want %q", hookInput.SessionID, testSessionID) + } +} + +func TestParseHookInput_AgentStop(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + input := `{"timestamp":1771465289990,"cwd":"/project","sessionId":"733bfbbd-bd9b-4750-a55b-3c8cc42629de","transcriptPath":"/home/user/.copilot/session-state/733bfbbd/events.jsonl","stopReason":"end_turn"}` + + hookInput, err := ag.ParseHookInput(agent.HookStop, bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if hookInput.SessionID != "733bfbbd-bd9b-4750-a55b-3c8cc42629de" { + t.Errorf("SessionID = %q, want 733bfbbd-bd9b-4750-a55b-3c8cc42629de", hookInput.SessionID) + } + if hookInput.SessionRef != "/home/user/.copilot/session-state/733bfbbd/events.jsonl" { + t.Errorf("SessionRef = %q, want transcript path", hookInput.SessionRef) + } + if hookInput.RawData["stopReason"] != "end_turn" { + t.Errorf("RawData[stopReason] = %q, want end_turn", hookInput.RawData["stopReason"]) + } +} + +func TestParseHookInput_SessionEnd_WithoutSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // Copilot sessionEnd without sessionId (current behavior) + input := `{"timestamp":1771465290000,"cwd":"/project","reason":"complete"}` + + _, err := ag.ParseHookInput(agent.HookSessionEnd, bytes.NewReader([]byte(input))) + + if !errors.Is(err, ErrMissingSessionID) { + t.Errorf("expected ErrMissingSessionID, got %v", err) + } +} + +func TestParseHookInput_SessionEnd_WithSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // sessionEnd with sessionId (future Copilot behavior) + // See: https://github.com/github/copilot-cli/issues/1425 + input := `{"timestamp":1771465290000,"cwd":"/project","reason":"complete","sessionId":"` + testSessionID + `","transcriptPath":"` + testTranscriptPath + `"}` + + hookInput, err := ag.ParseHookInput(agent.HookSessionEnd, bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if hookInput.SessionID != testSessionID { + t.Errorf("SessionID = %q, want %q", hookInput.SessionID, testSessionID) + } + if hookInput.SessionRef != testTranscriptPath { + t.Errorf("SessionRef = %q, want %q", hookInput.SessionRef, testTranscriptPath) + } + if hookInput.RawData["reason"] != "complete" { + t.Errorf("RawData[reason] = %q, want complete", hookInput.RawData["reason"]) + } +} + +func TestParseHookInput_Empty(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + _, err := ag.ParseHookInput(agent.HookSessionStart, bytes.NewReader([]byte(""))) + if err == nil { + t.Error("ParseHookInput() should error on empty input") + } +} + +func TestParseHookInput_InvalidJSON(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + _, err := ag.ParseHookInput(agent.HookSessionStart, bytes.NewReader([]byte("{not json}"))) + if err == nil { + t.Error("ParseHookInput() should error on invalid JSON") + } +} + +func TestReadSession(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + transcriptPath := filepath.Join(tempDir, "transcript.jsonl") + content := `{"role":"user","content":"hello"}` + if err := os.WriteFile(transcriptPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &CopilotAgent{} + input := &agent.HookInput{ + SessionID: "test-session", + SessionRef: transcriptPath, + } + + session, err := ag.ReadSession(input) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + if session.SessionID != "test-session" { + t.Errorf("SessionID = %q, want test-session", session.SessionID) + } + if session.AgentName != agent.AgentNameCopilot { + t.Errorf("AgentName = %q, want %q", session.AgentName, agent.AgentNameCopilot) + } + if len(session.NativeData) == 0 { + t.Error("NativeData is empty") + } +} + +func TestReadSession_NoSessionRef(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + input := &agent.HookInput{SessionID: "test-session"} + + _, err := ag.ReadSession(input) + if err == nil { + t.Error("ReadSession() should error when SessionRef is empty") + } +} + +func TestWriteSession(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + transcriptPath := filepath.Join(tempDir, "transcript.jsonl") + + ag := &CopilotAgent{} + session := &agent.AgentSession{ + SessionID: "test-session", + AgentName: agent.AgentNameCopilot, + SessionRef: transcriptPath, + NativeData: []byte(`{"role":"user","content":"hello"}`), + } + + if err := ag.WriteSession(session); err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + data, err := os.ReadFile(transcriptPath) + if err != nil { + t.Fatalf("failed to read transcript: %v", err) + } + if string(data) != `{"role":"user","content":"hello"}` { + t.Errorf("unexpected content: %s", data) + } +} + +func TestWriteSession_Nil(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + if err := ag.WriteSession(nil); err == nil { + t.Error("WriteSession(nil) should error") + } +} + +func TestWriteSession_WrongAgent(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + session := &agent.AgentSession{ + AgentName: "claude-code", + SessionRef: "/path/to/file", + NativeData: []byte("{}"), + } + + if err := ag.WriteSession(session); err == nil { + t.Error("WriteSession() should error for wrong agent") + } +} + +func TestWriteSession_NoSessionRef(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + session := &agent.AgentSession{ + AgentName: agent.AgentNameCopilot, + NativeData: []byte("{}"), + } + + if err := ag.WriteSession(session); err == nil { + t.Error("WriteSession() should error when SessionRef is empty") + } +} + +func TestWriteSession_NoNativeData(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + session := &agent.AgentSession{ + AgentName: agent.AgentNameCopilot, + SessionRef: "/path/to/file", + } + + if err := ag.WriteSession(session); err == nil { + t.Error("WriteSession() should error when NativeData is empty") + } +} + +func TestGetSupportedHooks(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + hooks := ag.GetSupportedHooks() + + expected := []agent.HookType{ + agent.HookSessionStart, + agent.HookSessionEnd, + agent.HookStop, + agent.HookUserPromptSubmit, + agent.HookPreToolUse, + agent.HookPostToolUse, + } + + if len(hooks) != len(expected) { + t.Errorf("GetSupportedHooks() returned %d hooks, want %d", len(hooks), len(expected)) + } + + for i, hook := range expected { + if hooks[i] != hook { + t.Errorf("GetSupportedHooks()[%d] = %v, want %v", i, hooks[i], hook) + } + } +} + +func TestChunkTranscript_SmallContent(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + content := []byte(`{"role":"user","content":"hello"} +{"role":"assistant","content":"hi"}`) + + chunks, err := ag.ChunkTranscript(content, agent.MaxChunkSize) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + if len(chunks) != 1 { + t.Errorf("Expected 1 chunk, got %d", len(chunks)) + } +} + +func TestChunkTranscript_RoundTrip(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + original := `{"role":"user","content":"hello"} +{"role":"assistant","content":"hi there"} +{"role":"user","content":"thanks"}` + + chunks, err := ag.ChunkTranscript([]byte(original), 50) + if err != nil { + t.Fatalf("ChunkTranscript() error = %v", err) + } + + reassembled, err := ag.ReassembleTranscript(chunks) + if err != nil { + t.Fatalf("ReassembleTranscript() error = %v", err) + } + + if string(reassembled) != original { + t.Errorf("Round-trip failed:\ngot: %q\nwant: %q", string(reassembled), original) + } +} diff --git a/cmd/entire/cli/agent/copilot/hooks.go b/cmd/entire/cli/agent/copilot/hooks.go new file mode 100644 index 000000000..e99cb2153 --- /dev/null +++ b/cmd/entire/cli/agent/copilot/hooks.go @@ -0,0 +1,257 @@ +package copilot + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure CopilotAgent implements HookSupport and HookHandler +var ( + _ agent.HookSupport = (*CopilotAgent)(nil) + _ agent.HookHandler = (*CopilotAgent)(nil) +) + +// Copilot hook names - these become subcommands under `entire hooks github-copilot` +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameUserPromptSubmitted = "user-prompt-submitted" + HookNameAgentStop = "agent-stop" + HookNameSubagentStop = "subagent-stop" + HookNamePreToolUse = "pre-tool-use" + HookNamePostToolUse = "post-tool-use" + HookNameErrorOccurred = "error-occurred" +) + +// CopilotConfigFileName is the hook config file used by GitHub Copilot. +const CopilotConfigFileName = "copilot-setup.json" + +// hookNameToConfigKey maps CLI subcommand names to Copilot's native hook names (camelCase in config) +var hookNameToConfigKey = map[string]string{ + HookNameSessionStart: "sessionStart", + HookNameSessionEnd: "sessionEnd", + HookNameUserPromptSubmitted: "userPromptSubmitted", + HookNameAgentStop: "agentStop", + HookNameSubagentStop: "subagentStop", + HookNamePreToolUse: "preToolUse", + HookNamePostToolUse: "postToolUse", + HookNameErrorOccurred: "errorOccurred", +} + +// entireHookPrefixes are command prefixes that identify Entire hooks +var entireHookPrefixes = []string{ + "entire ", + "go run ${COPILOT_PROJECT_DIR}/cmd/entire/main.go ", +} + +// GetHookNames implements agent.HookHandler by delegating to HookNames. +func (c *CopilotAgent) GetHookNames() []string { + return c.HookNames() +} + +// InstallHooks installs Copilot hooks in .github/hooks/copilot-setup.json. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (c *CopilotAgent) InstallHooks(localDev bool, force bool) (int, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when RepoRoot() fails (tests run outside git repos) + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + configPath := filepath.Join(repoRoot, ".github", "hooks", CopilotConfigFileName) + + // Read existing config if it exists + var config hookConfig + existingData, readErr := os.ReadFile(configPath) //nolint:gosec // path is constructed from repo root + fixed path + if readErr == nil { + if err := json.Unmarshal(existingData, &config); err != nil { + return 0, fmt.Errorf("failed to parse existing %s: %w", CopilotConfigFileName, err) + } + } else { + config.Version = 1 + } + + if config.Hooks == nil { + config.Hooks = make(map[string][]hookEntry) + } + + // Define hook command based on localDev mode + var cmdPrefix string + if localDev { + cmdPrefix = "go run ${COPILOT_PROJECT_DIR}/cmd/entire/main.go hooks github-copilot " + } else { + cmdPrefix = "entire hooks github-copilot " + } + + // Check for idempotency BEFORE removing hooks + if !force { + configKey := hookNameToConfigKey[HookNameSessionStart] + expectedCmd := cmdPrefix + HookNameSessionStart + if entries, ok := config.Hooks[configKey]; ok { + for _, entry := range entries { + if entry.Bash == expectedCmd { + return 0, nil // Already installed with same mode + } + } + } + } + + // Remove existing Entire hooks first + for configKey, entries := range config.Hooks { + config.Hooks[configKey] = removeEntireHookEntries(entries) + if len(config.Hooks[configKey]) == 0 { + delete(config.Hooks, configKey) + } + } + + // Install all 8 hooks + hookNames := c.HookNames() + for _, hookName := range hookNames { + configKey, ok := hookNameToConfigKey[hookName] + if !ok { + continue + } + + entry := hookEntry{ + Type: "command", + Bash: cmdPrefix + hookName, + TimeoutSec: 10, + } + + config.Hooks[configKey] = append(config.Hooks[configKey], entry) + } + + count := len(hookNames) + + // Write config file + if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .github/hooks directory: %w", err) + } + + output, err := json.MarshalIndent(config, "", " ") + if err != nil { + return 0, fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, output, 0o600); err != nil { + return 0, fmt.Errorf("failed to write %s: %w", CopilotConfigFileName, err) + } + + return count, nil +} + +// UninstallHooks removes Entire hooks from Copilot config. +func (c *CopilotAgent) UninstallHooks() error { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + configPath := filepath.Join(repoRoot, ".github", "hooks", CopilotConfigFileName) + data, err := os.ReadFile(configPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return nil //nolint:nilerr // No config file means nothing to uninstall + } + + var config hookConfig + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse %s: %w", CopilotConfigFileName, err) + } + + if config.Hooks == nil { + return nil + } + + // Remove Entire hooks from all hook types + for configKey, entries := range config.Hooks { + config.Hooks[configKey] = removeEntireHookEntries(entries) + if len(config.Hooks[configKey]) == 0 { + delete(config.Hooks, configKey) + } + } + + // Write back + output, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, output, 0o600); err != nil { + return fmt.Errorf("failed to write %s: %w", CopilotConfigFileName, err) + } + + return nil +} + +// AreHooksInstalled checks if Entire hooks are installed in Copilot config. +func (c *CopilotAgent) AreHooksInstalled() bool { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + configPath := filepath.Join(repoRoot, ".github", "hooks", CopilotConfigFileName) + data, err := os.ReadFile(configPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return false + } + + var config hookConfig + if err := json.Unmarshal(data, &config); err != nil { + return false + } + + for _, entries := range config.Hooks { + for _, entry := range entries { + if isEntireHook(entry.Bash) { + return true + } + } + } + + return false +} + +// GetSupportedHooks returns the hook types Copilot supports. +func (c *CopilotAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + agent.HookSessionStart, + agent.HookSessionEnd, + agent.HookStop, + agent.HookUserPromptSubmit, + agent.HookPreToolUse, + agent.HookPostToolUse, + } +} + +// Helper functions + +// isEntireHook checks if a command is an Entire hook +func isEntireHook(command string) bool { + for _, prefix := range entireHookPrefixes { + if strings.HasPrefix(command, prefix) { + return true + } + } + return false +} + +// removeEntireHookEntries removes all Entire hook entries from a slice +func removeEntireHookEntries(entries []hookEntry) []hookEntry { + result := make([]hookEntry, 0, len(entries)) + for _, entry := range entries { + if !isEntireHook(entry.Bash) { + result = append(result, entry) + } + } + return result +} diff --git a/cmd/entire/cli/agent/copilot/hooks_test.go b/cmd/entire/cli/agent/copilot/hooks_test.go new file mode 100644 index 000000000..fa0f9055b --- /dev/null +++ b/cmd/entire/cli/agent/copilot/hooks_test.go @@ -0,0 +1,320 @@ +package copilot + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestInstallHooks_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CopilotAgent{} + count, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + if count != 8 { + t.Errorf("InstallHooks() count = %d, want 8", count) + } + + config := readCopilotConfig(t, tempDir) + + if config.Version != 1 { + t.Errorf("version = %d, want 1", config.Version) + } + + // Verify all 8 hook types are present + expectedConfigKeys := []string{ + "sessionStart", "sessionEnd", "userPromptSubmitted", "agentStop", + "subagentStop", "preToolUse", "postToolUse", "errorOccurred", + } + for _, key := range expectedConfigKeys { + entries, ok := config.Hooks[key] + if !ok { + t.Errorf("hook %q not found in config", key) + continue + } + if len(entries) != 1 { + t.Errorf("hook %q has %d entries, want 1", key, len(entries)) + } + } + + // Verify command format + if entries, ok := config.Hooks["sessionStart"]; ok && len(entries) > 0 { + expected := "entire hooks github-copilot session-start" + if entries[0].Bash != expected { + t.Errorf("sessionStart bash = %q, want %q", entries[0].Bash, expected) + } + if entries[0].Type != "command" { + t.Errorf("sessionStart type = %q, want command", entries[0].Type) + } + if entries[0].TimeoutSec != 10 { + t.Errorf("sessionStart timeoutSec = %d, want 10", entries[0].TimeoutSec) + } + } +} + +func TestInstallHooks_LocalDev(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CopilotAgent{} + _, err := ag.InstallHooks(true, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + config := readCopilotConfig(t, tempDir) + + // Verify local dev commands use go run + if entries, ok := config.Hooks["sessionStart"]; ok && len(entries) > 0 { + expected := "go run ${COPILOT_PROJECT_DIR}/cmd/entire/main.go hooks github-copilot session-start" + if entries[0].Bash != expected { + t.Errorf("sessionStart bash = %q, want %q", entries[0].Bash, expected) + } + } +} + +func TestInstallHooks_Idempotent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CopilotAgent{} + + count1, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + if count1 != 8 { + t.Errorf("first InstallHooks() count = %d, want 8", count1) + } + + count2, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + if count2 != 0 { + t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count2) + } + + // Verify still only 1 entry per hook type + config := readCopilotConfig(t, tempDir) + for key, entries := range config.Hooks { + if len(entries) != 1 { + t.Errorf("hook %q has %d entries after double install, want 1", key, len(entries)) + } + } +} + +func TestInstallHooks_Force(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CopilotAgent{} + + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + count, err := ag.InstallHooks(false, true) + if err != nil { + t.Fatalf("force InstallHooks() error = %v", err) + } + if count != 8 { + t.Errorf("force InstallHooks() count = %d, want 8", count) + } +} + +func TestInstallHooks_PreservesUserHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config with existing user hooks + writeCopilotConfig(t, tempDir, `{ + "version": 1, + "hooks": { + "sessionStart": [ + {"type": "command", "bash": "echo user-hook", "timeoutSec": 5} + ] + } +}`) + + ag := &CopilotAgent{} + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + config := readCopilotConfig(t, tempDir) + + entries, ok := config.Hooks["sessionStart"] + if !ok { + t.Fatal("sessionStart not found") + } + if len(entries) != 2 { + t.Errorf("sessionStart entries = %d, want 2 (user + entire)", len(entries)) + } + + // Verify user hook is preserved + foundUserHook := false + for _, entry := range entries { + if entry.Bash == "echo user-hook" { + foundUserHook = true + } + } + if !foundUserHook { + t.Error("user hook was not preserved") + } +} + +func TestUninstallHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CopilotAgent{} + + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + if !ag.AreHooksInstalled() { + t.Error("hooks should be installed before uninstall") + } + + if err := ag.UninstallHooks(); err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + if ag.AreHooksInstalled() { + t.Error("hooks should not be installed after uninstall") + } +} + +func TestUninstallHooks_NoConfigFile(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CopilotAgent{} + + err := ag.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() should not error when no config file: %v", err) + } +} + +func TestUninstallHooks_PreservesUserHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create config with both user and entire hooks + writeCopilotConfig(t, tempDir, `{ + "version": 1, + "hooks": { + "sessionStart": [ + {"type": "command", "bash": "echo user-hook", "timeoutSec": 5}, + {"type": "command", "bash": "entire hooks github-copilot session-start", "timeoutSec": 10} + ] + } +}`) + + ag := &CopilotAgent{} + if err := ag.UninstallHooks(); err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + config := readCopilotConfig(t, tempDir) + + entries, ok := config.Hooks["sessionStart"] + if !ok { + t.Fatal("sessionStart should still exist with user hook") + } + if len(entries) != 1 { + t.Errorf("sessionStart entries = %d, want 1 (user only)", len(entries)) + } + if entries[0].Bash != "echo user-hook" { + t.Errorf("remaining hook = %q, want echo user-hook", entries[0].Bash) + } +} + +func TestAreHooksInstalled(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &CopilotAgent{} + + if ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() should be false when no config file") + } + + _, err := ag.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + if !ag.AreHooksInstalled() { + t.Error("AreHooksInstalled() should be true after installation") + } +} + +func TestHookNames(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + names := ag.HookNames() + + expected := []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameUserPromptSubmitted, + HookNameAgentStop, + HookNameSubagentStop, + HookNamePreToolUse, + HookNamePostToolUse, + HookNameErrorOccurred, + } + + if len(names) != len(expected) { + t.Errorf("HookNames() returned %d names, want %d", len(names), len(expected)) + } + + for i, name := range expected { + if names[i] != name { + t.Errorf("HookNames()[%d] = %q, want %q", i, names[i], name) + } + } +} + +// Helper functions + +func readCopilotConfig(t *testing.T, tempDir string) hookConfig { + t.Helper() + configPath := filepath.Join(tempDir, ".github", "hooks", CopilotConfigFileName) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config: %v", err) + } + + var config hookConfig + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("failed to parse config: %v", err) + } + return config +} + +func writeCopilotConfig(t *testing.T, tempDir, content string) { + t.Helper() + hooksDir := filepath.Join(tempDir, ".github", "hooks") + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + t.Fatalf("failed to create hooks dir: %v", err) + } + configPath := filepath.Join(hooksDir, CopilotConfigFileName) + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } +} diff --git a/cmd/entire/cli/agent/copilot/lifecycle.go b/cmd/entire/cli/agent/copilot/lifecycle.go new file mode 100644 index 000000000..ed235fb28 --- /dev/null +++ b/cmd/entire/cli/agent/copilot/lifecycle.go @@ -0,0 +1,226 @@ +package copilot + +import ( + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// ErrMissingSessionID is returned when a Copilot hook does not include a sessionId. +// See: https://github.com/github/copilot-cli/issues/1425 +var ErrMissingSessionID = errors.New("copilot hook missing sessionId (see https://github.com/github/copilot-cli/issues/1425)") + +// Compile-time interface assertions +var _ agent.TranscriptAnalyzer = (*CopilotAgent)(nil) + +// HookNames returns the hook verbs Copilot supports. +func (c *CopilotAgent) HookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameUserPromptSubmitted, + HookNameAgentStop, + HookNameSubagentStop, + HookNamePreToolUse, + HookNamePostToolUse, + HookNameErrorOccurred, + } +} + +// ParseHookEvent translates a Copilot hook into a normalized lifecycle Event. +// Returns nil if the hook has no lifecycle significance (e.g., pass-through hooks) +// or if the hook lacks a sessionId (the dispatcher requires sessionId for most events). +func (c *CopilotAgent) ParseHookEvent(hookName string, stdin io.Reader) (*agent.Event, error) { + switch hookName { + case HookNameSessionStart: + return c.parseSessionStart(stdin) + case HookNameUserPromptSubmitted: + return c.parseTurnStart(stdin) + case HookNameAgentStop: + return c.parseTurnEnd(stdin) + case HookNameSessionEnd: + return c.parseSessionEnd(stdin) + case HookNameSubagentStop: + return c.parseSubagentEnd(stdin) + case HookNamePreToolUse, HookNamePostToolUse, HookNameErrorOccurred: + return nil, nil //nolint:nilnil // nil event = no lifecycle action + default: + return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action + } +} + +// ReadTranscript reads the raw JSONL transcript bytes for a session. +func (c *CopilotAgent) ReadTranscript(sessionRef string) ([]byte, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + return data, nil +} + +// ChunkTranscript splits a JSONL transcript into chunks. +func (c *CopilotAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) { + chunks, err := agent.ChunkJSONL(content, maxSize) + if err != nil { + return nil, fmt.Errorf("failed to chunk JSONL: %w", err) + } + return chunks, nil +} + +// ReassembleTranscript combines JSONL chunks back into a single transcript. +func (c *CopilotAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} + +// ExtractPrompts extracts user prompts from the transcript starting at the given line offset. +func (c *CopilotAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + return extractPromptsFromData(data, fromOffset) +} + +// ExtractSummary extracts the last assistant message as a session summary. +func (c *CopilotAgent) ExtractSummary(sessionRef string) (string, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input + if err != nil { + return "", fmt.Errorf("failed to read transcript: %w", err) + } + return extractLastAssistantMessage(data), nil +} + +// GetTranscriptPosition returns the current line count of the JSONL transcript. +func (c *CopilotAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + + data, err := os.ReadFile(path) //nolint:gosec // Reading from controlled transcript path + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("failed to read transcript: %w", err) + } + + return countLines(data), nil +} + +// ExtractModifiedFilesFromOffset extracts files modified since a given line offset. +func (c *CopilotAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { + if path == "" { + return nil, 0, nil + } + + data, readErr := os.ReadFile(path) //nolint:gosec // Reading from controlled transcript path + if readErr != nil { + if os.IsNotExist(readErr) { + return nil, 0, nil + } + return nil, 0, fmt.Errorf("failed to read transcript: %w", readErr) + } + + if len(data) == 0 { + return nil, 0, nil + } + + totalLines := countLines(data) + extractedFiles := extractModifiedFilesFromLines(data, startOffset) + + return extractedFiles, totalLines, nil +} + +// --- Internal hook parsing functions --- + +// parseSessionStart handles the sessionStart hook. +func (c *CopilotAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[sessionStartRaw](stdin) + if err != nil { + return nil, err + } + if raw.SessionID == "" { + return nil, ErrMissingSessionID + } + return &agent.Event{ + Type: agent.SessionStart, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +// parseTurnStart handles the userPromptSubmitted hook. +func (c *CopilotAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[userPromptRaw](stdin) + if err != nil { + return nil, err + } + if raw.SessionID == "" { + return nil, ErrMissingSessionID + } + return &agent.Event{ + Type: agent.TurnStart, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Prompt: raw.Prompt, + Timestamp: time.Now(), + }, nil +} + +// parseTurnEnd handles the agentStop hook. +// This is the only hook that provides sessionId and transcriptPath. +func (c *CopilotAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[agentStopRaw](stdin) + if err != nil { + return nil, err + } + if raw.SessionID == "" { + return nil, ErrMissingSessionID + } + return &agent.Event{ + Type: agent.TurnEnd, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +// parseSessionEnd handles the sessionEnd hook. +func (c *CopilotAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[sessionEndRaw](stdin) + if err != nil { + return nil, err + } + if raw.SessionID == "" { + return nil, ErrMissingSessionID + } + return &agent.Event{ + Type: agent.SessionEnd, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +// parseSubagentEnd handles the subagentStop hook. +func (c *CopilotAgent) parseSubagentEnd(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[subagentStopRaw](stdin) + if err != nil { + return nil, err + } + if raw.SessionID == "" { + return nil, ErrMissingSessionID + } + return &agent.Event{ + Type: agent.SubagentEnd, + SessionID: raw.SessionID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} diff --git a/cmd/entire/cli/agent/copilot/lifecycle_test.go b/cmd/entire/cli/agent/copilot/lifecycle_test.go new file mode 100644 index 000000000..5e5a4e19b --- /dev/null +++ b/cmd/entire/cli/agent/copilot/lifecycle_test.go @@ -0,0 +1,383 @@ +package copilot + +import ( + "errors" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +const ( + testSessionID = "abc-123" + testTranscriptPath = "/tmp/events.jsonl" +) + +func TestParseHookEvent_SessionStart_WithoutSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // Copilot sessionStart without sessionId (current behavior) + input := `{"timestamp":1771465283476,"cwd":"/Users/test/project","source":"new","initialPrompt":"hello"}` + + _, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) + + if !errors.Is(err, ErrMissingSessionID) { + t.Errorf("expected ErrMissingSessionID, got %v", err) + } +} + +func TestParseHookEvent_SessionStart_WithSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // sessionStart with sessionId (future Copilot behavior) + // See: https://github.com/github/copilot-cli/issues/1425 + input := `{"timestamp":1771465283476,"cwd":"/Users/test/project","source":"new","initialPrompt":"hello","sessionId":"` + testSessionID + `","transcriptPath":"` + testTranscriptPath + `"}` + + event, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.SessionStart { + t.Errorf("expected event type %v, got %v", agent.SessionStart, event.Type) + } + if event.SessionID != testSessionID { + t.Errorf("expected session_id %q, got %q", testSessionID, event.SessionID) + } + if event.SessionRef != testTranscriptPath { + t.Errorf("expected session_ref %q, got %q", testTranscriptPath, event.SessionRef) + } + if event.Timestamp.IsZero() { + t.Error("expected non-zero timestamp") + } +} + +func TestParseHookEvent_TurnStart_WithoutSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // Copilot userPromptSubmitted without sessionId (current behavior) + input := `{"timestamp":1771465282293,"cwd":"/Users/test/project","prompt":"Hello Copilot"}` + + _, err := ag.ParseHookEvent(HookNameUserPromptSubmitted, strings.NewReader(input)) + + if !errors.Is(err, ErrMissingSessionID) { + t.Errorf("expected ErrMissingSessionID, got %v", err) + } +} + +func TestParseHookEvent_TurnStart_WithSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // userPromptSubmitted with sessionId (future Copilot behavior) + // See: https://github.com/github/copilot-cli/issues/1425 + input := `{"timestamp":1771465282293,"cwd":"/Users/test/project","prompt":"Hello Copilot","sessionId":"` + testSessionID + `","transcriptPath":"` + testTranscriptPath + `"}` + + event, err := ag.ParseHookEvent(HookNameUserPromptSubmitted, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.TurnStart { + t.Errorf("expected event type %v, got %v", agent.TurnStart, event.Type) + } + if event.SessionID != testSessionID { + t.Errorf("expected session_id %q, got %q", testSessionID, event.SessionID) + } + if event.Prompt != "Hello Copilot" { + t.Errorf("expected prompt %q, got %q", "Hello Copilot", event.Prompt) + } +} + +func TestParseHookEvent_TurnEnd(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + input := `{ + "timestamp": 1771465289990, + "cwd": "/Users/test/project", + "sessionId": "733bfbbd-bd9b-4750-a55b-3c8cc42629de", + "transcriptPath": "/Users/test/.copilot/session-state/733bfbbd/events.jsonl", + "stopReason": "end_turn" + }` + + event, err := ag.ParseHookEvent(HookNameAgentStop, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.TurnEnd { + t.Errorf("expected event type %v, got %v", agent.TurnEnd, event.Type) + } + if event.SessionID != "733bfbbd-bd9b-4750-a55b-3c8cc42629de" { + t.Errorf("expected session_id, got %q", event.SessionID) + } + if event.SessionRef != "/Users/test/.copilot/session-state/733bfbbd/events.jsonl" { + t.Errorf("expected session_ref, got %q", event.SessionRef) + } +} + +func TestParseHookEvent_SessionEnd_WithoutSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // Copilot sessionEnd without sessionId (current behavior) + input := `{"timestamp":1771465290000,"cwd":"/Users/test/project","reason":"complete"}` + + _, err := ag.ParseHookEvent(HookNameSessionEnd, strings.NewReader(input)) + + if !errors.Is(err, ErrMissingSessionID) { + t.Errorf("expected ErrMissingSessionID, got %v", err) + } +} + +func TestParseHookEvent_SessionEnd_WithSessionID(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + // sessionEnd with sessionId (future Copilot behavior) + // See: https://github.com/github/copilot-cli/issues/1425 + input := `{"timestamp":1771465290000,"cwd":"/Users/test/project","reason":"complete","sessionId":"` + testSessionID + `","transcriptPath":"` + testTranscriptPath + `"}` + + event, err := ag.ParseHookEvent(HookNameSessionEnd, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.SessionEnd { + t.Errorf("expected event type %v, got %v", agent.SessionEnd, event.Type) + } + if event.SessionID != testSessionID { + t.Errorf("expected session_id %q, got %q", testSessionID, event.SessionID) + } + if event.SessionRef != testTranscriptPath { + t.Errorf("expected session_ref %q, got %q", testTranscriptPath, event.SessionRef) + } +} + +func TestParseHookEvent_SubagentEnd(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + input := `{"timestamp":1771465290000,"cwd":"/Users/test/project","sessionId":"sub-123","transcriptPath":"/tmp/sub.jsonl"}` + + event, err := ag.ParseHookEvent(HookNameSubagentStop, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != agent.SubagentEnd { + t.Errorf("expected event type %v, got %v", agent.SubagentEnd, event.Type) + } +} + +func TestParseHookEvent_PassThroughHooks_ReturnNil(t *testing.T) { + t.Parallel() + + passThroughHooks := []string{ + HookNamePreToolUse, + HookNamePostToolUse, + HookNameErrorOccurred, + } + + ag := &CopilotAgent{} + input := `{"timestamp":1771465282293,"cwd":"/Users/test/project"}` + + for _, hookName := range passThroughHooks { + t.Run(hookName, func(t *testing.T) { + t.Parallel() + + event, err := ag.ParseHookEvent(hookName, strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error for %s: %v", hookName, err) + } + if event != nil { + t.Errorf("expected nil event for %s, got %+v", hookName, event) + } + }) + } +} + +func TestParseHookEvent_UnknownHook_ReturnsNil(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + input := `{"timestamp":1771465282293,"cwd":"/Users/test/project"}` + + event, err := ag.ParseHookEvent("unknown-hook-name", strings.NewReader(input)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if event != nil { + t.Errorf("expected nil event for unknown hook, got %+v", event) + } +} + +func TestParseHookEvent_EmptyInput(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + _, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader("")) + + if err == nil { + t.Fatal("expected error for empty input, got nil") + } + if !strings.Contains(err.Error(), "empty hook input") { + t.Errorf("expected 'empty hook input' error, got: %v", err) + } +} + +func TestParseHookEvent_MalformedJSON(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + input := `{"timestamp": INVALID}` + + _, err := ag.ParseHookEvent(HookNameSessionStart, strings.NewReader(input)) + + if err == nil { + t.Fatal("expected error for malformed JSON, got nil") + } + if !strings.Contains(err.Error(), "failed to parse hook input") { + t.Errorf("expected 'failed to parse hook input' error, got: %v", err) + } +} + +func TestParseHookEvent_AllLifecycleHooks_WithSessionID(t *testing.T) { + t.Parallel() + + // When sessionId is present, all lifecycle hooks should produce events. + // See: https://github.com/github/copilot-cli/issues/1425 + testCases := []struct { + hookName string + expectedType agent.EventType + expectNil bool + inputTemplate string + }{ + { + hookName: HookNameSessionStart, + expectedType: agent.SessionStart, + inputTemplate: `{"timestamp":1000,"cwd":"/tmp","source":"new","sessionId":"s1","transcriptPath":"/t"}`, + }, + { + hookName: HookNameUserPromptSubmitted, + expectedType: agent.TurnStart, + inputTemplate: `{"timestamp":1000,"cwd":"/tmp","prompt":"hi","sessionId":"s2","transcriptPath":"/t"}`, + }, + { + hookName: HookNameAgentStop, + expectedType: agent.TurnEnd, + inputTemplate: `{"timestamp":1000,"cwd":"/tmp","sessionId":"s3","transcriptPath":"/t","stopReason":"end_turn"}`, + }, + { + hookName: HookNameSessionEnd, + expectedType: agent.SessionEnd, + inputTemplate: `{"timestamp":1000,"cwd":"/tmp","reason":"complete","sessionId":"s4","transcriptPath":"/t"}`, + }, + { + hookName: HookNameSubagentStop, + expectedType: agent.SubagentEnd, + inputTemplate: `{"timestamp":1000,"cwd":"/tmp","sessionId":"s5","transcriptPath":"/t"}`, + }, + { + hookName: HookNamePreToolUse, + expectNil: true, + inputTemplate: `{"timestamp":1000,"cwd":"/tmp","sessionId":"s6"}`, + }, + { + hookName: HookNamePostToolUse, + expectNil: true, + inputTemplate: `{"timestamp":1000,"cwd":"/tmp","sessionId":"s6"}`, + }, + { + hookName: HookNameErrorOccurred, + expectNil: true, + inputTemplate: `{"timestamp":1000,"cwd":"/tmp"}`, + }, + } + + for _, tc := range testCases { + t.Run(tc.hookName, func(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + event, err := ag.ParseHookEvent(tc.hookName, strings.NewReader(tc.inputTemplate)) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tc.expectNil { + if event != nil { + t.Errorf("expected nil event, got %+v", event) + } + return + } + + if event == nil { + t.Fatal("expected event, got nil") + } + if event.Type != tc.expectedType { + t.Errorf("expected event type %v, got %v", tc.expectedType, event.Type) + } + }) + } +} + +func TestParseHookEvent_WithoutSessionID_ReturnsError(t *testing.T) { + t.Parallel() + + // When sessionId is absent (current Copilot behavior), lifecycle hooks + // should return ErrMissingSessionID. + // See: https://github.com/github/copilot-cli/issues/1425 + hooks := []struct { + hookName string + inputTemplate string + }{ + { + hookName: HookNameSessionStart, + inputTemplate: `{"timestamp":1000,"cwd":"/tmp","source":"new"}`, + }, + { + hookName: HookNameUserPromptSubmitted, + inputTemplate: `{"timestamp":1000,"cwd":"/tmp","prompt":"hi"}`, + }, + { + hookName: HookNameSessionEnd, + inputTemplate: `{"timestamp":1000,"cwd":"/tmp","reason":"complete"}`, + }, + } + + for _, tc := range hooks { + t.Run(tc.hookName, func(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + _, err := ag.ParseHookEvent(tc.hookName, strings.NewReader(tc.inputTemplate)) + + if !errors.Is(err, ErrMissingSessionID) { + t.Errorf("expected ErrMissingSessionID, got %v", err) + } + }) + } +} diff --git a/cmd/entire/cli/agent/copilot/transcript.go b/cmd/entire/cli/agent/copilot/transcript.go new file mode 100644 index 000000000..21ac23d69 --- /dev/null +++ b/cmd/entire/cli/agent/copilot/transcript.go @@ -0,0 +1,244 @@ +package copilot + +import ( + "bytes" + "encoding/json" + "slices" + "strings" +) + +// Copilot events.jsonl transcript parsing. +// +// Copilot CLI stores session transcripts as JSONL files at: +// ~/.copilot/session-state//events.jsonl +// +// Each line is a JSON object with the schema: +// {"type":"", "data":{...}, "id":"uuid", "timestamp":"iso8601", "parentId":"uuid|null"} +// +// Known event types: +// session.start, session.end, session.mode_changed, session.model_change +// user.message, assistant.message, assistant.turn_start, assistant.turn_end +// tool.execution_start, tool.execution_complete + +// countLines counts the number of non-empty lines in JSONL content. +func countLines(data []byte) int { + if len(data) == 0 { + return 0 + } + + count := 0 + lines := bytes.SplitSeq(data, []byte("\n")) + for line := range lines { + if len(bytes.TrimSpace(line)) > 0 { + count++ + } + } + return count +} + +// extractModifiedFilesFromLines extracts file paths modified by tool calls +// from events.jsonl lines starting at the given offset. +// Looks for tool.execution_start events where the tool name is a file modification tool. +func extractModifiedFilesFromLines(data []byte, startOffset int) []string { + lines := bytes.Split(data, []byte("\n")) + fileSet := make(map[string]bool) + var files []string + + lineIndex := 0 + for _, line := range lines { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + continue + } + + if lineIndex < startOffset { + lineIndex++ + continue + } + lineIndex++ + + var event Event + if err := json.Unmarshal(trimmed, &event); err != nil { + continue + } + + // Look for tool execution events that modify files + if event.Type != EventToolExecStart { + continue + } + + var toolData toolExecStartData + if err := json.Unmarshal(event.Data, &toolData); err != nil { + continue + } + + if !isFileModificationTool(toolData.ToolName) { + continue + } + + file := extractFilePathFromArgs(toolData.Arguments) + if file != "" && !fileSet[file] { + fileSet[file] = true + files = append(files, file) + } + } + + return files +} + +// extractPromptsFromData extracts user prompts from events.jsonl data +// starting at the given line offset. +// Looks for user.message events and extracts the raw content (not the transformed version +// which includes injected context like timestamps and reminders). +func extractPromptsFromData(data []byte, fromOffset int) ([]string, error) { + lines := bytes.Split(data, []byte("\n")) + var prompts []string + + lineIndex := 0 + for _, line := range lines { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + continue + } + + if lineIndex < fromOffset { + lineIndex++ + continue + } + lineIndex++ + + var event Event + if err := json.Unmarshal(trimmed, &event); err != nil { + continue + } + + if event.Type != EventUserMessage { + continue + } + + var msgData userMessageData + if err := json.Unmarshal(event.Data, &msgData); err != nil { + continue + } + + if msgData.Content != "" { + prompts = append(prompts, msgData.Content) + } + } + + return prompts, nil +} + +// extractLastAssistantMessage extracts the last non-empty assistant message +// from events.jsonl data. Searches backwards for the last assistant.message +// event with content. +func extractLastAssistantMessage(data []byte) string { + lines := bytes.Split(data, []byte("\n")) + + for i := len(lines) - 1; i >= 0; i-- { + trimmed := bytes.TrimSpace(lines[i]) + if len(trimmed) == 0 { + continue + } + + var event Event + if err := json.Unmarshal(trimmed, &event); err != nil { + continue + } + + if event.Type != EventAssistantMessage { + continue + } + + var msgData assistantMessageData + if err := json.Unmarshal(event.Data, &msgData); err != nil { + continue + } + + if msgData.Content != "" { + return msgData.Content + } + } + + return "" +} + +// ParseTranscript parses a Copilot events.jsonl transcript into structured events. +// This is used by the summarizer to build a condensed view. +func ParseTranscript(data []byte) ([]Event, error) { //nolint:unparam // error kept for API consistency with other transcript parsers + lines := bytes.Split(data, []byte("\n")) + var events []Event + + for _, line := range lines { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + continue + } + + var event Event + if err := json.Unmarshal(trimmed, &event); err != nil { + continue + } + events = append(events, event) + } + + return events, nil +} + +// isFileModificationTool checks if a tool name modifies files. +func isFileModificationTool(toolName string) bool { + return slices.Contains(fileModificationTools, toolName) +} + +// extractFilePathFromArgs extracts a file path from tool arguments map. +// Checks common field names used by Copilot tools: path, file_path, filePath. +func extractFilePathFromArgs(args map[string]any) string { + for _, key := range []string{"path", "file_path", "filePath"} { + if v, ok := args[key]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + } + return "" +} + +// sliceFromLine returns JSONL data scoped to lines starting from startLine. +// Returns the original data if startLine <= 0. +// Returns nil if startLine exceeds the number of lines. +func sliceFromLine(data []byte, startLine int) []byte { + if len(data) == 0 || startLine <= 0 { + return data + } + + lines := bytes.Split(data, []byte("\n")) + lineIndex := 0 + var result [][]byte + + for _, line := range lines { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + continue + } + + if lineIndex >= startLine { + result = append(result, line) + } + lineIndex++ + } + + if len(result) == 0 { + return nil + } + + return []byte(strings.Join(byteSlicesToStrings(result), "\n")) +} + +// byteSlicesToStrings converts [][]byte to []string. +func byteSlicesToStrings(slices [][]byte) []string { + result := make([]string, len(slices)) + for i, s := range slices { + result[i] = string(s) + } + return result +} diff --git a/cmd/entire/cli/agent/copilot/transcript_test.go b/cmd/entire/cli/agent/copilot/transcript_test.go new file mode 100644 index 000000000..afe1cff61 --- /dev/null +++ b/cmd/entire/cli/agent/copilot/transcript_test.go @@ -0,0 +1,394 @@ +package copilot + +import ( + "os" + "path/filepath" + "testing" +) + +// sampleEventsJSONL contains a realistic Copilot events.jsonl with various event types. +// Based on actual captured data from Copilot CLI v0.0.411. +const sampleEventsJSONL = `{"type":"session.start","data":{"sessionId":"733bfbbd-bd9b-4750-a55b-3c8cc42629de","version":1,"producer":"copilot-cli","copilotVersion":"0.0.411","startTime":"2025-06-19T18:21:24.159Z","context":{"cwd":"/Users/test/project","gitRoot":"/Users/test/project","branch":"main","repository":"test/project"}},"id":"e1","timestamp":"2025-06-19T18:21:24.159Z","parentId":null} +{"type":"session.model_change","data":{"model":"claude-sonnet-4-20250514"},"id":"e2","timestamp":"2025-06-19T18:21:24.160Z","parentId":"e1"} +{"type":"user.message","data":{"content":"Read the file main.go","transformedContent":"(context injected) Read the file main.go"},"id":"e3","timestamp":"2025-06-19T18:21:24.161Z","parentId":"e1"} +{"type":"assistant.turn_start","data":{},"id":"e4","timestamp":"2025-06-19T18:21:25.000Z","parentId":"e3"} +{"type":"assistant.message","data":{"messageId":"msg1","content":"","toolRequests":[{"toolCallId":"tc1","name":"view","arguments":{"path":"/Users/test/project/main.go"},"type":"function"}]},"id":"e5","timestamp":"2025-06-19T18:21:26.000Z","parentId":"e4"} +{"type":"tool.execution_start","data":{"toolCallId":"tc1","toolName":"view","arguments":{"path":"/Users/test/project/main.go"}},"id":"e6","timestamp":"2025-06-19T18:21:26.001Z","parentId":"e5"} +{"type":"tool.execution_complete","data":{"toolCallId":"tc1","success":true,"result":{"content":"package main..."}},"id":"e7","timestamp":"2025-06-19T18:21:26.100Z","parentId":"e6"} +{"type":"assistant.message","data":{"messageId":"msg2","content":"I've read the file main.go. It contains a basic Go program.","toolRequests":[]},"id":"e8","timestamp":"2025-06-19T18:21:27.000Z","parentId":"e7"} +{"type":"assistant.turn_end","data":{"stopReason":"end_turn"},"id":"e9","timestamp":"2025-06-19T18:21:27.100Z","parentId":"e8"} +{"type":"user.message","data":{"content":"thanks","transformedContent":"thanks"},"id":"e10","timestamp":"2025-06-19T18:21:30.000Z","parentId":"e9"} +{"type":"assistant.message","data":{"messageId":"msg3","content":"You're welcome!","toolRequests":[]},"id":"e11","timestamp":"2025-06-19T18:21:31.000Z","parentId":"e10"}` + +// sampleEventsWithCreate contains events.jsonl data with file modification tools (create, edit). +const sampleEventsWithCreate = `{"type":"session.start","data":{"sessionId":"10855a12-xxxx","version":1},"id":"e1","timestamp":"2025-06-19T18:25:00.000Z","parentId":null} +{"type":"user.message","data":{"content":"Create a test file","transformedContent":"Create a test file"},"id":"e2","timestamp":"2025-06-19T18:25:01.000Z","parentId":"e1"} +{"type":"assistant.turn_start","data":{},"id":"e3","timestamp":"2025-06-19T18:25:02.000Z","parentId":"e2"} +{"type":"assistant.message","data":{"messageId":"msg1","content":"","toolRequests":[{"toolCallId":"tc1","name":"create","arguments":{"path":"/tmp/test.txt","content":"hello"},"type":"function"}]},"id":"e4","timestamp":"2025-06-19T18:25:03.000Z","parentId":"e3"} +{"type":"tool.execution_start","data":{"toolCallId":"tc1","toolName":"create","arguments":{"path":"/tmp/test.txt","content":"hello"}},"id":"e5","timestamp":"2025-06-19T18:25:03.001Z","parentId":"e4"} +{"type":"tool.execution_complete","data":{"toolCallId":"tc1","success":true,"result":{"content":"Created /tmp/test.txt"}},"id":"e6","timestamp":"2025-06-19T18:25:03.100Z","parentId":"e5"} +{"type":"assistant.message","data":{"messageId":"msg2","content":"","toolRequests":[{"toolCallId":"tc2","name":"edit","arguments":{"file_path":"/tmp/other.go","old_string":"foo","new_string":"bar"},"type":"function"}]},"id":"e7","timestamp":"2025-06-19T18:25:04.000Z","parentId":"e6"} +{"type":"tool.execution_start","data":{"toolCallId":"tc2","toolName":"edit","arguments":{"file_path":"/tmp/other.go","old_string":"foo","new_string":"bar"}},"id":"e8","timestamp":"2025-06-19T18:25:04.001Z","parentId":"e7"} +{"type":"tool.execution_complete","data":{"toolCallId":"tc2","success":true,"result":{"content":"Edited /tmp/other.go"}},"id":"e9","timestamp":"2025-06-19T18:25:04.100Z","parentId":"e8"} +{"type":"assistant.message","data":{"messageId":"msg3","content":"Done! Created test.txt and edited other.go.","toolRequests":[]},"id":"e10","timestamp":"2025-06-19T18:25:05.000Z","parentId":"e9"}` + +func Test_countLines(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + want int + }{ + {"empty", "", 0}, + {"single line", `{"type":"session.start","data":{}}`, 1}, + {"two lines", `{"type":"session.start","data":{}} +{"type":"user.message","data":{}}`, 2}, + {"trailing newline", `{"type":"session.start","data":{}} +`, 1}, + {"blank lines ignored", `{"type":"session.start","data":{}} + +{"type":"user.message","data":{}} +`, 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := countLines([]byte(tt.data)) + if got != tt.want { + t.Errorf("countLines() = %d, want %d", got, tt.want) + } + }) + } +} + +func Test_extractModifiedFilesFromLines(t *testing.T) { + t.Parallel() + + files := extractModifiedFilesFromLines([]byte(sampleEventsWithCreate), 0) + + if len(files) != 2 { + t.Fatalf("extractModifiedFilesFromLines() got %d files, want 2: %v", len(files), files) + } + + expectedFiles := map[string]bool{"/tmp/test.txt": true, "/tmp/other.go": true} + for _, f := range files { + if !expectedFiles[f] { + t.Errorf("unexpected file: %s", f) + } + } +} + +func Test_extractModifiedFilesFromLines_ViewNotIncluded(t *testing.T) { + t.Parallel() + + // sampleEventsJSONL has a "view" tool call but no create/edit + files := extractModifiedFilesFromLines([]byte(sampleEventsJSONL), 0) + + if len(files) != 0 { + t.Errorf("extractModifiedFilesFromLines() got %d files, want 0 (view is not a modification tool): %v", len(files), files) + } +} + +func Test_extractModifiedFilesFromLines_WithOffset(t *testing.T) { + t.Parallel() + + // sampleEventsWithCreate: create tool is at line index 4, edit tool is at line index 7 + // Skip first 6 lines (indices 0-5) to only get the edit tool + files := extractModifiedFilesFromLines([]byte(sampleEventsWithCreate), 6) + + if len(files) != 1 { + t.Fatalf("extractModifiedFilesFromLines() got %d files, want 1: %v", len(files), files) + } + if files[0] != "/tmp/other.go" { + t.Errorf("expected /tmp/other.go, got %s", files[0]) + } +} + +func Test_extractModifiedFilesFromLines_Empty(t *testing.T) { + t.Parallel() + + files := extractModifiedFilesFromLines([]byte(""), 0) + if len(files) != 0 { + t.Errorf("expected 0 files for empty data, got %d", len(files)) + } +} + +func Test_extractModifiedFilesFromLines_Dedup(t *testing.T) { + t.Parallel() + + // Two create events for the same file + data := `{"type":"tool.execution_start","data":{"toolCallId":"tc1","toolName":"create","arguments":{"path":"/tmp/same.txt"}},"id":"e1","timestamp":"2025-01-01T00:00:00Z","parentId":null} +{"type":"tool.execution_start","data":{"toolCallId":"tc2","toolName":"create","arguments":{"path":"/tmp/same.txt"}},"id":"e2","timestamp":"2025-01-01T00:00:01Z","parentId":"e1"}` + + files := extractModifiedFilesFromLines([]byte(data), 0) + + if len(files) != 1 { + t.Errorf("expected 1 deduplicated file, got %d: %v", len(files), files) + } +} + +func Test_extractPromptsFromData(t *testing.T) { + t.Parallel() + + prompts, err := extractPromptsFromData([]byte(sampleEventsJSONL), 0) + if err != nil { + t.Fatalf("extractPromptsFromData() error = %v", err) + } + + if len(prompts) != 2 { + t.Fatalf("expected 2 prompts, got %d: %v", len(prompts), prompts) + } + if prompts[0] != "Read the file main.go" { + t.Errorf("first prompt = %q, want 'Read the file main.go'", prompts[0]) + } + if prompts[1] != "thanks" { + t.Errorf("second prompt = %q, want 'thanks'", prompts[1]) + } +} + +func Test_extractPromptsFromData_WithOffset(t *testing.T) { + t.Parallel() + + // The second user.message is at line index 9 (0-based) in sampleEventsJSONL + // Skip first 5 lines to skip the first user.message + prompts, err := extractPromptsFromData([]byte(sampleEventsJSONL), 5) + if err != nil { + t.Fatalf("extractPromptsFromData() error = %v", err) + } + + if len(prompts) != 1 { + t.Fatalf("expected 1 prompt, got %d: %v", len(prompts), prompts) + } + if prompts[0] != "thanks" { + t.Errorf("prompt = %q, want 'thanks'", prompts[0]) + } +} + +func Test_extractLastAssistantMessage(t *testing.T) { + t.Parallel() + + result := extractLastAssistantMessage([]byte(sampleEventsJSONL)) + if result != "You're welcome!" { + t.Errorf("extractLastAssistantMessage() = %q, want 'You're welcome!'", result) + } +} + +func Test_extractLastAssistantMessage_SkipsEmptyContent(t *testing.T) { + t.Parallel() + + // sampleEventsWithCreate has assistant messages with empty content (tool-only) and one final message + result := extractLastAssistantMessage([]byte(sampleEventsWithCreate)) + if result != "Done! Created test.txt and edited other.go." { + t.Errorf("extractLastAssistantMessage() = %q, want 'Done! Created test.txt and edited other.go.'", result) + } +} + +func Test_extractLastAssistantMessage_Empty(t *testing.T) { + t.Parallel() + + result := extractLastAssistantMessage([]byte("")) + if result != "" { + t.Errorf("expected empty string for empty data, got %q", result) + } +} + +func Test_extractLastAssistantMessage_NoAssistant(t *testing.T) { + t.Parallel() + + data := `{"type":"user.message","data":{"content":"hello"},"id":"e1","timestamp":"2025-01-01T00:00:00Z","parentId":null}` + result := extractLastAssistantMessage([]byte(data)) + if result != "" { + t.Errorf("expected empty string when no assistant messages, got %q", result) + } +} + +func Test_sliceFromLine(t *testing.T) { + t.Parallel() + + data := []byte(`{"type":"session.start","data":{},"id":"e1","timestamp":"T","parentId":null} +{"type":"user.message","data":{"content":"hi"},"id":"e2","timestamp":"T","parentId":"e1"} +{"type":"assistant.message","data":{"content":"hello"},"id":"e3","timestamp":"T","parentId":"e2"}`) + + t.Run("start from 0", func(t *testing.T) { + t.Parallel() + result := sliceFromLine(data, 0) + if string(result) != string(data) { + t.Errorf("sliceFromLine(0) should return original data") + } + }) + + t.Run("start from 1", func(t *testing.T) { + t.Parallel() + result := sliceFromLine(data, 1) + if result == nil { + t.Fatal("sliceFromLine(1) returned nil") + } + lines := countLines(result) + if lines != 2 { + t.Errorf("sliceFromLine(1) has %d lines, want 2", lines) + } + }) + + t.Run("start past end", func(t *testing.T) { + t.Parallel() + result := sliceFromLine(data, 10) + if result != nil { + t.Errorf("sliceFromLine(10) should return nil, got %q", result) + } + }) + + t.Run("empty data", func(t *testing.T) { + t.Parallel() + result := sliceFromLine([]byte(""), 0) + if len(result) != 0 { + t.Errorf("sliceFromLine on empty data should return empty") + } + }) +} + +func TestGetTranscriptPosition(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + transcriptPath := filepath.Join(tempDir, "events.jsonl") + + if err := os.WriteFile(transcriptPath, []byte(sampleEventsJSONL), 0o644); err != nil { + t.Fatal(err) + } + + ag := &CopilotAgent{} + pos, err := ag.GetTranscriptPosition(transcriptPath) + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + if pos != 11 { + t.Errorf("GetTranscriptPosition() = %d, want 11", pos) + } +} + +func TestGetTranscriptPosition_EmptyPath(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + pos, err := ag.GetTranscriptPosition("") + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + if pos != 0 { + t.Errorf("GetTranscriptPosition('') = %d, want 0", pos) + } +} + +func TestGetTranscriptPosition_NonexistentFile(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + pos, err := ag.GetTranscriptPosition("/nonexistent/file.jsonl") + if err != nil { + t.Fatalf("GetTranscriptPosition() error = %v", err) + } + if pos != 0 { + t.Errorf("GetTranscriptPosition() = %d, want 0 for nonexistent file", pos) + } +} + +func TestExtractModifiedFilesFromOffset(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + transcriptPath := filepath.Join(tempDir, "events.jsonl") + + if err := os.WriteFile(transcriptPath, []byte(sampleEventsWithCreate), 0o644); err != nil { + t.Fatal(err) + } + + ag := &CopilotAgent{} + files, pos, err := ag.ExtractModifiedFilesFromOffset(transcriptPath, 0) + if err != nil { + t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err) + } + if pos != 10 { + t.Errorf("position = %d, want 10", pos) + } + if len(files) != 2 { + t.Errorf("files = %d, want 2: %v", len(files), files) + } +} + +func TestExtractModifiedFilesFromOffset_EmptyPath(t *testing.T) { + t.Parallel() + + ag := &CopilotAgent{} + files, pos, err := ag.ExtractModifiedFilesFromOffset("", 0) + if err != nil { + t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err) + } + if pos != 0 || len(files) != 0 { + t.Errorf("expected empty results for empty path, got pos=%d files=%v", pos, files) + } +} + +func TestParseTranscript(t *testing.T) { + t.Parallel() + + events, err := ParseTranscript([]byte(sampleEventsJSONL)) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + if len(events) != 11 { + t.Fatalf("ParseTranscript() returned %d events, want 11", len(events)) + } + + // Verify event types (using string literals for types without constants) + expectedTypes := []string{ + "session.start", + "session.model_change", + EventUserMessage, + "assistant.turn_start", + EventAssistantMessage, + EventToolExecStart, + "tool.execution_complete", + EventAssistantMessage, + "assistant.turn_end", + EventUserMessage, + EventAssistantMessage, + } + + for i, expected := range expectedTypes { + if events[i].Type != expected { + t.Errorf("events[%d].Type = %q, want %q", i, events[i].Type, expected) + } + } +} + +func TestParseTranscript_Empty(t *testing.T) { + t.Parallel() + + events, err := ParseTranscript([]byte("")) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + if len(events) != 0 { + t.Errorf("ParseTranscript() returned %d events for empty input, want 0", len(events)) + } +} + +func TestParseTranscript_MalformedLines(t *testing.T) { + t.Parallel() + + // Malformed lines should be skipped + data := `{"type":"user.message","data":{"content":"hello"},"id":"e1","timestamp":"T","parentId":null} +not valid json +{"type":"assistant.message","data":{"content":"hi"},"id":"e2","timestamp":"T","parentId":"e1"}` + + events, err := ParseTranscript([]byte(data)) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + if len(events) != 2 { + t.Errorf("ParseTranscript() returned %d events, want 2 (skipping malformed line)", len(events)) + } +} diff --git a/cmd/entire/cli/agent/copilot/types.go b/cmd/entire/cli/agent/copilot/types.go new file mode 100644 index 000000000..6c5005731 --- /dev/null +++ b/cmd/entire/cli/agent/copilot/types.go @@ -0,0 +1,148 @@ +package copilot + +import "encoding/json" + +// hookConfig represents the .github/hooks/copilot-setup.json structure +type hookConfig struct { + Version int `json:"version"` + Hooks map[string][]hookEntry `json:"hooks"` +} + +// hookEntry represents a single hook command entry +type hookEntry struct { + Type string `json:"type"` + Bash string `json:"bash"` + TimeoutSec int `json:"timeoutSec,omitempty"` +} + +// --- Hook stdin types (from actual Copilot CLI v0.0.411 capture) --- +// +// Common fields: "timestamp" (unix milliseconds) and "cwd" are present in all hooks. +// "sessionId" and "transcriptPath" are currently only sent by the agentStop hook, +// but are declared in hookBase so that all hooks parse them when Copilot adds support. +// See: https://github.com/github/copilot-cli/issues/1425 + +// hookBase contains fields present in Copilot hook stdin JSON. +// sessionId and transcriptPath are optional: currently only agentStop sends them, +// but they are included here so all hooks will pick them up automatically. +type hookBase struct { + Timestamp int64 `json:"timestamp"` + Cwd string `json:"cwd"` + SessionID string `json:"sessionId,omitempty"` + TranscriptPath string `json:"transcriptPath,omitempty"` +} + +// sessionStartRaw is the stdin JSON for the sessionStart hook. +// Example: {"timestamp":1771465283476,"cwd":"/path","source":"new","initialPrompt":"...","sessionId":"uuid","transcriptPath":"/path/events.jsonl"} +type sessionStartRaw struct { + hookBase + + Source string `json:"source,omitempty"` + InitialPrompt string `json:"initialPrompt,omitempty"` +} + +// sessionEndRaw is the stdin JSON for the sessionEnd hook. +// Example: {"timestamp":1771465290000,"cwd":"/path","reason":"complete","sessionId":"uuid","transcriptPath":"/path/events.jsonl"} +type sessionEndRaw struct { + hookBase + + Reason string `json:"reason,omitempty"` +} + +// userPromptRaw is the stdin JSON for the userPromptSubmitted hook. +// Example: {"timestamp":1771465282293,"cwd":"/path","prompt":"...","sessionId":"uuid","transcriptPath":"/path/events.jsonl"} +type userPromptRaw struct { + hookBase + + Prompt string `json:"prompt,omitempty"` +} + +// agentStopRaw is the stdin JSON for the agentStop hook. +// This hook always includes sessionId and transcriptPath. +// Example: {"timestamp":...,"cwd":"/path","sessionId":"uuid","transcriptPath":"/path/events.jsonl","stopReason":"end_turn"} +type agentStopRaw struct { + hookBase + + StopReason string `json:"stopReason,omitempty"` +} + +// subagentStopRaw is the stdin JSON for the subagentStop hook. +// Assumed to follow agentStop format (not yet captured in the wild). +type subagentStopRaw struct { + hookBase +} + +// toolUseRaw is the stdin JSON for preToolUse/postToolUse hooks. +// Note: toolArgs is a JSON string in preToolUse but an object in postToolUse. +// Example (pre): {"timestamp":...,"cwd":"/path","toolName":"view","toolArgs":"{\"path\":\"/foo\"}"} +// Example (post): {"timestamp":...,"cwd":"/path","toolName":"view","toolArgs":{"path":"/foo"},"toolResult":{...}} +type toolUseRaw struct { + hookBase + + ToolName string `json:"toolName"` + ToolArgs json.RawMessage `json:"toolArgs,omitempty"` + ToolResult json.RawMessage `json:"toolResult,omitempty"` +} + +// --- events.jsonl types (session transcript format) --- + +// Event represents a single line in a Copilot events.jsonl transcript. +type Event struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + ParentID *string `json:"parentId"` +} + +// Event type constants for events.jsonl +// +// Known types not listed here (unused but documented): +// +// session.end, session.mode_changed, session.model_change, +// assistant.turn_start, assistant.turn_end, tool.execution_complete +const ( + EventUserMessage = "user.message" + EventAssistantMessage = "assistant.message" + EventToolExecStart = "tool.execution_start" +) + +// userMessageData is the data field for user.message events. +type userMessageData struct { + Content string `json:"content"` + TransformedContent string `json:"transformedContent"` +} + +// assistantMessageData is the data field for assistant.message events. +type assistantMessageData struct { + MessageID string `json:"messageId"` + Content string `json:"content"` + ToolRequests []toolRequest `json:"toolRequests"` +} + +// toolRequest represents a tool call request within an assistant.message event. +type toolRequest struct { + ToolCallID string `json:"toolCallId"` + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + Type string `json:"type"` +} + +// toolExecStartData is the data field for tool.execution_start events. +type toolExecStartData struct { + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` + Arguments map[string]any `json:"arguments"` +} + +// Tool names used in GitHub Copilot that modify files +const ( + ToolEdit = "edit" + ToolCreate = "create" +) + +// fileModificationTools lists tools that create or modify files in GitHub Copilot +var fileModificationTools = []string{ + ToolEdit, + ToolCreate, +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 0be89d8b6..7797079c5 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -93,12 +93,14 @@ type AgentType string const ( AgentNameClaudeCode AgentName = "claude-code" AgentNameGemini AgentName = "gemini" + AgentNameCopilot AgentName = "github-copilot" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" AgentTypeGemini AgentType = "Gemini CLI" + AgentTypeCopilot AgentType = "GitHub Copilot" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility ) diff --git a/cmd/entire/cli/e2e_test/agent_runner.go b/cmd/entire/cli/e2e_test/agent_runner.go index 89fd8c191..be46e1f2a 100644 --- a/cmd/entire/cli/e2e_test/agent_runner.go +++ b/cmd/entire/cli/e2e_test/agent_runner.go @@ -19,6 +19,9 @@ const AgentNameClaudeCode = "claude-code" // AgentNameGemini is the name for Gemini CLI agent. const AgentNameGemini = "gemini" +// AgentNameCopilot is the name for GitHub Copilot CLI agent. +const AgentNameCopilot = "github-copilot" + // AgentRunner abstracts invoking a coding agent for e2e tests. // This follows the multi-agent pattern from cmd/entire/cli/agent/agent.go. type AgentRunner interface { @@ -58,6 +61,8 @@ func NewAgentRunner(name string, config AgentRunnerConfig) AgentRunner { return NewClaudeCodeRunner(config) case AgentNameGemini: return NewGeminiCLIRunner(config) + case AgentNameCopilot: + return NewCopilotRunner(config) default: // Return a runner that reports as unavailable return &unavailableRunner{name: name} @@ -324,3 +329,109 @@ func (r *GeminiCLIRunner) RunPromptWithTools(ctx context.Context, workDir string result.ExitCode = 0 return result, nil } + +// CopilotRunner implements AgentRunner for GitHub Copilot CLI. +type CopilotRunner struct { + Model string + Timeout time.Duration +} + +// NewCopilotRunner creates a new Copilot CLI runner with the given config. +func NewCopilotRunner(config AgentRunnerConfig) *CopilotRunner { + model := config.Model + if model == "" { + model = os.Getenv("E2E_COPILOT_MODEL") + if model == "" { + model = "claude-haiku-4.5" + } + } + + timeout := config.Timeout + if timeout == 0 { + if envTimeout := os.Getenv("E2E_TIMEOUT"); envTimeout != "" { + if parsed, err := time.ParseDuration(envTimeout); err == nil { + timeout = parsed + } + } + if timeout == 0 { + timeout = 2 * time.Minute + } + } + + return &CopilotRunner{ + Model: model, + Timeout: timeout, + } +} + +func (r *CopilotRunner) Name() string { + return AgentNameCopilot +} + +// IsAvailable checks if Copilot CLI is installed and responds to --version. +func (r *CopilotRunner) IsAvailable() (bool, error) { + if _, err := exec.LookPath("copilot"); err != nil { + return false, fmt.Errorf("copilot CLI not found in PATH: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "copilot", "--version") + if err := cmd.Run(); err != nil { + return false, fmt.Errorf("copilot CLI not working: %w", err) + } + + return true, nil +} + +func (r *CopilotRunner) RunPrompt(ctx context.Context, workDir string, prompt string) (*AgentResult, error) { + return r.RunPromptWithTools(ctx, workDir, prompt, nil) +} + +func (r *CopilotRunner) RunPromptWithTools(ctx context.Context, workDir string, prompt string, _ []string) (*AgentResult, error) { + // Copilot uses --allow-all-tools for non-interactive mode which grants all permissions. + // The tools parameter is ignored because Copilot tool names differ from Claude Code's + // (e.g., "bash" vs "Bash", "create" vs "Write") and --available-tools restricts + // rather than allows, so passing Claude tool names would disable most tools. + args := []string{ + "--model", r.Model, + "-p", prompt, + "--allow-all-tools", + } + + ctx, cancel := context.WithTimeout(ctx, r.Timeout) + defer cancel() + + //nolint:gosec // args are constructed from trusted config, not user input + cmd := exec.CommandContext(ctx, "copilot", args...) + cmd.Dir = workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + start := time.Now() + err := cmd.Run() + duration := time.Since(start) + + result := &AgentResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Duration: duration, + } + + if err != nil { + exitErr := &exec.ExitError{} + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + } else { + result.ExitCode = -1 + } + //nolint:wrapcheck // error is from exec.Run, caller can check ExitCode in result + return result, err + } + + result.ExitCode = 0 + return result, nil +} diff --git a/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go index aaed018b0..e5cf7ae05 100644 --- a/cmd/entire/cli/e2e_test/testenv.go +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -281,6 +281,7 @@ func (env *TestEnv) GitCommitWithShadowHooks(message string, files ...string) { "ENTIRE_TEST_TTY=1", "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_COPILOT_SESSION_DIR="+filepath.Join(env.RepoDir, ".copilot-sessions"), ) if output, err := prepCmd.CombinedOutput(); err != nil { env.T.Logf("prepare-commit-msg output: %s", output) @@ -322,6 +323,7 @@ func (env *TestEnv) GitCommitWithShadowHooks(message string, files ...string) { postCmd.Env = append(os.Environ(), "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_COPILOT_SESSION_DIR="+filepath.Join(env.RepoDir, ".copilot-sessions"), ) if output, err := postCmd.CombinedOutput(); err != nil { env.T.Logf("post-commit output: %s", output) @@ -362,6 +364,7 @@ func (env *TestEnv) GitCommitStagedWithShadowHooks(message string) { "ENTIRE_TEST_TTY=1", "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_COPILOT_SESSION_DIR="+filepath.Join(env.RepoDir, ".copilot-sessions"), ) if output, err := prepCmd.CombinedOutput(); err != nil { env.T.Logf("prepare-commit-msg output: %s", output) @@ -403,6 +406,7 @@ func (env *TestEnv) GitCommitStagedWithShadowHooks(message string) { postCmd.Env = append(os.Environ(), "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_COPILOT_SESSION_DIR="+filepath.Join(env.RepoDir, ".copilot-sessions"), ) if output, err := postCmd.CombinedOutput(); err != nil { env.T.Logf("post-commit output: %s", output) @@ -435,6 +439,7 @@ func (env *TestEnv) GitCommitWithTrailerRemoved(message string, files ...string) "ENTIRE_TEST_TTY=1", "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_COPILOT_SESSION_DIR="+filepath.Join(env.RepoDir, ".copilot-sessions"), ) if output, err := prepCmd.CombinedOutput(); err != nil { env.T.Logf("prepare-commit-msg output: %s", output) @@ -493,6 +498,7 @@ func (env *TestEnv) GitCommitWithTrailerRemoved(message string, files ...string) postCmd.Env = append(os.Environ(), "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+filepath.Join(env.RepoDir, ".claude"), "ENTIRE_TEST_GEMINI_PROJECT_DIR="+filepath.Join(env.RepoDir, ".gemini"), + "ENTIRE_TEST_COPILOT_SESSION_DIR="+filepath.Join(env.RepoDir, ".copilot-sessions"), ) if output, err := postCmd.CombinedOutput(); err != nil { env.T.Logf("post-commit output: %s", output) diff --git a/cmd/entire/cli/hook_registry.go b/cmd/entire/cli/hook_registry.go index bf71bd56f..8172de06d 100644 --- a/cmd/entire/cli/hook_registry.go +++ b/cmd/entire/cli/hook_registry.go @@ -12,6 +12,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + "github.com/entireio/cli/cmd/entire/cli/agent/copilot" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -76,9 +77,9 @@ func newAgentHooksCmd(agentName agent.AgentName, handler agent.HookHandler) *cob // "agent" for all other agent hooks. func getHookType(hookName string) string { switch hookName { - case claudecode.HookNamePreTask, claudecode.HookNamePostTask, claudecode.HookNamePostTodo: + case claudecode.HookNamePreTask, claudecode.HookNamePostTask, claudecode.HookNamePostTodo, copilot.HookNameSubagentStop: return "subagent" - case geminicli.HookNameBeforeTool, geminicli.HookNameAfterTool: + case geminicli.HookNameBeforeTool, geminicli.HookNameAfterTool, copilot.HookNamePreToolUse, copilot.HookNamePostToolUse: return "tool" default: return "agent" diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d12922523..520855f4e 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -4,6 +4,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" // Import agents to ensure they are registered before we iterate _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + _ "github.com/entireio/cli/cmd/entire/cli/agent/copilot" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/spf13/cobra" diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 80fb2932d..c804febeb 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -178,7 +178,7 @@ To completely remove Entire integrations from this repository, use --uninstall: - Git hooks (prepare-commit-msg, commit-msg, post-commit, pre-push) - Session state files (.git/entire-sessions/) - Shadow branches (entire/) - - Agent hooks (Claude Code, Gemini CLI)`, + - Agent hooks (Claude Code, Gemini CLI, GitHub Copilot)`, RunE: func(cmd *cobra.Command, _ []string) error { if uninstall { return runUninstall(cmd.OutOrStdout(), cmd.ErrOrStderr(), force) @@ -1068,13 +1068,12 @@ func runUninstall(w, errW io.Writer, force bool) error { sessionStateCount := countSessionStates() shadowBranchCount := countShadowBranches() gitHooksInstalled := strategy.IsGitHookInstalled() - claudeHooksInstalled := checkClaudeCodeHooksInstalled() - geminiHooksInstalled := checkGeminiCLIHooksInstalled() + installedAgents := GetAgentsWithHooksInstalled() entireDirExists := checkEntireDirExists() // Check if there's anything to uninstall if !entireDirExists && !gitHooksInstalled && sessionStateCount == 0 && - shadowBranchCount == 0 && !claudeHooksInstalled && !geminiHooksInstalled { + shadowBranchCount == 0 && len(installedAgents) == 0 { fmt.Fprintln(w, "Entire is not installed in this repository.") return nil } @@ -1094,13 +1093,14 @@ func runUninstall(w, errW io.Writer, force bool) error { if shadowBranchCount > 0 { fmt.Fprintf(w, " - Shadow branches (%d)\n", shadowBranchCount) } - switch { - case claudeHooksInstalled && geminiHooksInstalled: - fmt.Fprintln(w, " - Agent hooks (Claude Code, Gemini CLI)") - case claudeHooksInstalled: - fmt.Fprintln(w, " - Agent hooks (Claude Code)") - case geminiHooksInstalled: - fmt.Fprintln(w, " - Agent hooks (Gemini CLI)") + if len(installedAgents) > 0 { + agentTypes := make([]string, 0, len(installedAgents)) + for _, name := range installedAgents { + if ag, err := agent.Get(name); err == nil { + agentTypes = append(agentTypes, string(ag.Type())) + } + } + fmt.Fprintf(w, " - Agent hooks (%s)\n", strings.Join(agentTypes, ", ")) } fmt.Fprintln(w) @@ -1189,32 +1189,6 @@ func countShadowBranches() int { return len(branches) } -// checkClaudeCodeHooksInstalled checks if Claude Code hooks are installed. -func checkClaudeCodeHooksInstalled() bool { - ag, err := agent.Get(agent.AgentNameClaudeCode) - if err != nil { - return false - } - hookAgent, ok := ag.(agent.HookSupport) - if !ok { - return false - } - return hookAgent.AreHooksInstalled() -} - -// checkGeminiCLIHooksInstalled checks if Gemini CLI hooks are installed. -func checkGeminiCLIHooksInstalled() bool { - ag, err := agent.Get(agent.AgentNameGemini) - if err != nil { - return false - } - hookAgent, ok := ag.(agent.HookSupport) - if !ok { - return false - } - return hookAgent.AreHooksInstalled() -} - // checkEntireDirExists checks if the .entire directory exists. func checkEntireDirExists() bool { entireDirAbs, err := paths.AbsPath(paths.EntireDir) @@ -1229,29 +1203,20 @@ func checkEntireDirExists() bool { func removeAgentHooks(w io.Writer) error { var errs []error - // Remove Claude Code hooks - claudeAgent, err := agent.Get(agent.AgentNameClaudeCode) - if err == nil { - if hookAgent, ok := claudeAgent.(agent.HookSupport); ok { - wasInstalled := hookAgent.AreHooksInstalled() - if err := hookAgent.UninstallHooks(); err != nil { - errs = append(errs, err) - } else if wasInstalled { - fmt.Fprintln(w, " Removed Claude Code hooks") - } + for _, name := range agent.List() { + ag, err := agent.Get(name) + if err != nil { + continue } - } - - // Remove Gemini CLI hooks - geminiAgent, err := agent.Get(agent.AgentNameGemini) - if err == nil { - if hookAgent, ok := geminiAgent.(agent.HookSupport); ok { - wasInstalled := hookAgent.AreHooksInstalled() - if err := hookAgent.UninstallHooks(); err != nil { - errs = append(errs, err) - } else if wasInstalled { - fmt.Fprintln(w, " Removed Gemini CLI hooks") - } + hookAgent, ok := ag.(agent.HookSupport) + if !ok { + continue + } + wasInstalled := hookAgent.AreHooksInstalled() + if err := hookAgent.UninstallHooks(); err != nil { + errs = append(errs, err) + } else if wasInstalled { + fmt.Fprintf(w, " Removed %s hooks\n", ag.Type()) } } diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 1d0a820f5..8721fa298 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -10,6 +10,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + _ "github.com/entireio/cli/cmd/entire/cli/agent/copilot" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" diff --git a/cmd/entire/cli/strategy/rewind_test.go b/cmd/entire/cli/strategy/rewind_test.go index bdf560c2b..25c874520 100644 --- a/cmd/entire/cli/strategy/rewind_test.go +++ b/cmd/entire/cli/strategy/rewind_test.go @@ -8,6 +8,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" // Register agent for ResolveAgentForRewind tests + _ "github.com/entireio/cli/cmd/entire/cli/agent/copilot" // Register agent for ResolveAgentForRewind tests _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" // Register agent for ResolveAgentForRewind tests "github.com/go-git/go-git/v5" diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 3aefde7e4..ba55b8858 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/copilot" "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/transcript" @@ -116,8 +117,10 @@ func BuildCondensedTranscriptFromBytes(content []byte, agentType agent.AgentType switch agentType { case agent.AgentTypeGemini: return buildCondensedTranscriptFromGemini(content) + case agent.AgentTypeCopilot: + return buildCondensedTranscriptFromCopilot(content) case agent.AgentTypeClaudeCode, agent.AgentTypeUnknown: - // Claude format - fall through to shared logic below + // JSONL format - fall through to shared logic below } // Claude format (JSONL) - handles Claude Code, Unknown, and any future agent types lines, err := transcript.ParseFromBytes(content) @@ -166,8 +169,73 @@ func buildCondensedTranscriptFromGemini(content []byte) ([]Entry, error) { return entries, nil } +// buildCondensedTranscriptFromCopilot parses a Copilot events.jsonl transcript +// and extracts a condensed view for summarization. +func buildCondensedTranscriptFromCopilot(content []byte) ([]Entry, error) { + events, err := copilot.ParseTranscript(content) + if err != nil { + return nil, fmt.Errorf("failed to parse Copilot transcript: %w", err) + } + + var entries []Entry + for _, event := range events { + switch event.Type { + case copilot.EventUserMessage: + var data struct { + Content string `json:"content"` + } + if err := json.Unmarshal(event.Data, &data); err != nil { + continue + } + if data.Content != "" { + entries = append(entries, Entry{ + Type: EntryTypeUser, + Content: data.Content, + }) + } + + case copilot.EventAssistantMessage: + var data struct { + Content string `json:"content"` + ToolRequests []struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"toolRequests"` + } + if err := json.Unmarshal(event.Data, &data); err != nil { + continue + } + if data.Content != "" { + entries = append(entries, Entry{ + Type: EntryTypeAssistant, + Content: data.Content, + }) + } + for _, tc := range data.ToolRequests { + entries = append(entries, Entry{ + Type: EntryTypeTool, + ToolName: tc.Name, + ToolDetail: extractCopilotToolDetail(tc.Arguments), + }) + } + } + } + + return entries, nil +} + +// extractCopilotToolDetail extracts an appropriate detail string from Copilot tool args. +func extractCopilotToolDetail(args map[string]any) string { + for _, key := range []string{"path", "command", "file_path", "pattern", "description"} { + if v, ok := args[key].(string); ok && v != "" { + return v + } + } + return "" +} + // extractGeminiToolDetail extracts an appropriate detail string from Gemini tool args. -func extractGeminiToolDetail(args map[string]interface{}) string { +func extractGeminiToolDetail(args map[string]any) string { // Check common fields in order of preference for _, key := range []string{"description", "command", "file_path", "path", "pattern"} { if v, ok := args[key].(string); ok && v != "" { diff --git a/copilot-integration.md b/copilot-integration.md new file mode 100644 index 000000000..7b5f01b20 --- /dev/null +++ b/copilot-integration.md @@ -0,0 +1,325 @@ +# Plan: GitHub Copilot CLI Agent Integration + +## Context + +The Entire CLI currently integrates with Claude Code (stable) and Gemini CLI (preview) via a generic Agent interface. GitHub Copilot CLI is a standalone terminal agent (`copilot` command) that supports hooks similar to Claude/Gemini. This plan adds Copilot as a third agent integration (preview status). + +**Good news**: All Copilot hooks include common fields `sessionId`, `transcript_path`, and `hookEventName` in their stdin JSON. Additionally, `agentStop` provides TurnEnd support. This means the integration follows the same pattern as Claude/Gemini. + +--- + +## Hook Inventory (8 hooks) + +| Copilot Hook | CLI Subcommand | Normalized Event | Notes | +|---|---|---|---| +| `sessionStart` | `session-start` | `SessionStart` | Session begins or resumes | +| `sessionEnd` | `session-end` | `SessionEnd` | Session completes/terminated | +| `userPromptSubmitted` | `user-prompt-submitted` | `TurnStart` | User submits prompt | +| `agentStop` | `agent-stop` | `TurnEnd` | Agent finished responding | +| `subagentStop` | `subagent-stop` | `SubagentEnd` | Subagent completed | +| `preToolUse` | `pre-tool-use` | `nil` | Pass-through | +| `postToolUse` | `post-tool-use` | `nil` | Pass-through | +| `errorOccurred` | `error-occurred` | `nil` | Logging only | + +**Common stdin fields** (all hooks): `timestamp`, `cwd`, `sessionId`, `hookEventName`, `transcript_path` + +**Hook-specific fields**: `source`/`initialPrompt` (sessionStart), `reason` (sessionEnd), `prompt` (userPromptSubmitted), `toolName`/`toolArgs` (preToolUse/postToolUse), `toolResult` (postToolUse), `error` (errorOccurred) + +--- + +## Step 0: Verify Hook stdin Format + +**Before writing code**, capture actual stdin JSON to confirm the common fields and `agentStop`/`subagentStop` format: + +```json +{ + "version": 1, + "hooks": { + "sessionStart": [{"type": "command", "bash": "cat > /tmp/copilot-session-start.json", "timeoutSec": 10}], + "agentStop": [{"type": "command", "bash": "cat > /tmp/copilot-agent-stop.json", "timeoutSec": 10}], + "userPromptSubmitted": [{"type": "command", "bash": "cat > /tmp/copilot-user-prompt.json", "timeoutSec": 10}] + } +} +``` + +Place at `.github/hooks/copilot-setup.json`, run `copilot`, check `/tmp/copilot-*.json`. Confirm `sessionId` and `transcript_path` are present. + +--- + +## Step 1: Add Registry Constants + +**File**: `cmd/entire/cli/agent/registry.go` + +```go +// In AgentName constants: +AgentNameCopilot AgentName = "github-copilot" + +// In AgentType constants: +AgentTypeCopilot AgentType = "GitHub Copilot" +``` + +--- + +## Step 2: Create `cmd/entire/cli/agent/copilot/types.go` + +Follow `geminicli/types.go` pattern. Copilot uses camelCase JSON fields. + +**Hook config types:** +```go +type CopilotHookConfig struct { + Version int `json:"version"` + Hooks map[string][]CopilotHookEntry `json:"hooks"` +} + +type CopilotHookEntry struct { + Type string `json:"type"` + Bash string `json:"bash"` + TimeoutSec int `json:"timeoutSec,omitempty"` +} +``` + +**Common stdin base (embedded in all hook input structs):** +```go +type hookBase struct { + SessionID string `json:"sessionId"` + TranscriptPath string `json:"transcript_path"` + Cwd string `json:"cwd"` + HookEventName string `json:"hookEventName"` + Timestamp string `json:"timestamp"` +} +``` + +**Hook-specific stdin types:** +- `sessionStartRaw` - embeds `hookBase` + `Source string`, `InitialPrompt string` +- `sessionEndRaw` - embeds `hookBase` + `Reason string` +- `userPromptRaw` - embeds `hookBase` + `Prompt string` +- `agentStopRaw` - embeds `hookBase` (no extra fields expected) +- `subagentStopRaw` - embeds `hookBase` (+ possibly subagent metadata) +- `toolUseRaw` - embeds `hookBase` + `ToolName string`, `ToolArgs json.RawMessage`, `ToolResult json.RawMessage` +- `errorRaw` - embeds `hookBase` + `Error json.RawMessage` + +**Tool name constants:** +```go +const ( + ToolEdit = "edit" + ToolCreate = "create" +) +var FileModificationTools = []string{ToolEdit, ToolCreate} +``` + +--- + +## Step 3: Create `cmd/entire/cli/agent/copilot/copilot.go` + +Core agent. Follow `geminicli/gemini.go` as template. + +**Self-registration:** +```go +func init() { + agent.Register(agent.AgentNameCopilot, NewCopilotAgent) +} +``` + +**Identity:** +- `Name()` → `agent.AgentNameCopilot` +- `Type()` → `agent.AgentTypeCopilot` +- `Description()` → `"GitHub Copilot - GitHub's AI coding assistant"` +- `IsPreview()` → `true` +- `ProtectedDirs()` → `[]string{}` (`.github` is shared, not ours) + +**Detection** (`DetectPresence()`): +- Check for `.github/hooks/` with an Entire copilot hook file present (AreHooksInstalled) +- OR check `~/.copilot/` directory existence +- Use `paths.RepoRoot()` for repo-relative checks + +**Session storage:** +- `GetHookConfigPath()` → `.github/hooks/copilot-setup.json` +- `GetSessionDir(repoPath)` → `~/.copilot/session-state/` (with `ENTIRE_TEST_COPILOT_SESSION_DIR` override) +- `ResolveSessionFile(dir, sessionID)` → `filepath.Join(dir, sessionID, "events.jsonl")` +- `FormatResumeCommand(sessionID)` → `copilot --resume` + +**Transcript storage** (JSONL - reuse existing helpers): +- `ReadTranscript(sessionRef)` → `os.ReadFile(sessionRef)` +- `ChunkTranscript(content, maxSize)` → `agent.ChunkJSONL(content, maxSize)` +- `ReassembleTranscript(chunks)` → `agent.ReassembleJSONL(chunks)` + +**Legacy methods:** ParseHookInput, GetSessionID, ReadSession, WriteSession (follow Gemini pattern) + +--- + +## Step 4: Create `cmd/entire/cli/agent/copilot/lifecycle.go` + +Event parsing. Follow `geminicli/lifecycle.go` pattern. + +**Compile-time assertions:** +```go +var _ agent.TranscriptAnalyzer = (*CopilotAgent)(nil) +``` + +**`ParseHookEvent` switch:** +```go +case HookNameSessionStart → parseSessionStart(stdin) → agent.SessionStart +case HookNameUserPromptSubmitted → parseTurnStart(stdin) → agent.TurnStart (with Prompt) +case HookNameAgentStop → parseTurnEnd(stdin) → agent.TurnEnd +case HookNameSessionEnd → parseSessionEnd(stdin) → agent.SessionEnd +case HookNameSubagentStop → parseSubagentEnd(stdin) → agent.SubagentEnd +case HookNamePreToolUse, HookNamePostToolUse, HookNameErrorOccurred → nil +``` + +**Internal parse functions** (use `agent.ReadAndParseHookInput[T]` generic helper): +- `parseSessionStart` → reads `sessionStartRaw`, returns Event{SessionStart, SessionID, SessionRef} +- `parseTurnStart` → reads `userPromptRaw`, returns Event{TurnStart, SessionID, SessionRef, Prompt} +- `parseTurnEnd` → reads `agentStopRaw`, returns Event{TurnEnd, SessionID, SessionRef} +- `parseSessionEnd` → reads `sessionEndRaw`, returns Event{SessionEnd, SessionID, SessionRef} +- `parseSubagentEnd` → reads `subagentStopRaw`, returns Event{SubagentEnd, SessionID, SessionRef} + +**TranscriptAnalyzer methods:** +- `GetTranscriptPosition(path)` → count lines in JSONL file +- `ExtractModifiedFilesFromOffset(path, offset)` → parse JSONL from line offset, extract file paths from edit/create events +- `ExtractPrompts(sessionRef, fromOffset)` → extract user message content from JSONL +- `ExtractSummary(sessionRef)` → last assistant message from JSONL + +--- + +## Step 5: Create `cmd/entire/cli/agent/copilot/hooks.go` + +Hook installation into `.github/hooks/copilot-setup.json`. Simpler than Claude/Gemini since config is a flat JSON file (no matchers). + +**Constants:** +```go +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameUserPromptSubmitted = "user-prompt-submitted" + HookNameAgentStop = "agent-stop" + HookNameSubagentStop = "subagent-stop" + HookNamePreToolUse = "pre-tool-use" + HookNamePostToolUse = "post-tool-use" + HookNameErrorOccurred = "error-occurred" +) + +// Maps CLI subcommand names to Copilot's native hook names (camelCase in config) +var hookNameToConfigKey = map[string]string{ + HookNameSessionStart: "sessionStart", + HookNameSessionEnd: "sessionEnd", + HookNameUserPromptSubmitted: "userPromptSubmitted", + HookNameAgentStop: "agentStop", + HookNameSubagentStop: "subagentStop", + HookNamePreToolUse: "preToolUse", + HookNamePostToolUse: "postToolUse", + HookNameErrorOccurred: "errorOccurred", +} +``` + +**`InstallHooks(localDev, force)`:** +1. `paths.RepoRoot()` to find repo root +2. Read/create `.github/hooks/copilot-setup.json` +3. Parse into `CopilotHookConfig` (or create with `version: 1`) +4. For idempotency: check if existing commands match expected +5. Remove existing Entire hooks if force or mode change +6. For each of 8 hooks, add entry: + ```json + {"type": "command", "bash": "entire hooks github-copilot ", "timeoutSec": 10} + ``` +7. Create `.github/hooks/` dir if needed +8. Write back with `json.MarshalIndent` +9. Return count (8) + +**`UninstallHooks()`:** Remove entries matching `entireHookPrefixes`, remove config key if array empty + +**`AreHooksInstalled()`:** Check if any entry matches `entireHookPrefixes` + +**`entireHookPrefixes`:** `["entire ", "go run ${COPILOT_PROJECT_DIR}/cmd/entire/main.go "]` + +--- + +## Step 6: Create `cmd/entire/cli/agent/copilot/transcript.go` + +Transcript parsing for `events.jsonl`. Basic implementation, refined after verifying actual format in Step 0. + +Since Copilot uses JSONL (like Claude Code), the transcript parsing follows the Claude pattern - line-based offsets, JSONL line splitting, etc. The exact event schema will be adapted based on the actual `events.jsonl` content discovered in Step 0. + +**Key functions:** +- Parse JSONL events line by line +- Extract file paths from tool call events (edit, create tools) +- Extract user prompts from user message events +- Extract assistant responses for summaries +- Token usage calculation (if token fields are present in events) + +--- + +## Step 7: Update Existing Files + +### `cmd/entire/cli/hooks_cmd.go` +Add blank import: +```go +_ "github.com/entireio/cli/cmd/entire/cli/agent/copilot" +``` + +### `cmd/entire/cli/hook_registry.go` +Add import and update `getHookType()`: +```go +import "github.com/entireio/cli/cmd/entire/cli/agent/copilot" + +// In getHookType(): +case copilot.HookNamePreToolUse, copilot.HookNamePostToolUse: + return "tool" +case copilot.HookNameSubagentStop: + return "subagent" +case copilot.HookNameErrorOccurred: + return "error" +``` + +### `cmd/entire/cli/setup_test.go` +Add blank import: `_ "github.com/entireio/cli/cmd/entire/cli/agent/copilot"` + +### `cmd/entire/cli/strategy/rewind_test.go` +Add blank import: `_ "github.com/entireio/cli/cmd/entire/cli/agent/copilot"` + +--- + +## Step 8: Write Tests + +All tests use `t.Parallel()`. Follow `geminicli/*_test.go` patterns. + +- **`copilot_test.go`**: Identity, detection, session storage, transcript chunk/reassemble +- **`hooks_test.go`**: Install (fresh, localDev, idempotent, force, preserve user hooks), uninstall, AreHooksInstalled +- **`lifecycle_test.go`**: ParseHookEvent for each hook → correct EventType, nil for pass-throughs, invalid/empty input errors +- **`transcript_test.go`**: JSONL parsing, file extraction, prompt extraction, summary extraction + +--- + +## Files Summary + +### New files (`cmd/entire/cli/agent/copilot/`): +| File | Template | Purpose | +|---|---|---| +| `types.go` | `geminicli/types.go` | Type definitions | +| `copilot.go` | `geminicli/gemini.go` | Core agent impl | +| `lifecycle.go` | `geminicli/lifecycle.go` | Event parsing | +| `hooks.go` | `geminicli/hooks.go` | Hook install/uninstall (simpler config) | +| `transcript.go` | `geminicli/transcript.go` | JSONL transcript parsing | +| `copilot_test.go` | `geminicli/gemini_test.go` | Core tests | +| `hooks_test.go` | `geminicli/hooks_test.go` | Hook tests | +| `lifecycle_test.go` | `geminicli/lifecycle_test.go` | Lifecycle tests | +| `transcript_test.go` | `geminicli/transcript_test.go` | Transcript tests | + +### Modified files: +| File | Change | +|---|---| +| `cmd/entire/cli/agent/registry.go` | Add `AgentNameCopilot`, `AgentTypeCopilot` constants | +| `cmd/entire/cli/hooks_cmd.go` | Add copilot blank import | +| `cmd/entire/cli/hook_registry.go` | Add copilot import + update `getHookType()` | +| `cmd/entire/cli/setup_test.go` | Add copilot blank import | +| `cmd/entire/cli/strategy/rewind_test.go` | Add copilot blank import | + +--- + +## Verification + +1. **Lint + format**: `mise run fmt && mise run lint` +2. **Tests**: `mise run test:ci` (unit + integration) +3. **Manual - hook install**: `entire enable --agent github-copilot` → verify `.github/hooks/copilot-setup.json` created +4. **Manual - commands exist**: `entire hooks github-copilot --help` → shows all 8 subcommands +5. **Manual - agent detection**: Verify `github-copilot` appears in agent list +6. **Manual - with Copilot CLI**: Start `copilot` session → hooks fire → make commit → checkpoint created → `entire session log` shows data diff --git a/mise.toml b/mise.toml index e080e6fdd..98790ad09 100644 --- a/mise.toml +++ b/mise.toml @@ -107,3 +107,7 @@ run = "E2E_AGENT=claude-code go test -tags=e2e -count=1 -timeout=30m -v ./cmd/en [tasks."test:e2e:gemini"] description = "Run E2E tests with Gemini CLI (sequential to avoid rate limits)" run = "E2E_AGENT=gemini go test -tags=e2e -count=1 -parallel 1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..." + +[tasks."test:e2e:copilot"] +description = "Run E2E tests with Github Copilot CLI" +run = "E2E_AGENT=github-copilot go test -tags=e2e -count=1 -timeout=30m -v ./cmd/entire/cli/e2e_test/..." \ No newline at end of file