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
64 changes: 62 additions & 2 deletions agent/codex/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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":
Expand Down Expand Up @@ -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]) + "..."
Expand All @@ -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")
Expand Down
165 changes: 165 additions & 0 deletions agent/codex/list_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading