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
2 changes: 2 additions & 0 deletions cmd/entire/cli/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,8 @@ func scopeTranscriptForCheckpoint(fullTranscript []byte, startOffset int, agentT
return nil
}
return scoped
case agent.AgentTypeCodex:
return transcript.SliceFromLine(fullTranscript, startOffset)
case agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown:
return transcript.SliceFromLine(fullTranscript, startOffset)
}
Expand Down
33 changes: 33 additions & 0 deletions cmd/entire/cli/explain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/strategy"
"github.com/entireio/cli/cmd/entire/cli/summarize"
"github.com/entireio/cli/cmd/entire/cli/testutil"
"github.com/entireio/cli/cmd/entire/cli/trailers"
"github.com/entireio/cli/cmd/entire/cli/transcript"
Expand Down Expand Up @@ -2738,6 +2739,38 @@ func TestScopeTranscriptForCheckpoint_ZeroLinesReturnsAll(t *testing.T) {
}
}

func TestScopeTranscriptForCheckpoint_CodexUsesStoredLineOffsets(t *testing.T) {
t.Parallel()

fullTranscript := []byte(`{"timestamp":"t1","type":"session_meta","payload":{"id":"s1"}}
{"timestamp":"t2","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"developer instructions"}]}}
{"timestamp":"t3","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"# AGENTS.md\ninstructions"}]}}
{"timestamp":"t4","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"first prompt"}]}}
{"timestamp":"t5","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"response to first"}]}}
{"timestamp":"t6","type":"event_msg","payload":{"type":"token_count","input_tokens":10,"output_tokens":1}}
{"timestamp":"t7","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"second prompt"}]}}
{"timestamp":"t8","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"response to second"}]}}
`)

scoped := scopeTranscriptForCheckpoint(fullTranscript, 6, agent.AgentTypeCodex)
entries, err := summarize.BuildCondensedTranscriptFromBytes(scoped, agent.AgentTypeCodex)
if err != nil {
t.Fatalf("failed to build condensed transcript: %v", err)
}

if len(entries) != 2 {
t.Fatalf("expected 2 scoped entries, got %d", len(entries))
}

if entries[0].Type != summarize.EntryTypeUser || entries[0].Content != "second prompt" {
t.Fatalf("expected first entry to be second prompt, got %#v", entries[0])
}

if entries[1].Type != summarize.EntryTypeAssistant || entries[1].Content != "response to second" {
t.Fatalf("expected second entry to be second response, got %#v", entries[1])
}
}

func TestExtractPromptsFromScopedTranscript(t *testing.T) {
// Transcript with 4 lines - 2 user prompts, 2 assistant responses
transcript := []byte(`{"type":"user","uuid":"u1","message":{"content":"First prompt"}}
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ func generateSummary(ctx context.Context, sessionData *ExtractedSessionData, sta
slog.String("error", sliceErr.Error()))
}
scopedTranscript = scoped
case agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown:
case agent.AgentTypeCodex, agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown:
scopedTranscript = transcript.SliceFromLine(sessionData.Transcript, state.CheckpointTranscriptStart)
}

Expand Down
98 changes: 97 additions & 1 deletion cmd/entire/cli/summarize/summarize.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package summarize

import (
"bytes"
"context"
"encoding/json"
"errors"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/entireio/cli/cmd/entire/cli/agent/types"
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/transcript"
"github.com/entireio/cli/cmd/entire/cli/transcript/compact"
)

// GenerateFromTranscript generates a summary from raw transcript bytes.
Expand Down Expand Up @@ -123,6 +125,8 @@ func BuildCondensedTranscriptFromBytes(content []byte, agentType types.AgentType
return buildCondensedTranscriptFromDroid(content)
case agent.AgentTypeOpenCode:
return buildCondensedTranscriptFromOpenCode(content)
case agent.AgentTypeCodex:
return buildCondensedTranscriptFromCodex(content)
case agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeUnknown:
// Claude/cursor format - fall through to shared logic below
}
Expand Down Expand Up @@ -217,6 +221,84 @@ func buildCondensedTranscriptFromOpenCode(content []byte) ([]Entry, error) {
return entries, nil
}

// buildCondensedTranscriptFromCodex converts Codex rollout JSONL into the compact
// transcript format, then reuses the shared transcript condensation logic.
func buildCondensedTranscriptFromCodex(content []byte) ([]Entry, error) {
compacted, err := compact.Compact(content, compact.MetadataFields{
Agent: "codex",
CLIVersion: "summarize",
})
if err != nil {
return nil, fmt.Errorf("failed to compact Codex transcript: %w", err)
}

type compactUserTextBlock struct {
Text string `json:"text"`
}
type compactAssistantBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
}
type compactLine struct {
Type string `json:"type"`
Content json.RawMessage `json:"content"`
}

var entries []Entry
for _, lineBytes := range splitCompactJSONL(compacted) {
var line compactLine
if err := json.Unmarshal(lineBytes, &line); err != nil {
continue
}

switch line.Type {
case transcript.TypeUser:
var blocks []compactUserTextBlock
if err := json.Unmarshal(line.Content, &blocks); err != nil {
continue
}
for _, block := range blocks {
if block.Text != "" {
entries = append(entries, Entry{
Type: EntryTypeUser,
Content: block.Text,
})
}
}
case transcript.TypeAssistant:
var blocks []compactAssistantBlock
if err := json.Unmarshal(line.Content, &blocks); err != nil {
continue
}
for _, block := range blocks {
switch block.Type {
case transcript.ContentTypeText:
if block.Text != "" {
entries = append(entries, Entry{
Type: EntryTypeAssistant,
Content: block.Text,
})
}
case transcript.ContentTypeToolUse:
var input map[string]interface{}
if err := json.Unmarshal(block.Input, &input); err != nil {
input = nil
}
entries = append(entries, Entry{
Type: EntryTypeTool,
ToolName: block.Name,
ToolDetail: extractGenericToolDetail(input),
})
}
}
}
}

return entries, nil
}

// buildCondensedTranscriptFromDroid parses Droid transcript and extracts a condensed view.
func buildCondensedTranscriptFromDroid(content []byte) ([]Entry, error) {
droidLines, _, err := factoryaidroid.ParseDroidTranscriptFromBytes(content, 0)
Expand All @@ -240,14 +322,28 @@ func extractOpenCodeToolDetail(input map[string]interface{}) string {
// extractGenericToolDetail extracts an appropriate detail string from a tool's input/args map.
// Checks common fields in order of preference. Used by Gemini condensation.
func extractGenericToolDetail(input map[string]interface{}) string {
for _, key := range []string{"description", "command", "file_path", "path", "pattern"} {
if input == nil {
return ""
}
for _, key := range []string{"description", "command", "cmd", "file_path", "path", "pattern"} {
if v, ok := input[key].(string); ok && v != "" {
return v
}
}
return ""
}

func splitCompactJSONL(data []byte) [][]byte {
var lines [][]byte
for _, line := range bytes.Split(data, []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) > 0 {
lines = append(lines, line)
}
}
return lines
}

// BuildCondensedTranscript extracts a condensed view of the transcript.
// It processes user prompts, assistant responses, and tool calls into
// a simplified format suitable for LLM summarization.
Expand Down
107 changes: 107 additions & 0 deletions cmd/entire/cli/summarize/summarize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,113 @@ func TestGenerateFromTranscript_NilGenerator(t *testing.T) {
}
}

func TestBuildCondensedTranscriptFromBytes_Codex(t *testing.T) {
t.Parallel()

codexTranscript := []byte(`{"timestamp":"2026-04-01T23:31:27.000Z","type":"session_meta","payload":{"id":"s1"}}
{"timestamp":"2026-04-01T23:31:28.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"create hello.txt"}]}}
{"timestamp":"2026-04-01T23:31:29.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Creating the file now."}]}}
{"timestamp":"2026-04-01T23:31:30.000Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"touch hello.txt\",\"workdir\":\"/repo\"}"}}
{"timestamp":"2026-04-01T23:31:31.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"ok"}}
{"timestamp":"2026-04-01T23:31:32.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done."}]}}
`)

entries, err := BuildCondensedTranscriptFromBytes(codexTranscript, agent.AgentTypeCodex)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if len(entries) != 4 {
t.Fatalf("expected 4 entries, got %d", len(entries))
}

if entries[0].Type != EntryTypeUser || entries[0].Content != "create hello.txt" {
t.Fatalf("unexpected first entry: %#v", entries[0])
}

if entries[1].Type != EntryTypeAssistant || entries[1].Content != "Creating the file now." {
t.Fatalf("unexpected second entry: %#v", entries[1])
}

if entries[2].Type != EntryTypeTool || entries[2].ToolName != "exec_command" {
t.Fatalf("unexpected tool entry: %#v", entries[2])
}

if entries[3].Type != EntryTypeAssistant || entries[3].Content != "Done." {
t.Fatalf("unexpected final entry: %#v", entries[3])
}
}

func TestBuildCondensedTranscriptFromBytes_Codex_CustomToolCall(t *testing.T) {
t.Parallel()

codexTranscript := []byte(`{"timestamp":"t1","type":"session_meta","payload":{"id":"s1"}}
{"timestamp":"t2","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"create hello.txt"}]}}
{"timestamp":"t3","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Creating the file now."}]}}
{"timestamp":"t4","type":"response_item","payload":{"type":"custom_tool_call","status":"completed","call_id":"call_1","name":"apply_patch","input":"*** Begin Patch\n*** Add File: hello.txt\n+Hello World\n*** End Patch\n"}}
{"timestamp":"t5","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_1","output":{"type":"text","text":"Success. Updated: A hello.txt"}}}
{"timestamp":"t6","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done."}]}}
`)

entries, err := BuildCondensedTranscriptFromBytes(codexTranscript, agent.AgentTypeCodex)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if len(entries) != 4 {
t.Fatalf("expected 4 entries, got %d: %#v", len(entries), entries)
}

if entries[0].Type != EntryTypeUser || entries[0].Content != "create hello.txt" {
t.Fatalf("unexpected first entry: %#v", entries[0])
}

if entries[1].Type != EntryTypeAssistant || entries[1].Content != "Creating the file now." {
t.Fatalf("unexpected second entry: %#v", entries[1])
}

if entries[2].Type != EntryTypeTool || entries[2].ToolName != "apply_patch" {
t.Fatalf("expected apply_patch tool entry, got: %#v", entries[2])
}

if entries[3].Type != EntryTypeAssistant || entries[3].Content != "Done." {
t.Fatalf("unexpected final entry: %#v", entries[3])
}
}

func TestBuildCondensedTranscriptFromBytes_Codex_ExecCommandDetail(t *testing.T) {
t.Parallel()

codexTranscript := []byte(`{"timestamp":"t1","type":"session_meta","payload":{"id":"s1"}}
{"timestamp":"t2","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Running command."}]}}
{"timestamp":"t3","type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"ls -la\",\"workdir\":\"/repo\"}"}}
{"timestamp":"t4","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"total 0"}}
`)

entries, err := BuildCondensedTranscriptFromBytes(codexTranscript, agent.AgentTypeCodex)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Find the tool entry
var toolEntry *Entry
for i := range entries {
if entries[i].Type == EntryTypeTool {
toolEntry = &entries[i]
break
}
}
if toolEntry == nil {
t.Fatalf("no tool entry found in entries: %#v", entries)
}
if toolEntry.ToolName != "exec_command" {
t.Fatalf("expected exec_command, got %q", toolEntry.ToolName)
}
if toolEntry.ToolDetail != "ls -la" {
t.Fatalf("expected tool detail 'ls -la', got %q", toolEntry.ToolDetail)
}
}

func TestBuildCondensedTranscriptFromBytes_OpenCodeUserAndAssistant(t *testing.T) {
// OpenCode export JSON format
ocExportJSON := `{
Expand Down
Loading
Loading