diff --git a/agent/codex/list.go b/agent/codex/list.go index 7c00cd3105..4f1d31570d 100644 --- a/agent/codex/list.go +++ b/agent/codex/list.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "sort" @@ -55,10 +56,17 @@ func listCodexSessions(workDir, codexHome string) ([]core.AgentSessionInfo, erro return nil, nil } + sessionTitles := loadCodexSessionTitles(codexHome) var sessions []core.AgentSessionInfo for _, f := range files { info := parseCodexSessionFile(f, absWorkDir) if info != nil { + if title := sessionTitles[info.ID]; title != "" { + if titleRunes := []rune(title); len(titleRunes) > 60 { + title = string(titleRunes[:60]) + "..." + } + info.Summary = title + } patchSessionSource(info.ID, codexHome) sessions = append(sessions, *info) } @@ -71,6 +79,38 @@ func listCodexSessions(workDir, codexHome string) ([]core.AgentSessionInfo, erro return sessions, nil } +// loadCodexSessionTitles reads the same generated thread names that Codex uses +// in its session picker. Later entries win because renames append a new record. +func loadCodexSessionTitles(codexHome string) map[string]string { + path := filepath.Join(resolveCodexHomeDir(codexHome), "session_index.jsonl") + f, err := os.Open(path) + if err != nil { + return nil + } + defer func() { + if err := f.Close(); err != nil { + slog.Warn("codex: failed to close session index", "path", path, "error", err) + } + }() + + titles := make(map[string]string) + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 64*1024), 256*1024) + for scanner.Scan() { + var entry struct { + ID string `json:"id"` + ThreadName string `json:"thread_name"` + } + if json.Unmarshal(scanner.Bytes(), &entry) != nil { + continue + } + if entry.ID != "" && strings.TrimSpace(entry.ThreadName) != "" { + titles[entry.ID] = entry.ThreadName + } + } + return titles +} + // parseCodexSessionFile reads a Codex JSONL transcript. // Returns nil if the session's cwd doesn't match filterCwd. func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo { @@ -87,6 +127,7 @@ func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo { var sessionID string var sessionCwd string + var sessionSource json.RawMessage var summary string var msgCount int userMsgSeen := 0 @@ -110,13 +151,18 @@ func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo { switch entry.Type { case "session_meta": + if sessionID != "" { + continue + } var meta struct { - ID string `json:"id"` - Cwd string `json:"cwd"` + ID string `json:"id"` + Cwd string `json:"cwd"` + Source json.RawMessage `json:"source"` } if json.Unmarshal(entry.Payload, &meta) == nil { sessionID = meta.ID sessionCwd = meta.Cwd + sessionSource = meta.Source } case "response_item": @@ -154,6 +200,9 @@ func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo { if sessionID == "" { return nil } + if isSubagentSessionSource(sessionSource) { + return nil + } if len([]rune(summary)) > 60 { summary = string([]rune(summary)[:60]) + "..." @@ -167,6 +216,17 @@ func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo { } } +// isSubagentSessionSource reports whether Codex recorded the rollout as an +// internal subagent thread rather than a top-level user session. +func isSubagentSessionSource(source json.RawMessage) bool { + var object map[string]json.RawMessage + if json.Unmarshal(source, &object) != nil { + return false + } + _, ok := object["subagent"] + return ok +} + // findSessionFile locates the JSONL transcript for a given session ID. func findSessionFile(sessionID, codexHome string) string { sessionsDir := filepath.Join(resolveCodexHomeDir(codexHome), "sessions") diff --git a/agent/codex/list_test.go b/agent/codex/list_test.go new file mode 100644 index 0000000000..a98d6284ed --- /dev/null +++ b/agent/codex/list_test.go @@ -0,0 +1,165 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAgentListSessions_ExcludesSubagentRollouts(t *testing.T) { + workDir := t.TempDir() + codexHome := t.TempDir() + sessionsDir := filepath.Join(codexHome, "sessions", "2026", "08", "03") + if err := os.MkdirAll(sessionsDir, 0o755); err != nil { + t.Fatalf("create sessions directory: %v", err) + } + workDirJSON, err := json.Marshal(workDir) + if err != nil { + t.Fatalf("encode work directory: %v", err) + } + + writeRollout := func(name, sessionID, source string) { + t.Helper() + body := `{"type":"session_meta","payload":{"id":"` + sessionID + `","cwd":` + string(workDirJSON) + `,"source":` + source + `}}` + "\n" + + `{"type":"response_item","payload":{"role":"user","content":[{"type":"input_text","text":"fix the login bug"}]}}` + "\n" + if err := os.WriteFile(filepath.Join(sessionsDir, name), []byte(body), 0o644); err != nil { + t.Fatalf("write rollout %s: %v", name, err) + } + } + + writeRollout("rollout-top-level.jsonl", "top-level", `"vscode"`) + writeRollout( + "rollout-subagent.jsonl", + "subagent", + `{"subagent":{"thread_spawn":{"parent_thread_id":"top-level"}}}`, + ) + + agent := &Agent{workDir: workDir, codexHome: codexHome} + sessions, err := agent.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions() error: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("ListSessions() returned %d sessions, want 1 top-level session", len(sessions)) + } + if sessions[0].ID != "top-level" { + t.Fatalf("ListSessions()[0].ID = %q, want %q", sessions[0].ID, "top-level") + } +} + +func TestAgentListSessions_ExcludesSubagentRolloutWithCopiedParentMeta(t *testing.T) { + workDir := t.TempDir() + codexHome := t.TempDir() + sessionsDir := filepath.Join(codexHome, "sessions", "2026", "08", "04") + if err := os.MkdirAll(sessionsDir, 0o755); err != nil { + t.Fatalf("create sessions directory: %v", err) + } + + workDirJSON, err := json.Marshal(workDir) + if err != nil { + t.Fatalf("encode work directory: %v", err) + } + parentMeta := `{"type":"session_meta","payload":{"id":"parent","cwd":` + string(workDirJSON) + `,"source":"vscode"}}` + parentRollout := parentMeta + "\n" + + `{"type":"response_item","payload":{"role":"user","content":[{"type":"input_text","text":"top-level prompt"}]}}` + "\n" + if err := os.WriteFile(filepath.Join(sessionsDir, "rollout-parent.jsonl"), []byte(parentRollout), 0o644); err != nil { + t.Fatalf("write parent rollout: %v", err) + } + + childMeta := `{"type":"session_meta","payload":{"id":"child","cwd":` + string(workDirJSON) + `,"source":{"subagent":{"thread_spawn":{"parent_thread_id":"parent"}}}}}` + childRollout := childMeta + "\n" + parentMeta + "\n" + + `{"type":"response_item","payload":{"role":"user","content":[{"type":"input_text","text":"copied parent prompt"}]}}` + "\n" + if err := os.WriteFile(filepath.Join(sessionsDir, "rollout-child.jsonl"), []byte(childRollout), 0o644); err != nil { + t.Fatalf("write child rollout: %v", err) + } + + agent := &Agent{workDir: workDir, codexHome: codexHome} + sessions, err := agent.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions() error: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("ListSessions() returned %d sessions, want only the parent session", len(sessions)) + } + if sessions[0].ID != "parent" { + t.Fatalf("ListSessions()[0].ID = %q, want parent", sessions[0].ID) + } +} + +func TestAgentListSessions_UsesSessionIndexThreadName(t *testing.T) { + workDir := t.TempDir() + codexHome := t.TempDir() + sessionsDir := filepath.Join(codexHome, "sessions", "2026", "08", "04") + if err := os.MkdirAll(sessionsDir, 0o755); err != nil { + t.Fatalf("create sessions directory: %v", err) + } + + workDirJSON, err := json.Marshal(workDir) + if err != nil { + t.Fatalf("encode work directory: %v", err) + } + const sessionID = "019fc636-3567-76e3-a4d6-b223545f7e71" + rollout := `{"type":"session_meta","payload":{"id":"` + sessionID + `","cwd":` + string(workDirJSON) + `,"source":"vscode"}}` + "\n" + + `{"type":"response_item","payload":{"role":"user","content":[{"type":"input_text","text":"这是很长的具体需求正文,不应该覆盖 Codex 生成的会话名称"}]}}` + "\n" + if err := os.WriteFile(filepath.Join(sessionsDir, "rollout-session.jsonl"), []byte(rollout), 0o644); err != nil { + t.Fatalf("write rollout: %v", err) + } + + indexEntry := `{"id":"` + sessionID + `","thread_name":"设计简易基础管理模块","updated_at":"2026-08-03T06:01:25Z"}` + "\n" + if err := os.WriteFile(filepath.Join(codexHome, "session_index.jsonl"), []byte(indexEntry), 0o644); err != nil { + t.Fatalf("write session index: %v", err) + } + + agent := &Agent{workDir: workDir, codexHome: codexHome} + sessions, err := agent.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions() error: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("ListSessions() returned %d sessions, want 1", len(sessions)) + } + if sessions[0].Summary != "设计简易基础管理模块" { + t.Fatalf("ListSessions()[0].Summary = %q, want Codex thread name", sessions[0].Summary) + } +} + +func TestAgentListSessions_LongThreadNameTruncated(t *testing.T) { + workDir := t.TempDir() + codexHome := t.TempDir() + sessionsDir := filepath.Join(codexHome, "sessions", "2026", "08", "15") + if err := os.MkdirAll(sessionsDir, 0o755); err != nil { + t.Fatalf("create sessions directory: %v", err) + } + + workDirJSON, err := json.Marshal(workDir) + if err != nil { + t.Fatalf("encode work directory: %v", err) + } + const sessionID = "019fc636-3567-76e3-a4d6-b223545f7e72" + rollout := `{"type":"session_meta","payload":{"id":"` + sessionID + `","cwd":` + string(workDirJSON) + `,"source":"vscode"}}` + "\n" + if err := os.WriteFile(filepath.Join(sessionsDir, "rollout-session.jsonl"), []byte(rollout), 0o644); err != nil { + t.Fatalf("write rollout: %v", err) + } + + longTitle := strings.Repeat("会", 61) + indexEntry := `{"id":"` + sessionID + `","thread_name":"` + longTitle + `","updated_at":"2026-08-15T00:00:00Z"}` + "\n" + if err := os.WriteFile(filepath.Join(codexHome, "session_index.jsonl"), []byte(indexEntry), 0o644); err != nil { + t.Fatalf("write session index: %v", err) + } + + agent := &Agent{workDir: workDir, codexHome: codexHome} + sessions, err := agent.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions() error: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("ListSessions() returned %d sessions, want 1", len(sessions)) + } + want := strings.Repeat("会", 60) + "..." + if sessions[0].Summary != want { + t.Fatalf("ListSessions()[0].Summary = %q, want %q", sessions[0].Summary, want) + } +}