Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion e2e/agents/copilot-cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package agents

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
)
Expand All @@ -25,7 +28,7 @@ func (c *CopilotCLI) Name() string { return "copilot-cli" }
func (c *CopilotCLI) Binary() string { return "copilot" }
func (c *CopilotCLI) EntireAgent() string { return "copilot-cli" }
func (c *CopilotCLI) PromptPattern() string { return `❯` }
func (c *CopilotCLI) TimeoutMultiplier() float64 { return 1.5 }
func (c *CopilotCLI) TimeoutMultiplier() float64 { return 2.5 }

func (c *CopilotCLI) IsTransientError(out Output, err error) bool {
if errors.Is(err, context.DeadlineExceeded) {
Expand Down Expand Up @@ -58,6 +61,13 @@ func (c *CopilotCLI) Bootstrap() error {
}

func (c *CopilotCLI) RunPrompt(ctx context.Context, dir string, prompt string, opts ...Option) (Output, error) {
// Copilot CLI v1.0.8+ requires folder trust before loading repo-level
// hooks. In non-interactive (-p) mode there is no trust dialog, so we
// pre-add the directory to the trusted_folders list.
if err := ensureCopilotTrust(dir); err != nil {
return Output{}, fmt.Errorf("ensure copilot folder trust: %w", err)
}

cfg := &runConfig{Model: "gpt-4.1"}
for _, o := range opts {
o(cfg)
Expand Down Expand Up @@ -176,3 +186,63 @@ func (c *CopilotCLI) StartSession(ctx context.Context, dir string) (Session, err

return s, nil
}

// copilotTrustMu serializes concurrent read-modify-write of ~/.copilot/config.json.
var copilotTrustMu sync.Mutex

// ensureCopilotTrust adds dir to ~/.copilot/config.json trusted_folders if not
// already present. Copilot CLI v1.0.8+ requires folder trust before loading
// repo-level hooks; without this, hooks silently don't fire in -p mode.
func ensureCopilotTrust(dir string) error {
absDir, err := filepath.Abs(dir)
if err != nil {
return err
}

home, err := os.UserHomeDir()
if err != nil {
return err
}
configPath := filepath.Join(home, ".copilot", "config.json")

copilotTrustMu.Lock()
defer copilotTrustMu.Unlock()

// Read existing config (or start fresh if it doesn't exist).
var cfg map[string]any
data, err := os.ReadFile(configPath)
switch {
case err == nil:
if err := json.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parse copilot config %s: %w", configPath, err)
}
case errors.Is(err, os.ErrNotExist):
cfg = make(map[string]any)
default:
return fmt.Errorf("read copilot config %s: %w", configPath, err)
}

// Check if already trusted.
var folders []any
if raw, ok := cfg["trusted_folders"]; ok {
if arr, ok := raw.([]any); ok {
folders = arr
}
}
for _, f := range folders {
if s, ok := f.(string); ok && s == absDir {
return nil // already trusted
}
}

// Add and write back.
cfg["trusted_folders"] = append(folders, absDir)
out, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
return os.WriteFile(configPath, out, 0o644)
}
12 changes: 6 additions & 6 deletions e2e/tests/attribution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestLineAttributionReasonable(t *testing.T) {
s.Git(t, "add", "docs/")
s.Git(t, "commit", "-m", "Add example.md")

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())

cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
sm := testutil.ReadSessionMetadata(t, s.Dir, cpID, 0)
Expand Down Expand Up @@ -62,7 +62,7 @@ func TestInteractiveAttributionOnAgentCommit(t *testing.T) {
s.WaitFor(t, session, prompt, 90*time.Second)
testutil.AssertNewCommits(t, s, 1)

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
sm := testutil.ReadSessionMetadata(t, s.Dir, cpID, 0)

Expand Down Expand Up @@ -93,15 +93,15 @@ func TestInteractiveAttributionMultiCommitSameSession(t *testing.T) {
s.WaitFor(t, session, prompt, 60*time.Second)
testutil.AssertNewCommits(t, s, 1)

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
cpBranch1 := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1")

// Second prompt: modify same file and commit again.
s.Send(t, session, "add another stanza to poem.txt about debugging, then create a NEW commit (do not amend). Do not ask for confirmation.")
s.WaitFor(t, session, prompt, 90*time.Second)
testutil.AssertNewCommits(t, s, 2)

testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, 15*time.Second)
testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, s.CheckpointTimeout())
cpID2 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
sm := testutil.WaitForSessionMetadata(t, s.Dir, cpID2, 0, 10*time.Second)

Expand Down Expand Up @@ -133,7 +133,7 @@ func TestInteractiveShadowBranchCleanedAfterAgentCommit(t *testing.T) {
s.WaitFor(t, session, prompt, 90*time.Second)
testutil.AssertNewCommits(t, s, 1)

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())

testutil.AssertNoShadowBranches(t, s.Dir)
})
Expand Down Expand Up @@ -170,7 +170,7 @@ func TestAttributionMixedHumanAndAgent(t *testing.T) {
s.Git(t, "add", "agent.txt", "human.txt")
s.Git(t, "commit", "-m", "Add agent and human files")

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())

cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
sm := testutil.ReadSessionMetadata(t, s.Dir, cpID, 0)
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/checkpoint_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestCheckpointMetadataDeepValidation(t *testing.T) {

s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Add validated.go")
testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())

cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
testutil.ValidateCheckpointDeep(t, s.Dir, testutil.DeepCheckpointValidation{
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/deleted_files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func TestDeletedFilesCommitDeletion(t *testing.T) {

s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Add replacement")
testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
testutil.AssertCheckpointExists(t, s.Dir, cpID1)

Expand Down
12 changes: 6 additions & 6 deletions e2e/tests/edge_cases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestAgentContinuesAfterCommit(t *testing.T) {
t.Fatalf("agent prompt 1 failed: %v", err)
}

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
cpBranchAfterFirst := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1")

Expand All @@ -41,7 +41,7 @@ func TestAgentContinuesAfterCommit(t *testing.T) {
s.Git(t, "commit", "-m", "Add blue.md")

// Wait for checkpoint branch to advance past the first checkpoint.
deadline := time.Now().Add(15 * time.Second)
deadline := time.Now().Add(s.CheckpointTimeout())
for time.Now().Before(deadline) {
after := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1")
if after != cpBranchAfterFirst {
Expand Down Expand Up @@ -70,7 +70,7 @@ func TestAgentAmendsCommit(t *testing.T) {
}

testutil.AssertFileExists(t, s.Dir, "docs/red.md")
testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")

// Agent creates another file and amends the previous commit.
Expand Down Expand Up @@ -109,7 +109,7 @@ func TestDirtyWorkingTree(t *testing.T) {
t.Fatalf("agent failed: %v", err)
}

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
testutil.AssertFileExists(t, s.Dir, "docs/red.md")

// Human's uncommitted file should be untouched.
Expand Down Expand Up @@ -176,13 +176,13 @@ func TestAgentCommitsMidTurnUserCommitsRemainder(t *testing.T) {

testutil.AssertNewCommits(t, s, 1)

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
cpBranchAfterAgent := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1")

s.Git(t, "add", "user_remainder.go")
s.Git(t, "commit", "-m", "Add user remainder")

testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranchAfterAgent, 15*time.Second)
testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranchAfterAgent, s.CheckpointTimeout())
userCpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
agentCpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD~1")

Expand Down
10 changes: 5 additions & 5 deletions e2e/tests/existing_files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func TestModifyExistingTrackedFile(t *testing.T) {
s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Update config.go")

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
testutil.AssertCheckpointAdvanced(t, s)

cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
Expand Down Expand Up @@ -72,15 +72,15 @@ func TestMixedNewAndModifiedFiles(t *testing.T) {
s.Git(t, "add", "src/main.go")
s.Git(t, "commit", "-m", "Update main.go")

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
cpBranch1 := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1")

// Commit remaining files (use "." to catch any extras the agent created).
s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Add utils.go and types.go")

testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, 15*time.Second)
testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cpBranch1, s.CheckpointTimeout())
cpID2 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")

assert.NotEqual(t, cpID1, cpID2, "checkpoint IDs should be distinct")
Expand Down Expand Up @@ -114,7 +114,7 @@ func TestInteractiveContentOverlapRevertNewFile(t *testing.T) {
// Wait for session to transition from ACTIVE to IDLE. The prompt
// pattern may appear before the turn-end hook completes (race between
// TUI rendering and hook execution, especially with OpenCode).
testutil.WaitForSessionIdle(t, s.Dir, 15*time.Second)
testutil.WaitForSessionIdle(t, s.Dir, s.CheckpointTimeout())

// Session is now idle (turn ended, waiting for next prompt).
// User replaces the content entirely.
Expand Down Expand Up @@ -167,7 +167,7 @@ func TestModifiedFileAlwaysGetsCheckpoint(t *testing.T) {
s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Rewrite config.go")

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
testutil.AssertCheckpointAdvanced(t, s)

cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
Expand Down
8 changes: 4 additions & 4 deletions e2e/tests/external_agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestExternalAgentSingleSessionManualCommit(t *testing.T) {
s.Git(t, "commit", "-m", "Add hello file via external agent")

testutil.AssertNewCommits(t, s, 1)
testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
testutil.AssertCheckpointAdvanced(t, s)

cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
Expand Down Expand Up @@ -69,7 +69,7 @@ func TestExternalAgentMultipleTurnsManualCommit(t *testing.T) {
s.Git(t, "commit", "-m", "Add alpha and beta via external agent")

testutil.AssertNewCommits(t, s, 1)
testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
testutil.AssertCheckpointAdvanced(t, s)

cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
Expand Down Expand Up @@ -99,7 +99,7 @@ func TestExternalAgentDeepCheckpointValidation(t *testing.T) {
s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Deep checkpoint validation test")

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())

cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")

Expand All @@ -125,7 +125,7 @@ func TestExternalAgentSessionMetadata(t *testing.T) {
s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Metadata test commit")

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())

cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")

Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/interactive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestInteractiveMultiStep(t *testing.T) {
s.WaitFor(t, session, prompt, 60*time.Second)
testutil.AssertNewCommits(t, s, 1)

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
testutil.AssertCommitLinkedToCheckpoint(t, s.Dir, "HEAD")
testutil.AssertNoShadowBranches(t, s.Dir)
})
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/mid_turn_commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func TestMidTurnCommit_DifferentFilesThanPreviousTurn(t *testing.T) {
// CRITICAL: The mid-turn commit should have triggered condensation
// to entire/checkpoints/v1, even though committed files differ from
// Turn 1's tracked files. This is the regression assertion.
testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
testutil.AssertCheckpointAdvanced(t, s)

cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
Expand Down
4 changes: 2 additions & 2 deletions e2e/tests/multi_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestMultiSessionManualCommit(t *testing.T) {
testutil.AssertFileExists(t, s.Dir, "docs/*.md")
testutil.AssertNewCommits(t, s, 1)

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
testutil.AssertCheckpointAdvanced(t, s)

cpID := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
Expand All @@ -56,7 +56,7 @@ func TestMultiSessionSequential(t *testing.T) {
testutil.AssertFileExists(t, s.Dir, "docs/*.md")
testutil.AssertNewCommits(t, s, 2)

testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
testutil.AssertCheckpointAdvanced(t, s)

cpID1 := testutil.AssertHasCheckpointTrailer(t, s.Dir, "HEAD")
Expand Down
4 changes: 2 additions & 2 deletions e2e/tests/resume_remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestResumeFromClonedRepo(t *testing.T) {

s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Add hello doc")
testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())

// Push feature branch and metadata branch to the bare remote.
s.Git(t, "push", "-u", "origin", "feature")
Expand Down Expand Up @@ -108,7 +108,7 @@ func TestResumeMetadataBranchAlreadyLocal(t *testing.T) {

s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Add hello doc")
testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())

// Switch back to main and resume the feature branch.
// The metadata branch exists locally (was created during commit).
Expand Down
8 changes: 4 additions & 4 deletions e2e/tests/resume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestResumeFromFeatureBranch(t *testing.T) {

s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Add hello doc")
testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())

// Switch back to main and resume the feature branch.
s.Git(t, "checkout", mainBranch)
Expand Down Expand Up @@ -86,7 +86,7 @@ func TestResumeSquashMergeMultipleCheckpoints(t *testing.T) {

s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Add red doc")
testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())
cp1Ref := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1")

_, err = s.RunPrompt(t, ctx,
Expand All @@ -97,7 +97,7 @@ func TestResumeSquashMergeMultipleCheckpoints(t *testing.T) {

s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Add blue doc")
testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cp1Ref, 15*time.Second)
testutil.WaitForCheckpointAdvanceFrom(t, s.Dir, cp1Ref, s.CheckpointTimeout())

// Record checkpoint IDs from both feature branch commits.
cpID1 := testutil.GetCheckpointTrailer(t, s.Dir, "HEAD~1")
Expand Down Expand Up @@ -214,7 +214,7 @@ func TestResumeOlderCheckpointWithNewerCommits(t *testing.T) {

s.Git(t, "add", ".")
s.Git(t, "commit", "-m", "Add hello doc")
testutil.WaitForCheckpoint(t, s, 15*time.Second)
testutil.WaitForCheckpoint(t, s, s.CheckpointTimeout())

// Add a human-only commit on top (no agent involvement, no checkpoint).
require.NoError(t, os.MkdirAll(filepath.Join(s.Dir, "notes"), 0o755))
Expand Down
Loading
Loading