Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
72b90f7
refactor(redact): split redact_test.go into smaller files
Patel230 Jun 19, 2026
a60c550
refactor(cli): split explain_test.go into smaller files
Patel230 Jun 19, 2026
11bc674
refactor(cli): split explain.go into smaller files
Patel230 Jun 19, 2026
72ce454
refactor(cli): split setup_test.go into smaller files
Patel230 Jun 19, 2026
79d4fdb
refactor(cli): split setup.go into smaller files
Patel230 Jun 19, 2026
daae4a4
refactor(cli): split status_test.go into smaller files
Patel230 Jun 19, 2026
9b1acb7
refactor(cli): split migrate_test.go into smaller files
Patel230 Jun 19, 2026
de35ecf
refactor(cli): split clean_test.go into smaller files
Patel230 Jun 19, 2026
93a9469
refactor(cli): split migrate.go into smaller files
Patel230 Jun 19, 2026
8e233d0
refactor(cli): split rewind.go into smaller files
Patel230 Jun 19, 2026
c603c32
refactor(cli): split setup_github_test.go into smaller files
Patel230 Jun 19, 2026
da6a157
refactor(cli): split search_tui_test.go into smaller files
Patel230 Jun 19, 2026
6040043
refactor(cli): split attach_test.go into smaller files
Patel230 Jun 19, 2026
e00f819
refactor(cli): split resume.go into smaller files
Patel230 Jun 19, 2026
9196a88
refactor(cli): split lifecycle.go into smaller files
Patel230 Jun 19, 2026
c20d9af
refactor(cli): split sessions_test.go into smaller files
Patel230 Jun 19, 2026
9949e73
refactor(cli): split search_tui.go into smaller files
Patel230 Jun 19, 2026
430ec15
refactor(cli): split trail_cmd.go into smaller files
Patel230 Jun 19, 2026
a7257d6
refactor(strategy): split manual_commit_test.go into smaller files
Patel230 Jun 19, 2026
a126098
refactor(strategy): split manual_commit_hooks.go into smaller files
Patel230 Jun 19, 2026
8b52ac0
refactor(strategy): split phase_postcommit_test.go into smaller files
Patel230 Jun 19, 2026
2eddeef
refactor(strategy): split manual_commit_condensation.go into smaller …
Patel230 Jun 19, 2026
e4a92fc
refactor(strategy): split common.go into smaller files
Patel230 Jun 19, 2026
e78f0e3
refactor(strategy): split manual_commit_attribution_test.go into smal…
Patel230 Jun 19, 2026
c11a1f8
refactor(strategy): split common_test.go into smaller files
Patel230 Jun 19, 2026
03d3ebd
refactor(strategy): split push_common_test.go into smaller files
Patel230 Jun 19, 2026
b4d35cf
refactor(strategy): split hooks_test.go into smaller files
Patel230 Jun 19, 2026
52e2175
refactor(strategy): split content_overlap_test.go into smaller files
Patel230 Jun 19, 2026
c23e342
refactor(strategy): split manual_commit_rewind.go into smaller files
Patel230 Jun 19, 2026
07eb874
refactor(strategy): split checkpoint_remote_test.go into smaller files
Patel230 Jun 19, 2026
401a726
refactor(strategy): split metadata_reconcile_test.go into smaller files
Patel230 Jun 19, 2026
7730bb4
refactor(checkpoint): split checkpoint_test.go into smaller files
Patel230 Jun 19, 2026
50157a9
refactor(checkpoint): split committed.go into smaller files
Patel230 Jun 19, 2026
16cf1f4
refactor(checkpoint): split temporary.go into smaller files
Patel230 Jun 19, 2026
6857d96
refactor(checkpoint): split v2_store_test.go into smaller files
Patel230 Jun 19, 2026
99c726f
refactor(integration): split testenv.go into smaller files
Patel230 Jun 19, 2026
6393a60
refactor(integration): split manual_commit_workflow_test.go into smal…
Patel230 Jun 19, 2026
d017c54
refactor(integration): split hooks.go into smaller files
Patel230 Jun 19, 2026
b9a5b49
refactor(integration): split agent_test.go into smaller files
Patel230 Jun 19, 2026
f218f1d
refactor(integration): split deferred_finalization_test.go into small…
Patel230 Jun 19, 2026
0d22f01
refactor(integration): split resume_test.go into smaller files
Patel230 Jun 19, 2026
d51fe65
refactor(integration): split explain_test.go into smaller files
Patel230 Jun 19, 2026
93d1e6a
refactor(settings): split settings.go into smaller files
Patel230 Jun 19, 2026
76e1a79
refactor(factoryaidroid): split transcript_test.go into smaller files
Patel230 Jun 19, 2026
2da1948
refactor(investigate): split cmd.go into smaller files
Patel230 Jun 19, 2026
6f74464
refactor(summarize): split summarize_test.go into smaller files
Patel230 Jun 19, 2026
a248d14
refactor(review): split cmd_test.go into smaller files
Patel230 Jun 19, 2026
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
490 changes: 490 additions & 0 deletions cli/agent/factoryaidroid/transcript_2_test.go

Large diffs are not rendered by default.

481 changes: 0 additions & 481 deletions cli/agent/factoryaidroid/transcript_test.go

Large diffs are not rendered by default.

381 changes: 381 additions & 0 deletions cli/attach_2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,381 @@
package cli

import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/GrayCodeAI/trace/cli/agent"
_ "github.com/GrayCodeAI/trace/cli/agent/claudecode" // register agent
_ "github.com/GrayCodeAI/trace/cli/agent/codex" // register agent
_ "github.com/GrayCodeAI/trace/cli/agent/cursor" // register agent
_ "github.com/GrayCodeAI/trace/cli/agent/factoryaidroid" // register agent
_ "github.com/GrayCodeAI/trace/cli/agent/geminicli" // register agent
"github.com/GrayCodeAI/trace/cli/agent/types"
"github.com/GrayCodeAI/trace/cli/session"
"github.com/GrayCodeAI/trace/cli/testutil"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
)

func TestAttach_CursorSuccess(t *testing.T) {
setupAttachTestRepo(t)

cursorDir := t.TempDir()
t.Setenv("TRACE_TEST_CURSOR_PROJECT_DIR", cursorDir)

sessionID := "test-attach-cursor-session"
// Cursor uses JSONL format, same as Claude Code
transcriptContent := `{"type":"user","message":{"role":"user","content":"add dark mode"},"uuid":"u1"}
{"type":"assistant","message":{"role":"assistant","content":"I'll add dark mode support."},"uuid":"a1"}
`
// Cursor flat layout: <dir>/<id>.jsonl
if err := os.WriteFile(filepath.Join(cursorDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil {
t.Fatal(err)
}

var out bytes.Buffer
err := runAttach(context.Background(), &out, sessionID, agent.AgentNameCursor, true)
if err != nil {
t.Fatalf("runAttach failed: %v", err)
}

if !strings.Contains(out.String(), "Attached session") {
t.Errorf("expected 'Attached session' in output, got: %s", out.String())
}

store, err := session.NewStateStore(context.Background())
if err != nil {
t.Fatal(err)
}
state, err := store.Load(context.Background(), sessionID)
if err != nil {
t.Fatal(err)
}
if state == nil {
t.Fatal("expected session state to be created")
return
}
if state.AgentType != agent.AgentTypeCursor {
t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeCursor)
}
if state.SessionTurnCount != 1 {
t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount)
}
}

func TestAttach_CodexSuccess(t *testing.T) {
setupAttachTestRepo(t)

codexDir := t.TempDir()
t.Setenv("TRACE_TEST_CODEX_SESSION_DIR", codexDir)

sessionID := "019d6c43-1537-7343-9691-1f8cee04fe59"
transcriptContent := `{"timestamp":"2026-04-08T10:43:48.000Z","type":"session_meta","payload":{"id":"019d6c43-1537-7343-9691-1f8cee04fe59","timestamp":"2026-04-08T10:43:48.000Z"}}
{"timestamp":"2026-04-08T10:43:49.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"investigate attach failure"}]}}
{"timestamp":"2026-04-08T10:43:50.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Looking into it."}]}}
`
sessionFile := filepath.Join(codexDir, "2026", "04", "08", "rollout-2026-04-08T10-43-48-"+sessionID+".jsonl")
if err := os.MkdirAll(filepath.Dir(sessionFile), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sessionFile, []byte(transcriptContent), 0o600); err != nil {
t.Fatal(err)
}

var out bytes.Buffer
err := runAttach(context.Background(), &out, sessionID, agent.AgentNameCodex, true)
if err != nil {
t.Fatalf("runAttach failed: %v", err)
}

if !strings.Contains(out.String(), "Attached session") {
t.Errorf("expected 'Attached session' in output, got: %s", out.String())
}

store, err := session.NewStateStore(context.Background())
if err != nil {
t.Fatal(err)
}
state, err := store.Load(context.Background(), sessionID)
if err != nil {
t.Fatal(err)
}
if state == nil {
t.Fatal("expected session state to be created")
return
}
if state.AgentType != agent.AgentTypeCodex {
t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeCodex)
}
if state.TranscriptPath != sessionFile {
t.Errorf("TranscriptPath = %q, want %q", state.TranscriptPath, sessionFile)
}
if state.LastCheckpointID.IsEmpty() {
t.Error("expected LastCheckpointID to be set after attach")
}
}

func TestAttach_FactoryAIDroidSuccess(t *testing.T) {
setupAttachTestRepo(t)

droidDir := t.TempDir()
t.Setenv("TRACE_TEST_DROID_PROJECT_DIR", droidDir)

sessionID := "test-attach-droid-session"
// Factory AI Droid uses JSONL format
transcriptContent := `{"type":"user","message":{"role":"user","content":"deploy to staging"},"uuid":"u1"}
{"type":"assistant","message":{"role":"assistant","content":"Deploying to staging now."},"uuid":"a1"}
`
// Factory AI Droid: flat <dir>/<id>.jsonl
if err := os.WriteFile(filepath.Join(droidDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil {
t.Fatal(err)
}

var out bytes.Buffer
err := runAttach(context.Background(), &out, sessionID, agent.AgentNameFactoryAIDroid, true)
if err != nil {
t.Fatalf("runAttach failed: %v", err)
}

if !strings.Contains(out.String(), "Attached session") {
t.Errorf("expected 'Attached session' in output, got: %s", out.String())
}

store, err := session.NewStateStore(context.Background())
if err != nil {
t.Fatal(err)
}
state, err := store.Load(context.Background(), sessionID)
if err != nil {
t.Fatal(err)
}
if state == nil {
t.Fatal("expected session state to be created")
return
}
if state.AgentType != agent.AgentTypeFactoryAIDroid {
t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeFactoryAIDroid)
}
if state.SessionTurnCount != 1 {
t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount)
}
}

func TestAttach_CursorNestedLayout(t *testing.T) {
setupAttachTestRepo(t)

cursorDir := t.TempDir()
t.Setenv("TRACE_TEST_CURSOR_PROJECT_DIR", cursorDir)

sessionID := "test-cursor-nested-layout"
transcriptContent := `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"u1"}
`
// Cursor IDE nested layout: <dir>/<id>/<id>.jsonl
nestedDir := filepath.Join(cursorDir, sessionID)
if err := os.MkdirAll(nestedDir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nestedDir, sessionID+".jsonl"), []byte(transcriptContent), 0o600); err != nil {
t.Fatal(err)
}

var out bytes.Buffer
err := runAttach(context.Background(), &out, sessionID, agent.AgentNameCursor, true)
if err != nil {
t.Fatalf("runAttach failed: %v", err)
}

if !strings.Contains(out.String(), "Attached session") {
t.Errorf("expected 'Attached session' in output, got: %s", out.String())
}
}

// setupAttachTestRepo creates a temp git repo with one commit and enables Trace.
// Returns the repo directory. Caller must not use t.Parallel() (uses t.Chdir).
func setupAttachTestRepo(t *testing.T) {
t.Helper()
tmpDir := t.TempDir()
testutil.InitRepo(t, tmpDir)
testutil.WriteFile(t, tmpDir, "init.txt", "init")
testutil.GitAdd(t, tmpDir, "init.txt")
testutil.GitCommit(t, tmpDir, "init")
t.Chdir(tmpDir)
enableTrace(t, tmpDir)
}

// setupClaudeTranscript creates a fake Claude transcript file.
// The file's mtime is backdated so that waitForTranscriptFlush treats it as
// stale and skips the 3-second poll loop.
func setupClaudeTranscript(t *testing.T, sessionID, content string) {
t.Helper()
claudeDir := t.TempDir()
t.Setenv("TRACE_TEST_CLAUDE_PROJECT_DIR", claudeDir)
fpath := filepath.Join(claudeDir, sessionID+".jsonl")
if err := os.WriteFile(fpath, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
stale := time.Now().Add(-3 * time.Minute)
if err := os.Chtimes(fpath, stale, stale); err != nil {
t.Fatal(err)
}
}

// enableTrace creates the .trace/settings.json file to mark Trace as enabled.
func enableTrace(t *testing.T, repoDir string) {
t.Helper()
traceDir := filepath.Join(repoDir, ".trace")
if err := os.MkdirAll(traceDir, 0o750); err != nil {
t.Fatal(err)
}
settingsContent := `{"enabled": true}`
if err := os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(settingsContent), 0o600); err != nil {
t.Fatal(err)
}
}

func setAttachCheckpointsV2Enabled(t *testing.T, repoDir string) {
t.Helper()
traceDir := filepath.Join(repoDir, ".trace")
if err := os.MkdirAll(traceDir, 0o750); err != nil {
t.Fatal(err)
}
settingsContent := `{"enabled": true, "strategy_options": {"checkpoints_v2": true}}`
if err := os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(settingsContent), 0o600); err != nil {
t.Fatal(err)
}
}

func setAttachCheckpointsV2Only(t *testing.T, repoDir string) {
t.Helper()
traceDir := filepath.Join(repoDir, ".trace")
if err := os.MkdirAll(traceDir, 0o750); err != nil {
t.Fatal(err)
}
settingsContent := `{"enabled": true, "strategy_options": {"checkpoints_version": 2}}`
if err := os.WriteFile(filepath.Join(traceDir, "settings.json"), []byte(settingsContent), 0o600); err != nil {
t.Fatal(err)
}
}

func mustGetwd(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return dir
}

func readFileFromRef(t *testing.T, repo *git.Repository, refName, filePath string) (string, bool) {
t.Helper()

ref, err := repo.Reference(plumbing.ReferenceName(refName), true)
if err != nil {
return "", false
}
commit, err := repo.CommitObject(ref.Hash())
if err != nil {
return "", false
}
tree, err := commit.Tree()
if err != nil {
return "", false
}
file, err := tree.File(filePath)
if err != nil {
return "", false
}
content, err := file.Contents()
if err != nil {
return "", false
}
return content, true
}

// TestAttach_DiscoversExternalAgents verifies that `trace attach --agent <external>`
// gets past the agent registry check when external_agents is enabled and a
// matching binary is on PATH. Without the DiscoverAndRegister call in the
// attach command, this would fail with "unknown agent: <name>".
//
// This test does not verify end-to-end attach behavior — it asserts only
// that discovery ran. The command is expected to fail later (transcript
// resolution) because we don't stand up a real session.
func TestAttach_DiscoversExternalAgents(t *testing.T) {
if _, err := exec.LookPath("sh"); err != nil {
t.Skip("sh not available")
}

setupAttachTestRepo(t)

// Overwrite settings to enable external_agents (enableTrace writes the
// file without it).
cwd := mustGetwd(t)
settingsPath := filepath.Join(cwd, ".trace", "settings.json")
if err := os.WriteFile(settingsPath, []byte(`{"enabled":true,"external_agents":true}`), 0o600); err != nil {
t.Fatal(err)
}

// Use a unique name so concurrent test runs can't collide in the global
// agent registry.
agentName := types.AgentName("attachtest-discovery-agent")

binDir := t.TempDir()
binPath := filepath.Join(binDir, "trace-agent-"+string(agentName))
infoJSON := `{
"protocol_version": 1,
"name": "` + string(agentName) + `",
"type": "Attach Test Agent",
"description": "Agent for attach discovery test",
"is_preview": false,
"protected_dirs": [],
"hook_names": [],
"capabilities": {}
}`
script := "#!/bin/sh\nif [ \"$1\" = \"info\" ]; then\n echo '" + infoJSON + "'\nfi\n"
if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil {
t.Fatalf("failed to write mock agent binary: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))

cmd := newAttachCmd()
// Pass a bogus session ID — the point is to exercise the registry check,
// not full attach flow.
cmd.SetArgs([]string{"--agent", string(agentName), "-f", "fake-session-id"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)

err := cmd.Execute()
// We expect an error (no transcript), but it must not be the
// registry-lookup error. A regression (removing DiscoverAndRegister)
// would produce "unknown agent: attachtest-discovery-agent".
if err == nil {
t.Fatalf("expected attach to fail on missing transcript, got success\noutput: %s", out.String())
}
if strings.Contains(err.Error(), "unknown agent") {
t.Fatalf("attach did not discover external agent — got registry miss: %v", err)
}

// Also confirm the agent actually landed in the registry, so the check
// above is meaningful (not merely passing because some other error
// short-circuited before the registry lookup).
if _, lookupErr := agent.Get(agentName); lookupErr != nil {
t.Errorf("expected external agent %q in registry after attach, got: %v", agentName, lookupErr)
}
}

func runGitInDir(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.CommandContext(context.Background(), "git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out)
}
}
Loading