Skip to content
Merged
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
27 changes: 20 additions & 7 deletions app/cli/pkg/action/trace_agent_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,8 @@ func sameDir(a, b string) bool {
// Idempotent so any hook entry point can call it safely. Per-input metadata
// (e.g., AgentVersion, Cwd) is captured on the first call only — subsequent
// hooks for the same session are no-ops, no upsert. The one exception is
// TranscriptPath, which a later hook fills in when the first one lacked it.
// TranscriptPath, which a later hook fills in when the first one lacked it,
// or replaces when the recorded file is gone.
func ensureSessionTracked(provider trace.Provider, store *state.Store, repoRoot string, input *trace.HookInput, log zerolog.Logger) {
// Run install before any early return so hooks recover from missing
// state (deleted .git/hooks, project YAML appearing after session start).
Expand Down Expand Up @@ -619,9 +620,10 @@ func ensureSessionTracked(provider trace.Provider, store *state.Store, repoRoot
}

// backfillTranscriptPath records the transcript path on an existing session
// record that lacks it, and copies the session data from there. Without it,
// a record created by a hook that carried no transcript path would fall back
// to the cwd lookup for the life of the session.
// record that lacks it, or whose transcript moved, and copies the session
// data from there. Without it, a record created by a hook that carried no
// transcript path would fall back to the cwd lookup for the life of the
// session, and a moved transcript would never reach pre-push.
func backfillTranscriptPath(provider trace.Provider, store *state.Store, repoRoot string, rec *state.SessionRecord, transcriptPath string, log zerolog.Logger) {
if !recordTranscriptPath(store, rec, transcriptPath, log) {
return
Expand All @@ -632,12 +634,23 @@ func backfillTranscriptPath(provider trace.Provider, store *state.Store, repoRoo
}
}

// recordTranscriptPath saves transcriptPath on rec when rec has none yet,
// and reports whether it did.
// recordTranscriptPath saves transcriptPath on rec when rec has none yet, or
// when the file of its path is gone, and reports whether it did. The agent
// can move the transcript of a running session: Claude Code files a resumed
// session under the project folder of the directory it resumes from. While
// the recorded file exists, another path, such as the transcript of a
// subagent, does not replace it.
func recordTranscriptPath(store *state.Store, rec *state.SessionRecord, transcriptPath string, log zerolog.Logger) bool {
if rec == nil || transcriptPath == "" || rec.TranscriptPath != "" {
if rec == nil || transcriptPath == "" || rec.TranscriptPath == transcriptPath {
return false
}
// Only a file that is gone is replaced. Another stat error does not show
// that the transcript moved.
if rec.TranscriptPath != "" {
if _, err := os.Stat(rec.TranscriptPath); !errors.Is(err, os.ErrNotExist) {
return false
}
}

rec.TranscriptPath = transcriptPath
if err := store.SaveSessionRecord(rec); err != nil {
Expand Down
68 changes: 68 additions & 0 deletions app/cli/pkg/action/trace_worktree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,74 @@ func TestEnsureSessionTracked_BackfillsTranscriptPath(t *testing.T) {
assert.Equal(t, root, rec.Cwd, "a later hook does not change the recorded cwd")
}

// Claude Code can move the transcript of a running session, for example to
// another project folder when the session is resumed from another
// directory. A hook that reports a new path replaces a recorded path whose
// file is gone. While the recorded file exists, a different path, such as
// the transcript of a subagent, does not replace it.
func TestEnsureSessionTracked_ReplacesMovedTranscriptPath(t *testing.T) {
testCases := []struct {
name string
oldExists bool
reportedPath func(home, sid string) string
wantNew bool
}{
{
name: "recorded file is gone",
oldExists: false,
reportedPath: func(home, sid string) string {
return filepath.Join(claudeProjectDir(home, "/resumed"), sid+".jsonl")
},
wantNew: true,
},
{
name: "recorded file exists",
oldExists: true,
reportedPath: func(home, sid string) string {
return filepath.Join(claudeProjectDir(home, "/started"), sid, "subagents", "agent-abc123.jsonl")
},
wantNew: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
const sid = "7412e0c2-1a2b-4c3d-8e9f-0a1b2c3d4e5f"
home := t.TempDir()
setTestHome(t, home)

root := chdirToResolvedGitRepo(t)
store := state.NewGitStore(filepath.Join(root, ".git"))
p := claude.New()

oldPath := filepath.Join(claudeProjectDir(home, "/started"), sid+".jsonl")
require.NoError(t, os.MkdirAll(filepath.Dir(oldPath), 0o750))
require.NoError(t, os.WriteFile(oldPath, []byte("{}\n"), 0o600))
withStdin(t, fmt.Sprintf(`{"session_id":%q,"transcript_path":%q,"cwd":%q,"hook_event_name":"SessionStart"}`, sid, oldPath, root))
require.NoError(t, HandleAgentSessionStart(p, nil, zerolog.Nop()))
if !tc.oldExists {
require.NoError(t, os.Remove(oldPath))
}

newPath := tc.reportedPath(home, sid)
require.NoError(t, os.MkdirAll(filepath.Dir(newPath), 0o750))
require.NoError(t, os.WriteFile(newPath, []byte("{}\n"), 0o600))
withStdin(t, fmt.Sprintf(`{"session_id":%q,"transcript_path":%q,"cwd":%q,"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true"}}`,
sid, newPath, root))
require.NoError(t, HandleAgentPreToolUse(p, zerolog.Nop()))

rec, err := store.LoadSessionRecord(sid)
require.NoError(t, err)
require.NotNil(t, rec)
want := oldPath
if tc.wantNew {
want = newPath
}
assert.Equal(t, want, rec.TranscriptPath)
})
}
}

// Session end can be the only hook after the record was created, e.g. a
// session that ran no tools. Pre-push reads the transcript path from the
// record, so session end fills it in too.
Expand Down
Loading