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
25 changes: 22 additions & 3 deletions cmd/agentop/tui/session_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,9 @@ func awaitsHarvest(s session.SessionSummary) bool { return s.Resident == nil ||
// this cheap: without it every event on a still-unnamed session would trigger a scan, and
// with it a busy session is harvested once, after it pauses.
//
// Only sessions the metadata does NOT name are considered, so a fully-harvested list triggers
// nothing at all.
// Only sessions the metadata does NOT name are considered, plus named sessions whose transcript
// moved since the harvest that named them (see transcriptMoved). A fully-harvested list whose
// transcripts are unchanged triggers nothing at all.
//
// "FULLY HARVESTED" IS NOT "EVERY ROW NAMED ON SCREEN", and this comment used to conflate the two.
// sessionHasTitle asks about the HARVEST only — sessionTitleFor's served-title fallback is
Expand Down Expand Up @@ -272,7 +273,7 @@ func awaitsHarvest(s session.SessionSummary) bool { return s.Resident == nil ||
// comment; the optimisation waits for a profile that asks for it.
func (m *model) untitledSettled(now time.Time) bool {
for _, s := range m.sessions {
if !awaitsHarvest(s) || m.sessionHasTitle(s.ID) {
if !awaitsHarvest(s) || (m.sessionHasTitle(s.ID) && !m.transcriptMoved(s.ID)) {
continue
}
// A ZERO UpdatedAt IS NOT "QUIET SINCE THE EPOCH". The field is whatever /v1/sessions
Expand Down Expand Up @@ -311,6 +312,24 @@ func (m *model) untitledSettled(now time.Time) bool {
return false
}

// transcriptMoved reports whether the transcript a harvested title came from changed after the
// harvest that read it. A NAMED row needs this to be read again: the title it has can be the cwd
// fallback or an early prompt — a session started by /clear and left idle is named by its cwd —
// and the transcript can since have grown a better one. Without it, a named row kept its first
// title for as long as agentop ran.
//
// One Stat per named live row per tick, on the rows awaitsHarvest admits. A harvest that only
// refreshes a named row names nothing NEW, so it counts as a miss and widens the backoff: an active
// session is re-read at most once per untitledBackoff, not once per pause.
func (m *model) transcriptMoved(id string) bool {
meta := m.sessionsData[id]
if meta.LogFile == "" {
return false
}
st, err := os.Stat(meta.LogFile)
return err == nil && st.ModTime().After(meta.LogModTime)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This stays true for good when the harvester cannot refresh the entry. The case is a live row whose LogFile sits under a config dir other than the one claude.Harvest scans, e.g. the entry was written with a different CLAUDE_CONFIG_DIR and is carried forward by the merge. ReadSessions never sees that file, so LogModTime never advances, and every settled pause in that session starts a harvest. The backoff bounds it at one walk per untitledBackoffCap, the same steady state the served-title rows already accept, so this is not blocking. Two possible fixes: a sentence in the doc comment, or skipping when meta.AgentConfigDir differs from the dir the harvester reads. Separately, "One Stat per named live row per tick" overstates the cost. The gate evaluates untitledSettled only after now.Sub(m.lastHarvest) >= wait, so it is one Stat per row per backoff window.

}

// untitledFresh reports whether some unnamed row on screen was NOT counted against the backoff
// yet — a session that appeared since the last harvest was scored.
//
Expand Down
75 changes: 75 additions & 0 deletions cmd/agentop/tui/sessions_title_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,81 @@ func TestUntitledSettled_BlankAndUnknownRows(t *testing.T) {
}
}

// TestUntitledSettled_NamedRowWhoseTranscriptMoved pins the re-read of a NAMED row.
//
// A session started by /clear and left idle is named by its cwd. Before this, a named row was never
// read again while agentop ran, so the prompt and ai-title that came later never reached the column.
func TestUntitledSettled_NamedRowWhoseTranscriptMoved(t *testing.T) {
settled := time.Now().Add(-2 * untitledSettleDelay)
log := filepath.Join(t.TempDir(), "s1.jsonl")
if err := os.WriteFile(log, []byte("{}\n"), 0o600); err != nil {
t.Fatal(err)
}
harvested := time.Now().Add(-time.Hour)
if err := os.Chtimes(log, harvested, harvested); err != nil {
t.Fatal(err)
}

cases := []struct {
name string
meta SessionMetadata
want bool
}{
{"unchanged since the harvest", SessionMetadata{Title: "/w/repo", LogFile: log, LogModTime: harvested}, false},
{"changed since the harvest", SessionMetadata{Title: "/w/repo", LogFile: log, LogModTime: harvested.Add(-time.Minute)}, true},
{"no transcript recorded", SessionMetadata{Title: "/w/repo"}, false},
{"transcript gone", SessionMetadata{Title: "/w/repo", LogFile: log + ".gone"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := newTitleModel(t, map[string]SessionMetadata{"s1": tc.meta}, "s1")
m.sessions = []session.SessionSummary{{ID: "s1", UpdatedAt: settled}}
if got := m.untitledSettled(time.Now()); got != tc.want {
t.Errorf("untitledSettled = %v, want %v", got, tc.want)
}
})
}
}

// TestSessionsPane_RefreshOfNamedRowCountsAsMiss pins the limit on re-reads of a NAMED row.
//
// An active session's transcript moves on every turn. If a re-read that names nothing new did not
// count as a miss, every pause in that session would start a harvest at the 5s floor.
func TestSessionsPane_RefreshOfNamedRowCountsAsMiss(t *testing.T) {
log := filepath.Join(t.TempDir(), "s1.jsonl")
if err := os.WriteFile(log, []byte("{}\n"), 0o600); err != nil {
t.Fatal(err)
}
st, err := os.Stat(log)
if err != nil {
t.Fatal(err)
}
moved := SessionMetadata{Title: "/w/repo", LogFile: log, LogModTime: st.ModTime().Add(-time.Minute)}
m := newTitleModel(t, map[string]SessionMetadata{"s1": moved}, "s1")
m.sessions = []session.SessionSummary{{ID: "s1", UpdatedAt: time.Now().Add(-time.Hour)}}
m.harvest = func() (map[string]SessionMetadata, error) { return nil, nil }

m.lastHarvest = time.Now().Add(-untitledBackoffCap)
m.Update(refreshTickMsg(time.Now()))
if !m.harvesting {
t.Fatal("no harvest started for a named row whose transcript moved")
}
// The re-read gives the same title and the transcript's current mtime.
m.Update(harvestedMsg{meta: map[string]SessionMetadata{
"s1": {Title: "/w/repo", LogFile: log, LogModTime: st.ModTime()},
}})
if m.untitledMisses != 1 {
t.Errorf("a re-read that named nothing new left untitledMisses = %d, want 1", m.untitledMisses)
}

// The transcript has not moved since that re-read, so no harvest starts.
m.lastHarvest = time.Now().Add(-untitledBackoffCap)
m.Update(refreshTickMsg(time.Now()))
if m.harvesting {
t.Error("harvested again although the transcript did not move")
}
}

// TestHarvestNamedSomething_JudgesOnlyRowsOnScreen pins the backoff's notion of progress.
//
// Two ways to get this wrong, and the function had the second one. len(meta) > 0 is true on every
Expand Down
3 changes: 2 additions & 1 deletion core/observe/claude/harvest.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,8 @@ func ReadSessions(configDir string, since map[string]SessionMetadata) (map[strin
// int64 per entry, and the Stat here already has it. Deliberately not done in this
// change: it is a second on-disk field for a case the explicit subcommand already
// answers, and the field added here needs to prove itself first.
if since != nil && !st.ModTime().After(harvestedAt(since[id], path)) {
// A "/clear" title came from an older build; its idle transcript never moves, so re-parse it.
if since != nil && !st.ModTime().After(harvestedAt(since[id], path)) && !isClear(since[id].Title) {
// A skipped transcript still WON this id: the entry being kept from `since`
// carries its title. So claim it, and drop any weaker claim an older
// duplicate already staked in an earlier directory — ReadDir is
Expand Down
87 changes: 85 additions & 2 deletions core/observe/claude/harvest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1262,8 +1262,8 @@ func TestTitleFromTranscript_UnwrapsSlashCommands(t *testing.T) {
},
{
"name only",
`<command-message>clear</command-message>\n<command-name>/clear</command-name>\n<command-args></command-args>`,
"/clear",
`<command-message>review</command-message>\n<command-name>/review</command-name>\n<command-args></command-args>`,
"/review",
},
{
"ordinary prose is untouched",
Expand All @@ -1290,6 +1290,46 @@ func TestTitleFromTranscript_UnwrapsSlashCommands(t *testing.T) {
}
}

// /clear never names a session. Claude Code writes it as the first user turn of the transcript it
// starts, so a session left idle after /clear was titled "/clear".
func TestTitleFromTranscript_ClearNamesNothing(t *testing.T) {
const clear = `<command-name>/clear</command-name>\n <command-message>clear</command-message>\n <command-args></command-args>`
user := func(content string) string {
body, err := json.Marshal(strings.ReplaceAll(content, `\n`, "\n"))
if err != nil {
t.Fatal(err)
}
return `{"type":"user","cwd":"/w/repo","message":{"role":"user","content":` + string(body) + `}}`
}
human := func(line string) string {
return strings.Replace(line, `"type":"user",`, `"type":"user","origin":{"kind":"human"},`, 1)
}
for _, tc := range []struct {
name string
lines []string
want string
}{
{"alone falls through to the cwd", []string{user(clear)}, "/w/repo"},
{"attributed human turn", []string{human(user(clear))}, "/w/repo"},
{"last-prompt record", []string{user(clear), `{"type":"last-prompt","lastPrompt":"/clear"}`}, "/w/repo"},
{"the prompt after it wins", []string{user(clear), user("fix the title bug")}, "fix the title bug"},
{"a prompt before it is kept", []string{user("fix the title bug"), user(clear)}, "fix the title bug"},
{"a longer command name is not /clear", []string{user(`<command-name>/clearcache</command-name><command-args></command-args>`)}, "/clearcache"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
writeSessionTranscript(t, dir, "s.jsonl", tc.lines...)
got, err := titleFromTranscript(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("title = %q, want %q", got, tc.want)
}
})
}
}

// The cwd fallback is NOT clipped, so sibling directories stay distinguishable.
//
// A path's distinguishing end is its leaf and clipTitle keeps the head, so two worktrees under a
Expand Down Expand Up @@ -3050,3 +3090,46 @@ func TestHarvest_SessionWithoutSubagentsHasNoMap(t *testing.T) {
t.Errorf("Subagents = %v, want nil", got)
}
}

// An entry an older build titled "/clear" is re-parsed even when its transcript has not moved:
// a session left idle after /clear never writes again, so the mtime skip would keep the title.
func TestHarvest_IncrementalReparsesAClearTitle(t *testing.T) {
for _, title := range []string{"/clear", "/clear my-feature"} {
t.Run(title, func(t *testing.T) {
metadataHome(t)
cfg := filepath.Join(t.TempDir(), "claude")
proj := filepath.Join(cfg, "projects", "-p")
log := filepath.Join(proj, "s1.jsonl")
writeSessionTranscript(t, proj, "s1.jsonl", `{"type":"ai-title","aiTitle":"current"}`)
touch(t, log, time.Now().Add(-time.Hour))

path, err := SessionMetadataPath()
if err != nil {
t.Fatal(err)
}
old, err := json.Marshal(map[string]SessionMetadata{"s1": {
Title: title, AgentType: "Claude Code", LogFile: log, LogModTime: time.Now(),
}})
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, old, 0o600); err != nil {
t.Fatal(err)
}

res, err := Harvest(Options{ConfigDir: cfg, Merge: true, Incremental: true})
if err != nil {
t.Fatal(err)
}
if res.Harvested != 1 || res.Skipped != 0 {
t.Errorf("harvested/skipped = %d/%d, want 1/0", res.Harvested, res.Skipped)
}
if got := readMetadataFile(t, res.Path)["s1"].Title; got != "current" {
t.Errorf("title = %q, want %q", got, "current")
}
})
}
}
10 changes: 9 additions & 1 deletion core/observe/claude/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,12 +477,20 @@ func promptCandidate(text string) (string, bool) {
candidate = stripWrapperTag(text)
}
candidate = cutTrailingHarness(candidate)
if isSyntheticPrompt(candidate) {
if isSyntheticPrompt(candidate) || isClear(candidate) {
return "", false
}
return candidate, true
}

// isClear reports whether s is the /clear command. /clear resets the session, so it names nothing:
// Claude Code writes it as the first user turn of the NEW transcript, before the user asks anything,
// and a session left idle after it was titled "/clear".
func isClear(s string) bool {
f := strings.Fields(s)
return len(f) > 0 && f[0] == "/clear"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: /clear is fixed, but other built-in local commands have the same shape and still name a session. Example: a session whose only user turn is <command-name>/permissions</command-name>…<command-args></command-args> (followed by <local-command-stdout>). A transcript on my machine titles as /permissions at this head, and /model, /config and the like would do the same if run first and the session left idle. Claude Code's own first-prompt picker draws the line differently: it skips a <command-name> turn when the name is in builtInCommandNames() and <command-args> is empty, and keeps it as /<name> <args> otherwise. That keeps /review some/path.md and custom commands/skills, which is what UnwrapsSlashCommands wants. Fine as a follow-up, since the PR scopes itself to /clear and that case is covered. If you generalise it, a small allowlist of no-arg local commands would match Claude Code's rule better than a prefix test.

}

// isSyntheticPrompt reports whether s READS AS markup Claude Code inserted rather than as something
// the user typed.
//
Expand Down
Loading