fix(codex): surface corrupt history at user entry points

This commit is contained in:
zhen dai committed 2026-09-28 23:49:00 +08:00
1 parent 48c4243cc3
commit dac6160362
5 files changed
+129 -28

No files matched your search

+29 -24
View File
@@ -140,7 +140,7 @@ func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo {
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if strings.TrimSpace(line) == "" {
continue
}
@@ -149,7 +149,8 @@ func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo {
Payload json.RawMessage `json:"payload"`
}
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
slog.Warn("codex: decode session file", "path", path, "error", err)
return nil
}
switch entry.Type {
@@ -162,11 +163,13 @@ func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo {
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
if err := json.Unmarshal(entry.Payload, &meta); err != nil {
slog.Warn("codex: decode session metadata", "path", path, "error", err)
return nil
}
sessionID = meta.ID
sessionCwd = meta.Cwd
sessionSource = meta.Source
case "response_item":
var item struct {
@@ -176,21 +179,23 @@ func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo {
Text string `json:"text"`
} `json:"content"`
}
if json.Unmarshal(entry.Payload, &item) == nil {
if item.Role == "user" {
userMsgSeen++
msgCount++
// The actual user prompt is the last user response_item
// (earlier ones are system/AGENTS.md instructions).
// Pick the last content block that looks like a real prompt.
for _, c := range item.Content {
if c.Type == "input_text" && c.Text != "" && isUserPrompt(c.Text) {
summary = c.Text
}
if err := json.Unmarshal(entry.Payload, &item); err != nil {
slog.Warn("codex: decode session response item", "path", path, "error", err)
return nil
}
if item.Role == "user" {
userMsgSeen++
msgCount++
// The actual user prompt is the last user response_item
// (earlier ones are system/AGENTS.md instructions).
// Pick the last content block that looks like a real prompt.
for _, c := range item.Content {
if c.Type == "input_text" && c.Text != "" && isUserPrompt(c.Text) {
summary = c.Text
}
} else if item.Role == "assistant" {
msgCount++
}
} else if item.Role == "assistant" {
msgCount++
}
}
}
@@ -289,7 +294,7 @@ func readCodexSessionHistory(r io.Reader, limit int) ([]core.HistoryEntry, error
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if strings.TrimSpace(line) == "" {
continue
}
@@ -298,8 +303,8 @@ func readCodexSessionHistory(r io.Reader, limit int) ([]core.HistoryEntry, error
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
if json.Unmarshal([]byte(line), &raw) != nil {
continue
if err := json.Unmarshal([]byte(line), &raw); err != nil {
return nil, fmt.Errorf("decode session record: %w", err)
}
if raw.Type != "response_item" {
continue
@@ -314,8 +319,8 @@ func readCodexSessionHistory(r io.Reader, limit int) ([]core.HistoryEntry, error
Text string `json:"text"`
} `json:"content"`
}
if json.Unmarshal(raw.Payload, &item) != nil {
continue
if err := json.Unmarshal(raw.Payload, &item); err != nil {
return nil, fmt.Errorf("decode response item: %w", err)
}
ts := parseCodexTimestamp(raw.Timestamp)
+45
View File
@@ -215,6 +215,51 @@ func TestReadCodexSessionHistory_ReportsScanError(t *testing.T) {
}
}
func TestReadCodexSessionHistory_RejectsCorruptJSONAfterValidPrefix(t *testing.T) {
valid := `{"type":"response_item","payload":{"role":"user","content":[{"type":"input_text","text":"hello"}]}}`
for _, bad := range []string{
`{"type":"response_item",`,
`{"type":"response_item","payload":{"role":`,
} {
entries, err := readCodexSessionHistory(strings.NewReader(valid+"\n"+bad+"\n"), 10)
if err == nil || entries != nil {
t.Fatalf("corrupt record %q: entries = %+v, error = %v; want nil entries and error", bad, entries, err)
}
}
entries, err := readCodexSessionHistory(strings.NewReader(valid+"\n\n \n"+`{"type":"unknown_event","payload":{}}`+"\n"), 10)
if err != nil || len(entries) != 1 {
t.Fatalf("blank and unknown records: entries = %+v, error = %v; want one entry", entries, err)
}
}
func TestAgentListSessions_BadFileDoesNotHideHealthySession(t *testing.T) {
workDir := t.TempDir()
codexHome := t.TempDir()
dir := filepath.Join(codexHome, "sessions")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
cwd, err := json.Marshal(workDir)
if err != nil {
t.Fatal(err)
}
healthy := `{"type":"session_meta","payload":{"id":"healthy","cwd":` + string(cwd) + `}}` + "\n" +
`{"type":"response_item","payload":{"role":"user","content":[{"type":"input_text","text":"hello"}]}}` + "\n"
if err := os.WriteFile(filepath.Join(dir, "rollout-healthy.jsonl"), []byte(healthy), 0o644); err != nil {
t.Fatal(err)
}
bad := `{"type":"session_meta","payload":{"id":"bad","cwd":` + string(cwd) + `}}` + "\n" + `{"type":"response_item","payload":`
if err := os.WriteFile(filepath.Join(dir, "rollout-bad.jsonl"), []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
a := &Agent{workDir: workDir, codexHome: codexHome}
sessions, err := a.ListSessions(context.Background())
if err != nil || len(sessions) != 1 || sessions[0].ID != "healthy" {
t.Fatalf("ListSessions() = %+v, %v; want only healthy session", sessions, err)
}
}
func TestGetSessionHistory_ConvertsTimestampToLocal(t *testing.T) {
codexHome := t.TempDir()
sessionID := "session-local-time"
+11 -4
View File
@@ -9641,9 +9641,13 @@ func (e *Engine) cmdHistory(p Platform, msg *Message, args []string) {
agentSID := s.GetAgentSessionID()
if len(entries) == 0 && agentSID != "" {
if hp, ok := agent.(HistoryProvider); ok {
if agentEntries, err := hp.GetSessionHistory(e.ctx, agentSID, n); err == nil {
entries = agentEntries
agentEntries, err := hp.GetSessionHistory(e.ctx, agentSID, n)
if err != nil {
slog.Warn("history: agent transcript read failed", "session_id", agentSID, "error", err)
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgHistoryReadFailed))
return
}
entries = agentEntries
}
}
@@ -13810,9 +13814,12 @@ func (e *Engine) renderHistoryCard(sessionKey string) *Card {
agentSID := s.GetAgentSessionID()
if len(entries) == 0 && agentSID != "" {
if hp, ok := agent.(HistoryProvider); ok {
if agentEntries, err := hp.GetSessionHistory(e.ctx, agentSID, 10); err == nil {
entries = agentEntries
agentEntries, err := hp.GetSessionHistory(e.ctx, agentSID, 10)
if err != nil {
slog.Warn("history: agent transcript read failed", "session_id", agentSID, "error", err)
return e.simpleCard(e.i18n.T(MsgCardTitleHistory), "turquoise", e.i18n.T(MsgHistoryReadFailed))
}
entries = agentEntries
}
}
+36
View File
@@ -20,6 +20,15 @@ import (
type stubAgent struct{}
type failingHistoryAgent struct {
stubAgent
err error
}
func (a *failingHistoryAgent) GetSessionHistory(context.Context, string, int) ([]HistoryEntry, error) {
return []HistoryEntry{{Role: "user", Content: "partial history"}}, a.err
}
func (a *stubAgent) Name() string { return "stub" }
func (a *stubAgent) StartSession(_ context.Context, _ string) (AgentSession, error) {
return &stubAgentSession{}, nil
@@ -3830,6 +3839,33 @@ func TestCmdCurrent_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) {
}
}
func TestCmdHistory_ReadFailureIsVisibleWithoutLeakingPath(t *testing.T) {
const secretPath = "/private/codex-home/sessions/secret.jsonl"
agent := &failingHistoryAgent{err: fmt.Errorf("scan %s: token too long", secretPath)}
msg := &Message{SessionKey: "test:history-failure", ReplyCtx: "ctx"}
plain := &stubPlatformEngine{n: "plain"}
textEngine := NewEngine("test", agent, []Platform{plain}, "", LangEnglish)
textEngine.sessions.GetOrCreateActive(msg.SessionKey).SetAgentSessionID("broken", "test")
textEngine.cmdHistory(plain, msg, []string{"10"})
sent := plain.getSent()
if len(sent) != 1 || sent[0] != textEngine.i18n.T(MsgHistoryReadFailed) || strings.Contains(sent[0], secretPath) {
t.Fatalf("text history response = %q; want generic localized failure without path", sent)
}
cardPlatform := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "cards"}}
cardEngine := NewEngine("test", agent, []Platform{cardPlatform}, "", LangEnglish)
cardEngine.sessions.GetOrCreateActive(msg.SessionKey).SetAgentSessionID("broken", "test")
cardEngine.cmdHistory(cardPlatform, msg, nil)
if len(cardPlatform.repliedCards) != 1 {
t.Fatalf("card replies = %d, want 1", len(cardPlatform.repliedCards))
}
cardText := cardPlatform.repliedCards[0].RenderText()
if !strings.Contains(cardText, cardEngine.i18n.T(MsgHistoryReadFailed)) || strings.Contains(cardText, secretPath) || strings.Contains(cardText, "partial history") {
t.Fatalf("card history response = %q; want generic localized failure without path or partial history", cardText)
}
}
func TestCmdCurrent_ShowsAgentSummaryWhenNoCustomName(t *testing.T) {
p := &stubPlatformEngine{n: "plain"}
agent := &stubListAgent{sessions: []AgentSessionInfo{
+8
View File
@@ -255,6 +255,7 @@ const (
MsgListSwitchHint MsgKey = "list_switch_hint"
MsgListError MsgKey = "list_error"
MsgHistoryEmpty MsgKey = "history_empty"
MsgHistoryReadFailed MsgKey = "history_read_failed"
MsgNameUsage MsgKey = "name_usage"
MsgNameSet MsgKey = "name_set"
MsgNameNoSession MsgKey = "name_no_session"
@@ -1510,6 +1511,13 @@ var messages = map[MsgKey]map[Language]string{
LangJapanese: "現在のセッションに履歴がありません。",
LangSpanish: "No hay historial en la sesión actual.",
},
MsgHistoryReadFailed: {
LangEnglish: "Failed to read session history.",
LangChinese: "读取会话历史失败。",
LangTraditionalChinese: "讀取會話歷史失敗。",
LangJapanese: "セッション履歴の読み取りに失敗しました。",
LangSpanish: "No se pudo leer el historial de la sesión.",
},
MsgNameUsage: {
LangEnglish: "Usage:\n`/name <text>` — name the current session\n`/name <number> <text>` — name a session by list number",
LangChinese: "用法:\n`/name <名称>` — 命名当前会话\n`/name <序号> <名称>` — 按列表序号命名会话",