diff --git a/CLAUDE.md b/CLAUDE.md index b4cd68b1e..71577ad50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,6 +261,7 @@ if settings.IsSummarizeEnabled() { ### Logging vs User Output - **Internal/debug logging**: Use `logging.Debug/Info/Warn/Error(ctx, msg, attrs...)` from `cmd/entire/cli/logging/`. Writes to `.entire/logs/`. +- **Enabling debug/perf logs locally**: Prefer adding `"log_level": "DEBUG"` to `.entire/settings.local.json` when you need detailed hook/perf logs. This file is gitignored, so it is a low-risk local-only change. `ENTIRE_LOG_LEVEL=debug` also works and takes precedence. - **User-facing output**: Use `fmt.Fprint*(cmd.OutOrStdout(), ...)` or `cmd.ErrOrStderr()`. Don't use `fmt.Print*` for operational messages (checkpoint saves, hook invocations, strategy decisions) - those should use the `logging` package. diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 846d9e8e2..306f5fa04 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -199,6 +199,18 @@ type HookResponseWriter interface { WriteHookResponse(message string) error } +// RestoredSessionPathResolver is implemented by agents that need a +// transcript-specific path when Entire reconstructs a session from checkpoint +// metadata. This is used for restored sessions only; live sessions still use +// the agent's native hook/session references. +type RestoredSessionPathResolver interface { + Agent + + // ResolveRestoredSessionFile returns where Entire should write a restored + // transcript so the agent can discover it later. + ResolveRestoredSessionFile(sessionDir, agentSessionID string, transcript []byte) (string, error) +} + // TestOnly is implemented by agents that exist solely for testing (e.g., the Vogon canary agent). // These agents are excluded from the user-facing agent selection in `entire enable`. type TestOnly interface { diff --git a/cmd/entire/cli/agent/codex/codex.go b/cmd/entire/cli/agent/codex/codex.go index d8d3d2d56..5b04929d2 100644 --- a/cmd/entire/cli/agent/codex/codex.go +++ b/cmd/entire/cli/agent/codex/codex.go @@ -8,9 +8,11 @@ import ( "os" "path/filepath" "sort" + "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/validation" ) //nolint:gochecknoinits // Agent self-registration is the intended pattern @@ -89,24 +91,28 @@ func (c *CodexAgent) ResolveSessionFile(sessionDir, agentSessionID string) strin if filepath.IsAbs(agentSessionID) { return agentSessionID } + if path := findRolloutBySessionID(sessionDir, agentSessionID); path != "" { + return path + } if sessionDir != "" { - patterns := []string{ - filepath.Join(sessionDir, "rollout-*-"+agentSessionID+".jsonl"), - filepath.Join(sessionDir, "*", "*", "*", "rollout-*-"+agentSessionID+".jsonl"), - } - for _, pattern := range patterns { - matches, err := filepath.Glob(pattern) - if err == nil && len(matches) > 0 { - sort.Strings(matches) - return matches[len(matches)-1] - } - } - return filepath.Join(sessionDir, agentSessionID+".jsonl") } return agentSessionID } +// ResolveRestoredSessionFile returns the canonical Codex rollout path for a +// restored session so `codex resume ` can rediscover it. +func (c *CodexAgent) ResolveRestoredSessionFile(sessionDir, agentSessionID string, transcript []byte) (string, error) { + if err := validation.ValidateAgentSessionID(agentSessionID); err != nil { + return "", fmt.Errorf("validate agent session ID: %w", err) + } + startTime, err := parseSessionStartTime(transcript) + if err != nil { + return "", fmt.Errorf("parse session start time: %w", err) + } + return restoredRolloutPath(sessionDir, agentSessionID, startTime), nil +} + // ProtectedDirs returns directories that Codex uses for config/state. func (c *CodexAgent) ProtectedDirs() []string { return []string{".codex"} } @@ -166,7 +172,8 @@ func (c *CodexAgent) WriteSession(_ context.Context, session *agent.AgentSession return errors.New("session has no native data to write") } - if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + dataToWrite := sanitizeRestoredTranscript(session.NativeData) + if err := os.WriteFile(session.SessionRef, dataToWrite, 0o600); err != nil { return fmt.Errorf("failed to write transcript: %w", err) } @@ -200,3 +207,39 @@ func (c *CodexAgent) ChunkTranscript(_ context.Context, content []byte, maxSize func (c *CodexAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { return agent.ReassembleJSONL(chunks), nil } + +func restoredRolloutPath(codexHome, agentSessionID string, startTime time.Time) string { + timestamp := startTime.UTC() + datePath := filepath.Join( + codexHome, + timestamp.Format("2006"), + timestamp.Format("01"), + timestamp.Format("02"), + ) + filename := fmt.Sprintf("rollout-%s-%s.jsonl", timestamp.Format("2006-01-02T15-04-05"), agentSessionID) + return filepath.Join(datePath, filename) +} + +func findRolloutBySessionID(codexHome, agentSessionID string) string { + if codexHome == "" || validation.ValidateAgentSessionID(agentSessionID) != nil { + return "" + } + + patterns := []string{ + filepath.Join(codexHome, "rollout-*-"+agentSessionID+".jsonl"), + filepath.Join(codexHome, "*", "*", "*", "rollout-*-"+agentSessionID+".jsonl"), + filepath.Join(filepath.Dir(codexHome), "archived_sessions", "*", "*", "*", "rollout-*-"+agentSessionID+".jsonl"), + } + for _, pattern := range patterns { + matches, err := filepath.Glob(pattern) + if err != nil || len(matches) == 0 { + continue + } + // Multiple restored rollouts for the same session ID can exist. Return the + // lexicographically latest path so newer dated restores win deterministically. + sort.Strings(matches) + return matches[len(matches)-1] + } + + return "" +} diff --git a/cmd/entire/cli/agent/codex/codex_test.go b/cmd/entire/cli/agent/codex/codex_test.go index de3b3ebcb..8cb420a8e 100644 --- a/cmd/entire/cli/agent/codex/codex_test.go +++ b/cmd/entire/cli/agent/codex/codex_test.go @@ -93,6 +93,33 @@ func TestCodexAgent_ResolveSessionFile_SessionTreeLayout(t *testing.T) { require.Equal(t, expected, result) } +func TestCodexAgent_ResolveRestoredSessionFile(t *testing.T) { + t.Parallel() + + ag := &CodexAgent{} + dir := t.TempDir() + + path, err := ag.ResolveRestoredSessionFile(dir, "019d24c3-1111-2222-3333-444444444444", []byte(sampleRollout)) + require.NoError(t, err) + require.Equal(t, + filepath.Join(dir, "2026", "03", "25", "rollout-2026-03-25T11-31-10-019d24c3-1111-2222-3333-444444444444.jsonl"), + path, + ) +} + +func TestCodexAgent_ResolveSessionFile_FindsNestedRollout(t *testing.T) { + t.Parallel() + + ag := &CodexAgent{} + dir := t.TempDir() + want := filepath.Join(dir, "2026", "03", "25", "rollout-2026-03-25T11-31-10-019d24c3.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(want), 0o755)) + require.NoError(t, os.WriteFile(want, []byte(sampleRollout), 0o600)) + + got := ag.ResolveSessionFile(dir, "019d24c3") + require.Equal(t, want, got) +} + func TestCodexAgent_ReadSession(t *testing.T) { t.Parallel() ag := &CodexAgent{} diff --git a/cmd/entire/cli/agent/codex/transcript.go b/cmd/entire/cli/agent/codex/transcript.go index 6445fba81..a2c0cda75 100644 --- a/cmd/entire/cli/agent/codex/transcript.go +++ b/cmd/entire/cli/agent/codex/transcript.go @@ -17,9 +17,10 @@ import ( // Compile-time interface assertions. var ( - _ agent.TranscriptAnalyzer = (*CodexAgent)(nil) - _ agent.TokenCalculator = (*CodexAgent)(nil) - _ agent.PromptExtractor = (*CodexAgent)(nil) + _ agent.TranscriptAnalyzer = (*CodexAgent)(nil) + _ agent.TokenCalculator = (*CodexAgent)(nil) + _ agent.PromptExtractor = (*CodexAgent)(nil) + _ agent.RestoredSessionPathResolver = (*CodexAgent)(nil) ) // rolloutLine is the top-level JSONL line structure in Codex rollout files. @@ -29,6 +30,8 @@ type rolloutLine struct { Payload json.RawMessage `json:"payload"` } +const rolloutLineTypeResponseItem = "response_item" + // sessionMetaPayload is the payload for type="session_meta" lines. type sessionMetaPayload struct { ID string `json:"id"` @@ -156,7 +159,7 @@ func extractFilesFromLine(lineData []byte) []string { return nil } - if line.Type != "response_item" { + if line.Type != rolloutLineTypeResponseItem { return nil } @@ -274,7 +277,7 @@ func (c *CodexAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string continue } - if line.Type != "response_item" { + if line.Type != rolloutLineTypeResponseItem { continue } @@ -303,6 +306,134 @@ func (c *CodexAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string return prompts, nil } +// SanitizePortableTranscript strips encrypted history fragments that cannot be +// replayed when Entire reconstructs a Codex rollout outside its original +// session context. +func SanitizePortableTranscript(data []byte) []byte { + lines := splitJSONL(data) + if len(lines) == 0 { + return data + } + + sanitized := make([][]byte, 0, len(lines)) + for _, lineData := range lines { + updated, keep := sanitizeRolloutLine(lineData) + if !keep { + continue + } + sanitized = append(sanitized, updated) + } + + if len(sanitized) == 0 { + return data + } + return agent.ReassembleJSONL(sanitized) +} + +func sanitizeRestoredTranscript(data []byte) []byte { + return SanitizePortableTranscript(data) +} + +func sanitizeRolloutLine(lineData []byte) ([]byte, bool) { + var line rolloutLine + if err := json.Unmarshal(lineData, &line); err != nil { + return lineData, true + } + if line.Type == "compacted" { + return sanitizeCompactedLine(line) + } + if line.Type != rolloutLineTypeResponseItem { + return lineData, true + } + + var payload map[string]any + if err := json.Unmarshal(line.Payload, &payload); err != nil { + return lineData, true + } + + itemType, ok := payload["type"].(string) + if !ok { + return lineData, true + } + switch itemType { + case "reasoning": + delete(payload, "encrypted_content") + case "compaction", "compaction_summary": + return nil, false + default: + return lineData, true + } + + encodedPayload, err := json.Marshal(payload) + if err != nil { + return lineData, true + } + line.Payload = encodedPayload + + encodedLine, err := json.Marshal(line) + if err != nil { + return lineData, true + } + return encodedLine, true +} + +func sanitizeCompactedLine(line rolloutLine) ([]byte, bool) { + var payload map[string]any + if err := json.Unmarshal(line.Payload, &payload); err != nil { + return mustMarshalRolloutLine(line), true + } + + replacementHistory, ok := payload["replacement_history"].([]any) + if !ok { + return mustMarshalRolloutLine(line), true + } + + sanitizedHistory := sanitizeHistoryItems(replacementHistory) + payload["replacement_history"] = sanitizedHistory + + encodedPayload, err := json.Marshal(payload) + if err != nil { + return mustMarshalRolloutLine(line), true + } + line.Payload = encodedPayload + + return mustMarshalRolloutLine(line), true +} + +func sanitizeHistoryItems(items []any) []any { + sanitized := make([]any, 0, len(items)) + for _, item := range items { + itemMap, ok := item.(map[string]any) + if !ok { + sanitized = append(sanitized, item) + continue + } + + itemType, ok := itemMap["type"].(string) + if !ok { + sanitized = append(sanitized, itemMap) + continue + } + switch itemType { + case "reasoning": + delete(itemMap, "encrypted_content") + case "compaction", "compaction_summary": + continue + } + + sanitized = append(sanitized, itemMap) + } + return sanitized +} + +func mustMarshalRolloutLine(line rolloutLine) []byte { + encodedLine, err := json.Marshal(line) + if err != nil { + return nil + } + return encodedLine +} + // splitJSONL splits JSONL bytes into individual lines, skipping empty lines. func splitJSONL(data []byte) [][]byte { var lines [][]byte diff --git a/cmd/entire/cli/agent/codex/transcript_test.go b/cmd/entire/cli/agent/codex/transcript_test.go index 3525519a7..4dd6b1cf5 100644 --- a/cmd/entire/cli/agent/codex/transcript_test.go +++ b/cmd/entire/cli/agent/codex/transcript_test.go @@ -223,3 +223,35 @@ func TestSplitJSONL(t *testing.T) { require.Contains(t, string(lines[0]), `"a"`) require.Contains(t, string(lines[2]), `"c"`) } + +func TestSanitizeRestoredTranscript_StripsEncryptedItems(t *testing.T) { + t.Parallel() + + input := []byte(`{"timestamp":"2026-03-25T11:31:11.752Z","type":"session_meta","payload":{"id":"019d24c3","timestamp":"2026-03-25T11:31:10.922Z","cwd":"/tmp/repo","originator":"codex_exec","cli_version":"0.116.0","source":"exec"}} +{"timestamp":"2026-03-25T11:31:11.754Z","type":"response_item","payload":{"type":"reasoning","summary":[{"text":"brief"}],"encrypted_content":"REDACTED"}} +{"timestamp":"2026-03-25T11:31:11.755Z","type":"response_item","payload":{"type":"compaction","encrypted_content":"REDACTED"}} +{"timestamp":"2026-03-25T11:31:11.756Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}} +`) + + got := string(sanitizeRestoredTranscript(input)) + require.Contains(t, got, `"type":"reasoning"`) + require.NotContains(t, got, `"encrypted_content":"REDACTED"`) + require.NotContains(t, got, `"type":"compaction"`) + require.Contains(t, got, `"type":"message"`) +} + +func TestSanitizeRestoredTranscript_StripsEncryptedItemsFromCompactedHistory(t *testing.T) { + t.Parallel() + + input := []byte(`{"timestamp":"2026-03-25T11:31:11.752Z","type":"session_meta","payload":{"id":"019d24c3","timestamp":"2026-03-25T11:31:10.922Z","cwd":"/tmp/repo","originator":"codex_exec","cli_version":"0.116.0","source":"exec"}} +{"timestamp":"2026-03-25T11:31:11.754Z","type":"compacted","payload":{"message":"","replacement_history":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]},{"type":"reasoning","summary":[{"text":"brief"}],"encrypted_content":"REDACTED"},{"type":"compaction","encrypted_content":"REDACTED"},{"type":"compaction_summary","encrypted_content":"REDACTED"}]}} +`) + + got := string(sanitizeRestoredTranscript(input)) + require.Contains(t, got, `"type":"compacted"`) + require.Contains(t, got, `"type":"reasoning"`) + require.Contains(t, got, `"type":"message"`) + require.NotContains(t, got, `"encrypted_content":"REDACTED"`) + require.NotContains(t, got, `"type":"compaction"`) + require.NotContains(t, got, `"type":"compaction_summary"`) +} diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index 5cc7ace7e..0944b4335 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -81,6 +81,7 @@ func TestAttach_Success(t *testing.T) { } if state == nil { t.Fatal("expected session state to be created") + return } if state.SessionID != sessionID { t.Errorf("session ID = %q, want %q", state.SessionID, sessionID) @@ -368,6 +369,7 @@ func TestAttach_GeminiSubdirectorySession(t *testing.T) { } if state == nil { t.Fatal("expected session state to be created") + return } if state.AgentType != agent.AgentTypeGemini { t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeGemini) @@ -413,6 +415,7 @@ func TestAttach_GeminiSuccess(t *testing.T) { } if state == nil { t.Fatal("expected session state to be created") + return } if state.AgentType != agent.AgentTypeGemini { t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeGemini) @@ -458,6 +461,7 @@ func TestAttach_CursorSuccess(t *testing.T) { } if state == nil { t.Fatal("expected session state to be created") + return } if state.AgentType != agent.AgentTypeCursor { t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeCursor) @@ -506,6 +510,7 @@ func TestAttach_CodexSuccess(t *testing.T) { } if state == nil { t.Fatal("expected session state to be created") + return } if state.AgentType != agent.AgentTypeCodex { t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeCodex) @@ -554,6 +559,7 @@ func TestAttach_FactoryAIDroidSuccess(t *testing.T) { } if state == nil { t.Fatal("expected session state to be created") + return } if state.AgentType != agent.AgentTypeFactoryAIDroid { t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeFactoryAIDroid) diff --git a/cmd/entire/cli/checkpoint/checkpoint_test.go b/cmd/entire/cli/checkpoint/checkpoint_test.go index 711bdce54..f2b1e1cb8 100644 --- a/cmd/entire/cli/checkpoint/checkpoint_test.go +++ b/cmd/entire/cli/checkpoint/checkpoint_test.go @@ -1170,6 +1170,39 @@ func writeSingleSession(t *testing.T, cpIDStr, sessionID, transcript string) (*G return store, checkpointID } +func TestWriteCommitted_CodexSanitizesPortableTranscript(t *testing.T) { + repo, _ := setupBranchTestRepo(t) + store := NewGitStore(repo) + checkpointID := id.MustCheckpointID("c0de1234beef") + + transcript := `{"timestamp":"2026-03-25T11:31:11.754Z","type":"response_item","payload":{"type":"reasoning","summary":[{"text":"brief"}],"encrypted_content":"REDACTED"}} +{"timestamp":"2026-03-25T11:31:11.755Z","type":"response_item","payload":{"type":"compaction","encrypted_content":"REDACTED"}} +{"timestamp":"2026-03-25T11:31:11.756Z","type":"compacted","payload":{"message":"","replacement_history":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]},{"type":"reasoning","summary":[{"text":"nested"}],"encrypted_content":"REDACTED"},{"type":"compaction","encrypted_content":"REDACTED"},{"type":"compaction_summary","encrypted_content":"REDACTED"}]}} +` + + err := store.WriteCommitted(context.Background(), WriteCommittedOptions{ + CheckpointID: checkpointID, + SessionID: "codex-session", + Strategy: "manual-commit", + Agent: agent.AgentTypeCodex, + Transcript: []byte(transcript), + CheckpointsCount: 1, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + }) + require.NoError(t, err) + + content, err := store.ReadLatestSessionContent(context.Background(), checkpointID) + require.NoError(t, err) + + got := string(content.Transcript) + require.NotContains(t, got, `"encrypted_content":"REDACTED"`) + require.NotContains(t, got, `"type":"compaction"`) + require.NotContains(t, got, `"type":"compaction_summary"`) + require.Contains(t, got, `"summary":[{"text":"brief"}]`) + require.Contains(t, got, `"summary":[{"text":"nested"}]`) +} + // TestReadSessionContent_InvalidIndex verifies that ReadSessionContent returns // an error when requesting a session index that doesn't exist. func TestReadSessionContent_InvalidIndex(t *testing.T) { diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index f2b6633f7..f17cc147e 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -16,6 +16,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/codex" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/jsonutil" @@ -24,6 +25,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/entireio/cli/cmd/entire/cli/validation" "github.com/entireio/cli/cmd/entire/cli/versioninfo" + "github.com/entireio/cli/perf" "github.com/entireio/cli/redact" "github.com/go-git/go-git/v6" @@ -626,6 +628,7 @@ func aggregateTokenUsage(a, b *agent.TokenUsage) *agent.TokenUsage { // writeTranscript writes the transcript file from in-memory content or file path. // If the transcript exceeds MaxChunkSize, it's split into multiple chunk files. func (s *GitStore) writeTranscript(ctx context.Context, opts WriteCommittedOptions, basePath string, entries map[string]object.TreeEntry) error { + logCtx := logging.WithComponent(ctx, "checkpoint") transcript := opts.Transcript if len(transcript) == 0 && opts.TranscriptPath != "" { var readErr error @@ -639,23 +642,43 @@ func (s *GitStore) writeTranscript(ctx context.Context, opts WriteCommittedOptio return nil } + if opts.Agent == agent.AgentTypeCodex { + transcript = codex.SanitizePortableTranscript(transcript) + } + // Redact secrets before chunking so content hash reflects redacted content + redactStart := time.Now() + redactCtx, redactTranscriptSpan := perf.Start(ctx, "redact_transcript") transcript, err := redact.JSONLBytes(transcript) if err != nil { + redactTranscriptSpan.RecordError(err) + redactTranscriptSpan.End() return fmt.Errorf("failed to redact transcript secrets: %w", err) } + redactTranscriptSpan.End() + redactDuration := time.Since(redactStart) // Chunk the transcript if it's too large - chunks, err := agent.ChunkTranscript(ctx, transcript, opts.Agent) + chunkStart := time.Now() + chunkCtx, chunkTranscriptSpan := perf.Start(redactCtx, "chunk_transcript") + chunks, err := agent.ChunkTranscript(chunkCtx, transcript, opts.Agent) if err != nil { + chunkTranscriptSpan.RecordError(err) + chunkTranscriptSpan.End() return fmt.Errorf("failed to chunk transcript: %w", err) } + chunkTranscriptSpan.End() + chunkDuration := time.Since(chunkStart) // Write chunk files + blobStart := time.Now() + blobCtx, writeTranscriptBlobsSpan := perf.Start(chunkCtx, "write_transcript_blobs") for i, chunk := range chunks { chunkPath := basePath + agent.ChunkFileName(paths.TranscriptFileName, i) blobHash, err := CreateBlobFromContent(s.repo, chunk) if err != nil { + writeTranscriptBlobsSpan.RecordError(err) + writeTranscriptBlobsSpan.End() return err } entries[chunkPath] = object.TreeEntry{ @@ -664,11 +687,17 @@ func (s *GitStore) writeTranscript(ctx context.Context, opts WriteCommittedOptio Hash: blobHash, } } + writeTranscriptBlobsSpan.End() + blobDuration := time.Since(blobStart) // Content hash for deduplication (hash of full transcript) + contentHashStart := time.Now() + _, contentHashSpan := perf.Start(blobCtx, "write_transcript_content_hash") contentHash := fmt.Sprintf("sha256:%x", sha256.Sum256(transcript)) hashBlob, err := CreateBlobFromContent(s.repo, []byte(contentHash)) if err != nil { + contentHashSpan.RecordError(err) + contentHashSpan.End() return err } entries[basePath+paths.ContentHashFileName] = object.TreeEntry{ @@ -676,6 +705,19 @@ func (s *GitStore) writeTranscript(ctx context.Context, opts WriteCommittedOptio Mode: filemode.Regular, Hash: hashBlob, } + contentHashSpan.End() + + logging.Debug(logCtx, "write transcript timings", + slog.String("session_id", opts.SessionID), + slog.String("checkpoint_id", opts.CheckpointID.String()), + slog.String("agent", string(opts.Agent)), + slog.Int64("redact_transcript_ms", redactDuration.Milliseconds()), + slog.Int64("chunk_transcript_ms", chunkDuration.Milliseconds()), + slog.Int64("write_transcript_blobs_ms", blobDuration.Milliseconds()), + slog.Int64("write_transcript_content_hash_ms", time.Since(contentHashStart).Milliseconds()), + slog.Int("transcript_bytes", len(transcript)), + slog.Int("chunk_count", len(chunks)), + ) return nil } diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index 2c5539d4f..ea20ff1de 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -777,6 +777,39 @@ func TestDisplayRestoredSessions_SingleSessionOutput(t *testing.T) { } } +func TestDisplayRestoredSessions_CodexShowsResumeCommand(t *testing.T) { + t.Parallel() + + session := strategy.RestoredSession{ + SessionID: "019d6d29-8cf7-7fe3-adc9-8c3e4d9d5603", + Agent: "Codex", + Prompt: "Can you take a look at the go code", + CreatedAt: time.Date(2026, time.April, 8, 18, 46, 0, 0, time.UTC), + } + + ag, err := strategy.ResolveAgentForRewind(session.Agent) + if err != nil { + t.Fatalf("ResolveAgentForRewind() error = %v", err) + } + + var output bytes.Buffer + if err := displayRestoredSessions(&output, []strategy.RestoredSession{session}); err != nil { + t.Fatalf("displayRestoredSessions() error = %v", err) + } + + got := output.String() + if !strings.Contains(got, "✓ Restored session 019d6d29-8cf7-7fe3-adc9-8c3e4d9d5603.\n") { + t.Fatalf("displayRestoredSessions() missing session header, got: %q", got) + } + if !strings.Contains(got, "\nTo continue this session, run:\n") { + t.Fatalf("displayRestoredSessions() missing continuation header, got: %q", got) + } + wantCommand := " " + ag.FormatResumeCommand(session.SessionID) + " # Can you take a look at the go code\n" + if !strings.Contains(got, wantCommand) { + t.Fatalf("displayRestoredSessions() missing command %q in %q", wantCommand, got) + } +} + func TestPrintMultiSessionResumeCommands_SingleSessionHasCheckmark(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/search_tui_test.go b/cmd/entire/cli/search_tui_test.go index a01f6aeac..a0f887f74 100644 --- a/cmd/entire/cli/search_tui_test.go +++ b/cmd/entire/cli/search_tui_test.go @@ -654,6 +654,7 @@ func TestSearchModel_SelectedResult(t *testing.T) { r := m.selectedResult() if r == nil { t.Fatal("selectedResult() = nil, want first result") + return } if r.Data.ID != "a3b2c4d5e6f7" { t.Errorf("selectedResult().Data.ID = %q, want %q", r.Data.ID, "a3b2c4d5e6f7") @@ -664,6 +665,7 @@ func TestSearchModel_SelectedResult(t *testing.T) { r = m.selectedResult() if r == nil { t.Fatal("selectedResult() at cursor 1 = nil") + return } if r.Data.ID != "d5e6f789ab01" { t.Errorf("selectedResult().Data.ID = %q, want %q", r.Data.ID, "d5e6f789ab01") diff --git a/cmd/entire/cli/sessions_test.go b/cmd/entire/cli/sessions_test.go index 7fe9427bc..ed1676d1c 100644 --- a/cmd/entire/cli/sessions_test.go +++ b/cmd/entire/cli/sessions_test.go @@ -100,6 +100,7 @@ func TestStopCmd_SingleSession_EmptyWorktreePath_Force(t *testing.T) { } if loaded == nil { t.Fatal("expected session state to still exist after stop") + return } if loaded.Phase != session.PhaseEnded { t.Errorf("expected Phase=PhaseEnded, got: %v", loaded.Phase) @@ -191,6 +192,7 @@ func TestStopCmd_AlreadyStopped(t *testing.T) { } if loaded == nil { t.Fatal("expected session state to still exist") + return } if loaded.Phase != session.PhaseEnded { t.Errorf("expected Phase=PhaseEnded unchanged, got: %v", loaded.Phase) @@ -225,6 +227,7 @@ func TestStopCmd_SessionFlag(t *testing.T) { } if target == nil { t.Fatal("expected target session state to exist") + return } if target.Phase != session.PhaseEnded { t.Errorf("expected target Phase=PhaseEnded, got: %v", target.Phase) @@ -236,6 +239,7 @@ func TestStopCmd_SessionFlag(t *testing.T) { } if other == nil { t.Fatal("expected other session state to exist") + return } if other.Phase == session.PhaseEnded { t.Errorf("expected other session to remain non-ended, got: %v", other.Phase) @@ -284,6 +288,7 @@ func TestStopCmd_AllFlag(t *testing.T) { } if loaded == nil { t.Fatalf("expected session %s to exist after stop", id) + return } if loaded.Phase != session.PhaseEnded { t.Errorf("expected session %s Phase=PhaseEnded, got: %v", id, loaded.Phase) @@ -330,6 +335,7 @@ func TestStopCmd_AllFlag_IncludesAllWorktrees(t *testing.T) { } if loaded == nil { t.Fatalf("expected session %s to exist after stop", id) + return } if loaded.Phase != session.PhaseEnded { t.Errorf("expected session %s to be PhaseEnded, got: %v", id, loaded.Phase) @@ -384,6 +390,7 @@ func TestStopCmd_AllAndSessionMutuallyExclusive(t *testing.T) { } if loaded == nil { t.Fatal("expected session state to still exist") + return } if loaded.Phase == session.PhaseEnded { t.Error("expected session to remain non-ended after mutual exclusion error") @@ -473,6 +480,7 @@ func TestStopSelectedSessions_StopsAll(t *testing.T) { } if loaded == nil { t.Fatalf("expected session %s to exist after batch stop", id) + return } if loaded.Phase != session.PhaseEnded { t.Errorf("expected session %s to be PhaseEnded after batch stop, got: %v", id, loaded.Phase) @@ -517,6 +525,7 @@ func TestStopCmd_AlreadyStopped_EndedAtOnly(t *testing.T) { } if loaded == nil { t.Fatal("expected session state to still exist") + return } if loaded.Phase != session.PhaseIdle { t.Errorf("expected Phase to remain PhaseIdle (legacy), got: %v", loaded.Phase) @@ -606,6 +615,7 @@ func TestStopCmd_NoFlags_CrossWorktreeSession(t *testing.T) { } if loaded == nil { t.Fatal("expected cross-worktree session to exist after stop") + return } if loaded.Phase != session.PhaseEnded { t.Errorf("expected cross-worktree session to be PhaseEnded, got: %v", loaded.Phase) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index a7e1f02c1..90db3bef1 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -8,7 +8,9 @@ import ( "log/slog" "os" "path/filepath" + "slices" "strings" + "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" @@ -26,6 +28,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/transcript" "github.com/entireio/cli/cmd/entire/cli/transcript/compact" "github.com/entireio/cli/cmd/entire/cli/versioninfo" + "github.com/entireio/cli/perf" "github.com/entireio/cli/redact" "github.com/go-git/go-git/v6" @@ -112,6 +115,8 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re if len(opts) > 0 { o = opts[0] } + logCtx := logging.WithComponent(ctx, "checkpoint") + condenseStart := time.Now() // Get shadow branch — use pre-resolved ref if available, otherwise resolve from repo. shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) @@ -132,14 +137,20 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re resolveTranscriptPath(state) //nolint:errcheck,gosec // best-effort; downstream readers handle missing files var sessionData *ExtractedSessionData + extractStart := time.Now() + _, extractSessionDataSpan := perf.Start(ctx, "extract_session_data") if hasShadowBranch { var extractErr error sessionData, extractErr = s.extractSessionData(ctx, repo, ref.Hash(), state.SessionID, state.FilesTouched, state.AgentType, state.TranscriptPath, state.CheckpointTranscriptStart, state.Phase.IsActive()) if extractErr != nil { + extractSessionDataSpan.RecordError(extractErr) + extractSessionDataSpan.End() return nil, fmt.Errorf("failed to extract session data: %w", extractErr) } } else { if state.TranscriptPath == "" { + extractSessionDataSpan.RecordError(errors.New("shadow branch not found and no live transcript available")) + extractSessionDataSpan.End() return nil, errors.New("shadow branch not found and no live transcript available") } if state.Phase.IsActive() { @@ -148,9 +159,13 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re var extractErr error sessionData, extractErr = s.extractSessionDataFromLiveTranscript(ctx, state) if extractErr != nil { + extractSessionDataSpan.RecordError(extractErr) + extractSessionDataSpan.End() return nil, fmt.Errorf("failed to extract session data from live transcript: %w", extractErr) } } + extractSessionDataSpan.End() + extractDuration := time.Since(extractStart) // Backfill session state token usage from the freshly-extracted transcript. // Copilot CLI writes session.shutdown after the hooks return, so by condensation @@ -174,9 +189,11 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re } } sessionData.FilesTouched = filtered - } - - if len(sessionData.FilesTouched) == 0 && !hadFilesBeforeFiltering { + } else { + // Mid-turn commits can happen before SaveStep records FilesTouched. + // In that case, fall back to the actual committed files, excluding + // Entire's own metadata paths, so the checkpoint still reflects the + // files captured by this commit. sessionData.FilesTouched = committedFilesExcludingMetadata(committedFiles) } } @@ -196,7 +213,9 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re attrBase = state.BaseCommit } - attribution := calculateSessionAttributions(ctx, repo, ref, sessionData, state, attributionOpts{ + attributionStart := time.Now() + attrCtx, attributionSpan := perf.Start(ctx, "calculate_session_attribution") + attribution := calculateSessionAttributions(attrCtx, repo, ref, sessionData, state, attributionOpts{ headTree: o.headTree, parentTree: o.parentTree, repoDir: o.repoDir, @@ -205,6 +224,8 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re headCommitHash: o.headCommitHash, allAgentFiles: o.allAgentFiles, }) + attributionSpan.End() + attributionDuration := time.Since(attributionStart) // Get current branch name branchName := GetCurrentBranchName(repo) @@ -239,24 +260,55 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re Summary: summary, } - if settings.IsCheckpointsV2Enabled(ctx) { - redactedForCompact, compactRedactErr := redact.JSONLBytes(sessionData.Transcript) - if compactRedactErr != nil { - logging.Warn(ctx, "compact transcript redaction failed, skipping transcript.jsonl on /main", - slog.String("session_id", state.SessionID), - slog.String("error", compactRedactErr.Error()), - ) - redactedForCompact = nil - } - writeOpts.CompactTranscript = compactTranscriptForV2(ctx, ag, redactedForCompact, state.CheckpointTranscriptStart) + compactRedactStart := time.Now() + compactCtx, compactRedactSpan := perf.Start(ctx, "redact_transcript_for_compact") + redactedForCompact, compactRedactErr := redact.JSONLBytes(sessionData.Transcript) + if compactRedactErr != nil { + compactRedactSpan.RecordError(compactRedactErr) + logging.Warn(ctx, "compact transcript redaction failed, skipping transcript.jsonl on /main", + slog.String("session_id", state.SessionID), + slog.String("error", compactRedactErr.Error()), + ) + redactedForCompact = nil } + compactRedactSpan.End() + compactRedactDuration := time.Since(compactRedactStart) + compactTranscriptStart := time.Now() + compactCtx, compactTranscriptSpan := perf.Start(compactCtx, "compact_transcript_v2") + writeOpts.CompactTranscript = compactTranscriptForV2(compactCtx, ag, redactedForCompact, state.CheckpointTranscriptStart) + compactTranscriptSpan.End() + compactTranscriptDuration := time.Since(compactTranscriptStart) // Write checkpoint metadata to v1 branch - if err := store.WriteCommitted(ctx, writeOpts); err != nil { + writeV1Start := time.Now() + writeCtx, writeCommittedSpan := perf.Start(ctx, "write_committed_v1") + if err := store.WriteCommitted(writeCtx, writeOpts); err != nil { + writeCommittedSpan.RecordError(err) + writeCommittedSpan.End() return nil, fmt.Errorf("failed to write checkpoint metadata: %w", err) } - - writeCommittedV2IfEnabled(ctx, repo, writeOpts) + writeCommittedSpan.End() + writeV1Duration := time.Since(writeV1Start) + + writeV2Start := time.Now() + writeV2Ctx, writeCommittedV2Span := perf.Start(ctx, "write_committed_v2") + writeCommittedV2IfEnabled(writeV2Ctx, repo, writeOpts) + writeCommittedV2Span.End() + writeV2Duration := time.Since(writeV2Start) + + logging.Debug(logCtx, "condense timings", + slog.String("session_id", state.SessionID), + slog.String("checkpoint_id", checkpointID.String()), + slog.Int64("extract_session_data_ms", extractDuration.Milliseconds()), + slog.Int64("calculate_session_attribution_ms", attributionDuration.Milliseconds()), + slog.Int64("redact_transcript_for_compact_ms", compactRedactDuration.Milliseconds()), + slog.Int64("compact_transcript_v2_ms", compactTranscriptDuration.Milliseconds()), + slog.Int64("write_committed_v1_ms", writeV1Duration.Milliseconds()), + slog.Int64("write_committed_v2_ms", writeV2Duration.Milliseconds()), + slog.Int64("total_ms", time.Since(condenseStart).Milliseconds()), + slog.Int("transcript_bytes", len(sessionData.Transcript)), + slog.Int("transcript_lines", sessionData.FullTranscriptLines), + ) return &CondenseResult{ CheckpointID: checkpointID, @@ -542,6 +594,7 @@ func committedFilesExcludingMetadata(committedFiles map[string]struct{}) []strin } result = append(result, f) } + slices.Sort(result) return result } diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index cd3da506c..e0a9e6cfe 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2665,13 +2665,15 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( remainingFiles []string, ) { logCtx := logging.WithComponent(ctx, "checkpoint") + start := time.Now() store := checkpoint.NewGitStore(repo) // Don't include metadata directory in carry-forward. The carry-forward branch // only needs to preserve file content for comparison - not the transcript. // Including the transcript would cause sessionHasNewContent to always return true // because CheckpointTranscriptStart is reset to 0 for carry-forward. - result, err := store.WriteTemporary(ctx, checkpoint.WriteTemporaryOptions{ + writeCtx, carryForwardWriteSpan := perf.Start(ctx, "write_carry_forward_shadow") + result, err := store.WriteTemporary(writeCtx, checkpoint.WriteTemporaryOptions{ SessionID: state.SessionID, BaseCommit: state.BaseCommit, WorktreeID: state.WorktreeID, @@ -2682,12 +2684,16 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( IsFirstCheckpoint: false, }) if err != nil { + carryForwardWriteSpan.RecordError(err) + carryForwardWriteSpan.End() logging.Warn(logCtx, "post-commit: carry-forward failed", slog.String("session_id", state.SessionID), slog.String("error", err.Error()), ) return } + carryForwardWriteSpan.End() + duration := time.Since(start) if result.Skipped { logging.Debug(logCtx, "post-commit: carry-forward skipped (no changes)", slog.String("session_id", state.SessionID), @@ -2716,4 +2722,9 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( slog.String("session_id", state.SessionID), slog.Int("remaining_files", len(remainingFiles)), ) + logging.Debug(logCtx, "carry-forward timings", + slog.String("session_id", state.SessionID), + slog.Int64("write_carry_forward_shadow_ms", duration.Milliseconds()), + slog.Int("remaining_files", len(remainingFiles)), + ) } diff --git a/cmd/entire/cli/strategy/manual_commit_rewind.go b/cmd/entire/cli/strategy/manual_commit_rewind.go index 38d711738..ced4b8cfc 100644 --- a/cmd/entire/cli/strategy/manual_commit_rewind.go +++ b/cmd/entire/cli/strategy/manual_commit_rewind.go @@ -742,6 +742,14 @@ func (s *ManualCommitStrategy) RestoreLogsOnly(ctx context.Context, w, errW io.W continue } sessionFile := sessionAgent.ResolveSessionFile(sessionAgentDir, sessionID) + if resolver, ok := sessionAgent.(agent.RestoredSessionPathResolver); ok { + resolvedFile, resolveErr := resolver.ResolveRestoredSessionFile(sessionAgentDir, sessionID, content.Transcript) + if resolveErr != nil { + fmt.Fprintf(errW, " Warning: failed to resolve restored session path for session %d (%s): %v (using fallback path)\n", i, sessionID, resolveErr) + } else { + sessionFile = resolvedFile + } + } // Get first prompt for display promptPreview := ExtractFirstPrompt(content.Prompts) diff --git a/e2e/agents/codex.go b/e2e/agents/codex.go index 61fdd962e..9995fa264 100644 --- a/e2e/agents/codex.go +++ b/e2e/agents/codex.go @@ -23,6 +23,14 @@ func init() { // Codex implements the E2E Agent interface for OpenAI's Codex CLI. type Codex struct{} +type CodexSession struct { + *TmuxSession + + home string +} + +func (s *CodexSession) Home() string { return s.home } + func (c *Codex) Name() string { return "codex" } func (c *Codex) Binary() string { return "codex" } func (c *Codex) EntireAgent() string { return "codex" } @@ -149,12 +157,7 @@ func (c *Codex) StartSession(ctx context.Context, dir string) (Session, error) { return nil, fmt.Errorf("seed codex home: %w", err) } - s, err := NewTmuxSession(name, dir, []string{"CODEX_HOME", "ENTIRE_TEST_TTY"}, "env", - "CODEX_HOME="+home, - "HOME="+os.Getenv("HOME"), - "TERM="+os.Getenv("TERM"), - "codex", "--dangerously-bypass-approvals-and-sandbox", - ) + s, err := c.startTmuxSession(name, dir, home, "codex", "--dangerously-bypass-approvals-and-sandbox") if err != nil { cleanup() return nil, err @@ -177,7 +180,39 @@ func (c *Codex) StartSession(ctx context.Context, dir string) (Session, error) { time.Sleep(500 * time.Millisecond) } - return s, nil + return &CodexSession{TmuxSession: s, home: home}, nil +} + +func (c *Codex) ResumeSession(ctx context.Context, dir, home, sessionID string) (Session, error) { + _ = ctx + name := fmt.Sprintf("codex-resume-%d", time.Now().UnixNano()) + + s, err := c.startTmuxSession(name, dir, home, "codex", "--dangerously-bypass-approvals-and-sandbox", "resume", sessionID) + if err != nil { + return nil, err + } + + // Dismiss any startup prompts until the input prompt appears. + for range 5 { + content, waitErr := s.WaitFor(c.PromptPattern(), 15*time.Second) + if waitErr != nil { + _ = s.Close() + return nil, fmt.Errorf("waiting for codex resumed prompt: %w", waitErr) + } + if !strings.Contains(content, "press enter to confirm") && + !strings.Contains(content, "Use ↑/↓ to move") { + break + } + _ = s.SendKeys("Enter") + time.Sleep(500 * time.Millisecond) + } + + return &CodexSession{TmuxSession: s, home: home}, nil +} + +func (c *Codex) startTmuxSession(name, dir, home string, args ...string) (*TmuxSession, error) { + tmuxArgs := append([]string{"CODEX_HOME=" + home, "HOME=" + os.Getenv("HOME"), "TERM=" + os.Getenv("TERM")}, args...) + return NewTmuxSession(name, dir, []string{"CODEX_HOME", "ENTIRE_TEST_TTY"}, "env", tmuxArgs...) } // seedCodexHome writes trust + feature flag config and links auth credentials diff --git a/e2e/entire/entire.go b/e2e/entire/entire.go index b89e0d182..87668b647 100644 --- a/e2e/entire/entire.go +++ b/e2e/entire/entire.go @@ -136,11 +136,21 @@ func Resume(dir, branch string) (string, error) { return runOutput(dir, "resume", branch, "--force") } +// ResumeWithEnv runs `entire resume --force` with extra env vars. +func ResumeWithEnv(dir, branch string, extraEnv []string) (string, error) { + return runOutputEnv(dir, extraEnv, "resume", branch, "--force") +} + // runOutput executes an `entire` subcommand and returns (output, error). func runOutput(dir string, args ...string) (string, error) { + return runOutputEnv(dir, nil, args...) +} + +func runOutputEnv(dir string, extraEnv []string, args ...string) (string, error) { cmd := exec.Command(BinPath(), args...) cmd.Dir = dir - cmd.Env = append(os.Environ(), "ENTIRE_TEST_TTY=0") + cmd.Env = append(append([]string{}, os.Environ()...), "ENTIRE_TEST_TTY=0") + cmd.Env = append(cmd.Env, extraEnv...) out, err := cmd.CombinedOutput() if err != nil { diff --git a/e2e/tests/codex_resume_test.go b/e2e/tests/codex_resume_test.go new file mode 100644 index 000000000..138ecd1db --- /dev/null +++ b/e2e/tests/codex_resume_test.go @@ -0,0 +1,124 @@ +//go:build e2e + +package tests + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "testing" + "time" + + "github.com/entireio/cli/e2e/agents" + "github.com/entireio/cli/e2e/entire" + "github.com/entireio/cli/e2e/testutil" + "github.com/stretchr/testify/require" +) + +func TestCodexResumeRestoredSessionWithSanitizedCompactedHistory(t *testing.T) { + testutil.ForEachAgent(t, 4*time.Minute, func(t *testing.T, s *testutil.RepoState, ctx context.Context) { + if s.Agent.Name() != "codex" { + t.Skip("Codex-only native resume coverage") + } + + mainBranch := testutil.GitOutput(t, s.Dir, "branch", "--show-current") + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Enable entire") + s.Git(t, "checkout", "-b", "feature") + + session := s.StartSession(t, ctx) + codexSession, ok := session.(*agents.CodexSession) + require.True(t, ok, "expected Codex session type") + + s.WaitFor(t, session, s.Agent.PromptPattern(), 30*time.Second) + s.Send(t, session, "create a file at docs/hello.md with a short paragraph about greetings. Do not commit. Do not ask for confirmation.") + s.WaitFor(t, session, s.Agent.PromptPattern(), 90*time.Second) + testutil.AssertFileExists(t, s.Dir, "docs/hello.md") + + rolloutPath := findCodexRollout(t, codexSession.Home()) + sessionID := readCodexSessionID(t, rolloutPath) + appendCompactedEncryptedHistory(t, rolloutPath) + + s.Git(t, "add", ".") + s.Git(t, "commit", "-m", "Add hello doc") + testutil.WaitForSessionIdle(t, s.Dir, 15*time.Second) + testutil.WaitForCheckpoint(t, s, 30*time.Second) + + s.Git(t, "checkout", mainBranch) + + out, err := entire.ResumeWithEnv(s.Dir, "feature", []string{"CODEX_HOME=" + codexSession.Home()}) + require.NoError(t, err, "entire resume failed: %s", out) + require.Contains(t, out, "codex resume "+sessionID) + + resumed, err := s.Agent.(*agents.Codex).ResumeSession(ctx, s.Dir, codexSession.Home(), sessionID) + require.NoError(t, err) + defer resumed.Close() + + content, waitErr := resumed.WaitFor(s.Agent.PromptPattern(), 45*time.Second) + require.NoError(t, waitErr, "resumed Codex session should reach prompt") + require.NotContains(t, content, "invalid_encrypted_content") + }) +} + +func findCodexRollout(t *testing.T, codexHome string) string { + t.Helper() + + matches, err := filepath.Glob(filepath.Join(codexHome, "sessions", "*", "*", "*", "rollout-*.jsonl")) + require.NoError(t, err) + require.Len(t, matches, 1, "expected exactly one Codex rollout in isolated CODEX_HOME") + return matches[0] +} + +func readCodexSessionID(t *testing.T, rolloutPath string) string { + t.Helper() + + data, err := os.ReadFile(rolloutPath) + require.NoError(t, err) + + re := regexp.MustCompile(`(?m)^\{"timestamp":".*","type":"session_meta","payload":\{"id":"([^"]+)"`) + m := re.FindSubmatch(data) + require.Len(t, m, 2, "session_meta id not found in rollout") + return string(m[1]) +} + +func appendCompactedEncryptedHistory(t *testing.T, rolloutPath string) { + t.Helper() + + line := map[string]any{ + "timestamp": "2026-04-08T12:00:00.000Z", + "type": "compacted", + "payload": map[string]any{ + "message": "", + "replacement_history": []map[string]any{ + { + "type": "message", + "role": "user", + "content": []map[string]any{ + {"type": "input_text", "text": "hello"}, + }, + }, + { + "type": "reasoning", + "summary": []map[string]any{{"text": "brief"}}, + "encrypted_content": "REDACTED", + }, + { + "type": "compaction", + "encrypted_content": "REDACTED", + }, + }, + }, + } + + encoded, err := json.Marshal(line) + require.NoError(t, err) + + f, err := os.OpenFile(rolloutPath, os.O_APPEND|os.O_WRONLY, 0) + require.NoError(t, err) + defer f.Close() + + _, err = f.Write(append(encoded, '\n')) + require.NoError(t, err) +}