diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index aaea0f63b..e2a7b0ee0 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -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). @@ -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 @@ -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 { diff --git a/app/cli/pkg/action/trace_worktree_test.go b/app/cli/pkg/action/trace_worktree_test.go index 7ca0d89cd..72fc6e961 100644 --- a/app/cli/pkg/action/trace_worktree_test.go +++ b/app/cli/pkg/action/trace_worktree_test.go @@ -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.