From 2086884befdb974fd3fcb0346a34a7337d6fc356 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 22:17:45 +0800 Subject: [PATCH] =?UTF-8?q?sessions:=20a=20session=20goes=20by=20the=20nam?= =?UTF-8?q?e=20its=20agent=20shows=20for=20it=20=E2=80=94=20Codex's=20thre?= =?UTF-8?q?ad=20name=20(session=5Findex.jsonl),=20the=20one=20a=20Claude?= =?UTF-8?q?=20Code=20session=20was=20renamed=20to=20(/rename),=20else=20th?= =?UTF-8?q?e=20title=20the=20agent=20made=20=E2=80=94=20and=20by=20its=20f?= =?UTF-8?q?irst=20prompt=20only=20when=20it=20has=20none=20(TJHHHH=20on=20?= =?UTF-8?q?Discord)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/sessions/agents_test.go | 6 ++-- internal/sessions/claude.go | 11 +++++-- internal/sessions/cursor_test.go | 6 ++-- internal/sessions/droid.go | 3 ++ internal/sessions/droid_test.go | 4 +-- internal/sessions/grok_workbuddy_test.go | 4 +-- internal/sessions/omp_test.go | 2 +- internal/sessions/opencode_pi_test.go | 4 +-- internal/sessions/session_titles_test.go | 37 ++++++++++++++++++++++++ internal/sessions/sessions.go | 23 ++++++++++----- internal/sessions/sessions_test.go | 2 +- internal/sessions/workbuddy.go | 2 +- sessions_cli_test.go | 4 +-- 13 files changed, 81 insertions(+), 27 deletions(-) create mode 100644 internal/sessions/session_titles_test.go diff --git a/internal/sessions/agents_test.go b/internal/sessions/agents_test.go index d7a110e2..efe5e964 100644 --- a/internal/sessions/agents_test.go +++ b/internal/sessions/agents_test.go @@ -89,7 +89,7 @@ func TestZCode(t *testing.T) { t.Fatalf("want 2 ZCode sessions (the subagent in its parent), got %d", n) } z := find(t, ss, "zcode", "ses_zcA") - if z.Cwd != "/work/zc" || z.Title != "Port the parser" || z.Resume != "" { + if z.Cwd != "/work/zc" || z.Title != "Port the parser to Go" || z.Resume != "" { t.Fatalf("zcode: %+v", z) } // input without the cache, output with its reasoning already @@ -117,7 +117,7 @@ func checkDsh(t *testing.T, ss []Session) { t.Fatalf("want 3 dsh sessions (the subagent in its parent), got %d", n) } a := find(t, ss, "dsh", "dsh-a") - if a.Cwd != "/work/dsh" || a.Title != "Tidy the build script" || a.Resume != "" { + if a.Cwd != "/work/dsh" || a.Title != "Build script tidy" || a.Resume != "" { t.Fatalf("dsh: %+v", a) } // the format 3 file, not the one left behind @@ -200,7 +200,7 @@ func TestCline(t *testing.T) { } c := find(t, ss, "cline", clineMain) // the prompt typed, out of Cline's tags, before its title - if c.Cwd != "/work/cline" || c.Title != "Speed up the importer" { + if c.Cwd != "/work/cline" || c.Title != "Importer speed-up" { t.Fatalf("cline: %+v", c) } if m := model(c, "claude-opus-5-5"); m.Tokens != (Tokens{100, 50, 1000, 500, 0}) || !m.Priced { diff --git a/internal/sessions/claude.go b/internal/sessions/claude.go index 33adda85..f9ba6adc 100644 --- a/internal/sessions/claude.go +++ b/internal/sessions/claude.go @@ -22,6 +22,7 @@ type ccLine struct { SessionID string `json:"sessionId"` RequestID ccStr `json:"requestId"` AITitle string `json:"aiTitle"` + CustomTitle string `json:"customTitle"` Summary string `json:"summary"` Message struct { ID string `json:"id"` @@ -41,6 +42,7 @@ var ( ccAssistant = []byte(`"type":"assistant"`) ccUser = []byte(`"type":"user"`) ccTitle = []byte(`"type":"ai-title"`) + ccCustom = []byte(`"type":"custom-title"`) ccSummary = []byte(`"type":"summary"`) ccCwd = []byte(`"cwd":"`) ) @@ -66,7 +68,7 @@ func claudeLine(s *state, b []byte, main bool) { switch typeAfter(h, ccTop) { case "user", "assistant": // a message laid out type first - case "ai-title", "summary": + case "ai-title", "custom-title", "summary": claudeFull(s, b, main) return default: @@ -223,7 +225,7 @@ func claudeFull(s *state, b []byte, main bool) { s.saw(at, main) want := bytes.Contains(b, ccAssistant) || main && bytes.Contains(b, ccUser) || - main && (bytes.Contains(b, ccTitle) || bytes.Contains(b, ccSummary)) || + main && (bytes.Contains(b, ccTitle) || bytes.Contains(b, ccCustom) || bytes.Contains(b, ccSummary)) || s.Cwd == "" && bytes.Contains(b, ccCwd) if !want { return @@ -243,6 +245,11 @@ func claudeFull(s *state, b []byte, main bool) { if l.AITitle != "" { s.Named = title(l.AITitle) } + case "custom-title": + // named by the user (/rename): before the one Claude Code made + if main && l.CustomTitle != "" { + s.Custom = title(l.CustomTitle) + } case "summary": if l.Summary != "" && s.Named == "" { s.Named = title(l.Summary) diff --git a/internal/sessions/cursor_test.go b/internal/sessions/cursor_test.go index 9cd776a1..c8a38199 100644 --- a/internal/sessions/cursor_test.go +++ b/internal/sessions/cursor_test.go @@ -237,7 +237,7 @@ func TestCursor(t *testing.T) { if !ok { t.Fatal("the chat isn't listed") } - if s.Title != "Start the port of the parser" || s.Cwd != curCwd { + if s.Title != "Port the parser" || s.Cwd != curCwd { t.Fatalf("title %q cwd %q", s.Title, s.Cwd) } if !s.Start.Equal(time.UnixMilli(curT0)) || !s.Last.Equal(time.UnixMilli(curT1)) { @@ -254,7 +254,7 @@ func TestCursor(t *testing.T) { t.Fatalf("resume %q, want %q", s.Resume, want) } o, ok := findSession(ss, "cursor", curOther) - if !ok || o.Title != "tidy the imports" || o.Cwd != "" || o.Resume != "" { + if !ok || o.Title != "Tidy imports" || o.Cwd != "" || o.Resume != "" { t.Fatalf("the chat with no meta.json: %+v (its folder unknown, it has no resume)", o) } if !o.Last.Equal(curOld) { @@ -330,7 +330,7 @@ func TestCursorDelete(t *testing.T) { } } Reset() - if s, ok := findSession(List(0), "cursor", curMain); !ok || s.Title != "Start the port of the parser" { + if s, ok := findSession(List(0), "cursor", curMain); !ok || s.Title != "Port the parser" { t.Fatalf("the restored chat: %+v", s) } } diff --git a/internal/sessions/droid.go b/internal/sessions/droid.go index 1e32c9b8..cbf7473f 100644 --- a/internal/sessions/droid.go +++ b/internal/sessions/droid.go @@ -169,6 +169,9 @@ func parseDroid(f file) *state { var st droidStart if l.Type == "session_start" && json.Unmarshal(b, &st) == nil { s.ID, s.Cwd, s.Named = st.ID, st.Cwd, title(st.Title) + if s.Named == "New Session" { + s.Named = "" // Droid's until it names one + } if st.LastCwd != "" { s.Cwd = st.LastCwd } diff --git a/internal/sessions/droid_test.go b/internal/sessions/droid_test.go index 7f3ac80a..9e8f4594 100644 --- a/internal/sessions/droid_test.go +++ b/internal/sessions/droid_test.go @@ -66,7 +66,7 @@ func TestDroid(t *testing.T) { t.Fatalf("want 3 Droid sessions (the subagent in its session), got %d", n) } d := find(t, ss, "droid", droidMain) - if d.Cwd != "/work/droid" || d.Title != "Fix the login bug" { + if d.Cwd != "/work/droid" || d.Title != "Login bug" { t.Fatalf("droid: %+v", d) } // magpie's custom model by the model it asks for, the subagent's in @@ -87,7 +87,7 @@ func TestDroid(t *testing.T) { // the fork: what it copied is its source's; a custom model of the // user's own by its name f := find(t, ss, "droid", droidFork) - if f.Title != "Try another way" || !f.Start.Equal(at("2026-09-27T09:00:00Z")) || !f.Last.Equal(at("2026-09-27T09:00:30Z")) { + if f.Title != "Login bug" || !f.Start.Equal(at("2026-09-27T09:00:00Z")) || !f.Last.Equal(at("2026-09-27T09:00:30Z")) { t.Fatalf("fork: %+v", f) } if m := model(f, "Kimi-K2"); m.Tokens != (Tokens{7, 3, 0, 0, 0}) { diff --git a/internal/sessions/grok_workbuddy_test.go b/internal/sessions/grok_workbuddy_test.go index 004e1f95..d9161f98 100644 --- a/internal/sessions/grok_workbuddy_test.go +++ b/internal/sessions/grok_workbuddy_test.go @@ -24,7 +24,7 @@ func TestGrok(t *testing.T) { } g := find(t, ss, "grok", grokMain) // the first prompt typed, its chunks put together, before the title Grok made - if g.Cwd != "/work/grok" || g.Title != "Port the parser" { + if g.Cwd != "/work/grok" || g.Title != "Parser port" { t.Fatalf("grok: %+v", g) } // the cache out of the input, the subagent's turn in once @@ -45,7 +45,7 @@ func TestGrok(t *testing.T) { } // the fork: what it copied counted in the session it came from f := find(t, ss, "grok", grokFork) - if f.Tokens != (Tokens{100, 30, 300, 0, 0}) || f.Title != "Try another way" { + if f.Tokens != (Tokens{100, 30, 300, 0, 0}) || f.Title != "Parser port, another way" { t.Fatalf("fork: %+v", f) } if !f.Start.Equal(at("2026-09-27T09:00:00.25Z")) || !f.Last.Equal(at("2026-09-27T09:00:30Z")) { diff --git a/internal/sessions/omp_test.go b/internal/sessions/omp_test.go index 5a85286b..0e96386d 100644 --- a/internal/sessions/omp_test.go +++ b/internal/sessions/omp_test.go @@ -23,7 +23,7 @@ func TestOmp(t *testing.T) { t.Fatalf("want 3 omp sessions (the subagents and advisor in theirs), got %d", n) } m := find(t, ss, "omp", ompMain) - if m.Cwd != "/work/omp" || m.Title != "Port the parser" { + if m.Cwd != "/work/omp" || m.Title != "Port the parser to omp" { t.Fatalf("omp: %+v", m) } // the task tool's summed usage not counted again diff --git a/internal/sessions/opencode_pi_test.go b/internal/sessions/opencode_pi_test.go index c3bd7af1..19904962 100644 --- a/internal/sessions/opencode_pi_test.go +++ b/internal/sessions/opencode_pi_test.go @@ -61,7 +61,7 @@ func checkOpenCode(t *testing.T, ss []Session) { t.Fatalf("want 1 OpenCode session (the child in its parent, the empty one out), got %d: %+v", n, ss) } oc := find(t, ss, "opencode", ocMain) - if oc.Cwd != "/work/oc" || oc.Title != "Why does the login test flake?" { + if oc.Cwd != "/work/oc" || oc.Title != "Flaky test hunt" { t.Fatalf("opencode: %+v", oc) } if a := model(oc, "gpt-6-astra"); a.Tokens != (Tokens{1000, 120, 5000, 0, 0}) || !a.Priced { @@ -210,7 +210,7 @@ func TestPi(t *testing.T) { setupMore(t) ss := List(0) p := find(t, ss, "pi", piFirst) - if p.Cwd != "/work/pi" || p.Title != "Refactor the parser" { + if p.Cwd != "/work/pi" || p.Title != "Parser refactor" { t.Fatalf("pi: %+v", p) } // a tool's model work and a compaction on the model in use, a usage diff --git a/internal/sessions/session_titles_test.go b/internal/sessions/session_titles_test.go new file mode 100644 index 00000000..88293584 --- /dev/null +++ b/internal/sessions/session_titles_test.go @@ -0,0 +1,37 @@ +package sessions + +import ( + "os" + "path/filepath" + "testing" +) + +// A session goes by the name its agent shows for it: Codex's thread name, +// the one a Claude Code session was renamed to, else the title the agent +// made, and the first prompt only when there is none (TJHHHH on Discord). +func TestSessionTitles(t *testing.T) { + claude, codex := setup(t) + index := `{"id":"01a0bdc9-b5fd-7e63-9658-68bc8dce5ecd","thread_name":"Codex 起的名字","updated_at":"2026-09-23T00:27:30Z"}` + "\n" + if err := os.WriteFile(filepath.Join(codex, "session_index.jsonl"), []byte(index), 0o644); err != nil { + t.Fatal(err) + } + ss := List(0) + cx, cc := ss[0], ss[1] + if cx.Title != "Codex 起的名字" { + t.Fatalf("codex: the thread name, not the first prompt: %q", cx.Title) + } + if cc.Title != "Fix login bug" { + t.Fatalf("claude: the title Claude Code made, not the first prompt: %q", cc.Title) + } + + path := filepath.Join(claude, "projects", "-work-app", "11111111-2222-3333-4444-555555555555.jsonl") + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + f.WriteString(`{"type":"custom-title","customTitle":"Login, renamed","sessionId":"11111111-2222-3333-4444-555555555555"}` + "\n") + f.Close() + if got := List(0)[1].Title; got != "Login, renamed" { + t.Fatalf("claude: the name it was renamed to: %q", got) + } +} diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go index 662c362c..c12bdc09 100644 --- a/internal/sessions/sessions.go +++ b/internal/sessions/sessions.go @@ -14,6 +14,7 @@ package sessions import ( "bufio" "bytes" + "cmp" "encoding/json" "errors" "io" @@ -96,7 +97,7 @@ type Session struct { Agent string `json:"agent"` // magpie agent id: claude, codex, opencode, pi, omp, zcode, dsh, cline, qoder, qoder-cn, grok, workbuddy, droid, cursor, hermes, alma ID string `json:"id"` Cwd string `json:"cwd"` - Title string `json:"title"` // the first prompt, else the agent's own title + Title string `json:"title"` // the name it was given, else the agent's own title, else the first prompt Start time.Time `json:"start"` Last time.Time `json:"last"` Models []Model `json:"models"` @@ -138,6 +139,7 @@ type state struct { Cwd string `json:"cwd,omitempty"` Title string `json:"title,omitempty"` Named string `json:"named,omitempty"` // the agent's own title for it + Custom string `json:"custom,omitempty"` // the name the user gave it First string `json:"first,omitempty"` // the first message, when no prompt looked typed Start time.Time `json:"start"` Last time.Time `json:"last"` @@ -530,7 +532,8 @@ func CachePath() string { return filepath.Join(filepath.Dir(catalog.CachePath()) // 16: count Codex response records and compaction usage. // 17: reconcile recent Claude message revisions. // 18: Claude Code's 1-hour cache writes apart from its 5-minute ones. -const cacheVersion = 18 +// 19: the names the user gave Claude Code's and WorkBuddy's (custom-title). +const cacheVersion = 19 type cacheFile struct { Version int `json:"version"` @@ -903,7 +906,7 @@ func assemble(fs []file, price func(string) *catalog.Price) (Session, bool) { s.ReadOnly = s.ReadOnly || f.readOnly } s.ID = strings.TrimPrefix(fs[0].key, s.Agent+":") - var named, first string + var custom, named, first string models := map[string]*Model{} for _, f := range fs { st := cache[f.path] @@ -920,6 +923,9 @@ func assemble(fs []file, price func(string) *catalog.Price) (Session, bool) { if st.Named != "" { named = st.Named } + if st.Custom != "" { + custom = st.Custom + } if first == "" { first = st.First } @@ -942,12 +948,13 @@ func assemble(fs []file, price func(string) *catalog.Price) (Session, bool) { m.add(t) } } - if s.Title == "" { - s.Title = named - } - if s.Title == "" { - s.Title = first + // the name the session goes by in its agent: one the user gave it, else + // Codex's thread name (session_index.jsonl), else the title the agent + // made, else the first prompt, else the first message + if s.Agent == "codex" && s.WSL == "" && custom == "" { + custom = CodexTitles([]string{s.ID})[s.ID] } + s.Title = cmp.Or(custom, named, s.Title, first) for _, m := range models { if p := price(m.Model); p != nil { // a sum of the session's calls of the model, at its base price: diff --git a/internal/sessions/sessions_test.go b/internal/sessions/sessions_test.go index c371091a..a80aafcf 100644 --- a/internal/sessions/sessions_test.go +++ b/internal/sessions/sessions_test.go @@ -96,7 +96,7 @@ func TestList(t *testing.T) { } // Claude Code: a message's blocks counted once, the subagent's calls in - if cc.ID != "11111111-2222-3333-4444-555555555555" || cc.Cwd != "/work/app" || cc.Title != "Fix the login bug in auth.go" { + if cc.ID != "11111111-2222-3333-4444-555555555555" || cc.Cwd != "/work/app" || cc.Title != "Fix login bug" { t.Fatalf("claude: %+v", cc) } if o := model(cc, "claude-opus-5-5"); o.Tokens != (Tokens{100, 50, 5000, 1000, 0}) || !o.Priced { diff --git a/internal/sessions/workbuddy.go b/internal/sessions/workbuddy.go index c4fbd5b4..76ecd7ad 100644 --- a/internal/sessions/workbuddy.go +++ b/internal/sessions/workbuddy.go @@ -142,7 +142,7 @@ func workbuddyLine(s *state, b []byte, main bool) { case "custom-title": // named by the user: before the one WorkBuddy made if main && l.CustomTitle != "" { - s.Title = title(l.CustomTitle) + s.Custom = title(l.CustomTitle) } case "ai-title": if main && l.AITitle != "" { diff --git a/sessions_cli_test.go b/sessions_cli_test.go index 510273bd..0cbcff22 100644 --- a/sessions_cli_test.go +++ b/sessions_cli_test.go @@ -84,8 +84,8 @@ func TestSessionsCmd(t *testing.T) { now := sessionsHome(t) list := runSessions(t, now) - wantAll(t, list, "ago", "Codex", "it's", "Add a README", "Claude Code", "app", "Fix the login bug in auth.go", "latest 2") - if strings.Index(list, "Add a README") > strings.Index(list, "Fix the login") { + wantAll(t, list, "ago", "Codex", "it's", "Add a README", "Claude Code", "app", "Fix login bug", "latest 2") + if strings.Index(list, "Add a README") > strings.Index(list, "Fix login bug") { t.Error("not the latest first") } if got := runSessions(t, now, "--folder", "app"); strings.Contains(got, "README") || !strings.Contains(got, "login") {