package parser import ( "errors" "fmt" "os" "path/filepath" "strings" "testing" "testing/iotest" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.kenn.io/agentsview/internal/testjsonl" ) func runCodexParserTest(t *testing.T, fileName, content string, includeExec bool) (*ParsedSession, []ParsedMessage) { t.Helper() if fileName == "" { fileName = "test.jsonl" } path := createTestFile(t, fileName, content) sess, msgs, err := parseCodexTestSession(t, path, "local", includeExec) require.NoError(t, err) return sess, msgs } // newCodexTestProvider builds a concrete codexProvider so package tests can // exercise the folded parse, discovery, and source-lookup behavior directly // through provider methods now that the package-level ParseCodexSession, // ParseCodexSessionFrom, DiscoverCodexSessions, and FindCodexSourceFile free // functions are gone. func newCodexTestProvider(t *testing.T, roots ...string) *codexProvider { t.Helper() provider, ok := NewProvider(AgentCodex, ProviderConfig{ Roots: roots, Machine: "local", }) require.True(t, ok) cp, ok := provider.(*codexProvider) require.True(t, ok) return cp } // parseCodexTestSession parses a Codex session through the provider-owned // parseSession method, replacing the removed package-level ParseCodexSession. func parseCodexTestSession( t *testing.T, path, machine string, includeExec bool, ) (*ParsedSession, []ParsedMessage, error) { t.Helper() return newCodexTestProvider(t).parseSession(path, machine, includeExec) } // parseCodexTestSessionFrom parses appended Codex lines through the // provider-owned parseSessionFrom method, replacing the removed package-level // ParseCodexSessionFrom. func parseCodexTestSessionFrom( t *testing.T, path string, offset int64, startOrdinal int, includeExec bool, ) ([]ParsedMessage, time.Time, int64, error) { t.Helper() return newCodexTestProvider(t).parseSessionFrom(path, offset, startOrdinal, includeExec) } // discoverCodexTestSessions discovers Codex session paths under root through // the provider source set, returning the legacy DiscoveredFile shape the tests // assert against, replacing the removed DiscoverCodexSessions. func discoverCodexTestSessions(t *testing.T, root string) []DiscoveredFile { t.Helper() provider := newCodexTestProvider(t, root) paths := provider.sources.discoverSessionPaths(root) if len(paths) == 0 { return nil } files := make([]DiscoveredFile, 0, len(paths)) for _, path := range paths { files = append(files, DiscoveredFile{ Path: path, Agent: AgentCodex, }) } return files } // findCodexTestSourceFile resolves a Codex session UUID to a transcript path // through the provider source set, replacing the removed FindCodexSourceFile. func findCodexTestSourceFile(t *testing.T, root, sessionID string) string { t.Helper() return newCodexTestProvider(t, root).sources.findSourceFile(root, sessionID) } func assertToolResultEvents( t *testing.T, got []ParsedToolResultEvent, want []ParsedToolResultEvent, ) { t.Helper() require.Len(t, got, len(want)) for i := range want { assert.Equal(t, want[i].ToolUseID, got[i].ToolUseID, "event %d tool_use_id", i) assert.Equal(t, want[i].AgentID, got[i].AgentID, "event %d agent_id", i) assert.Equal(t, want[i].SubagentSessionID, got[i].SubagentSessionID, "event %d subagent_session_id", i) assert.Equal(t, want[i].Source, got[i].Source, "event %d source", i) assert.Equal(t, want[i].Status, got[i].Status, "event %d status", i) assert.Equal(t, want[i].Content, got[i].Content, "event %d content", i) } } func TestParseCodexSession_Basic(t *testing.T) { content := loadFixture(t, "codex/standard_session.jsonl") sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, "codex:abc-123", sess.ID) assert.Equal(t, "/Users/alice/code/my-api", sess.Cwd) assert.Equal(t, 2, len(msgs)) assertSessionMeta(t, sess, "codex:abc-123", "my_api", AgentCodex) } func TestParseCodexSession_UsesThreadNameFromSessionIndex(t *testing.T) { root := t.TempDir() sessionDir := filepath.Join(root, "sessions", "2026", "06", "11") require.NoError(t, os.MkdirAll(sessionDir, 0o755)) sessionPath := filepath.Join(sessionDir, "rollout-2026-06-11T12-44-06-019eb791-cf7d-75c1-8439-9ed74c1229e1.jsonl") content := loadFixture(t, "codex/standard_session.jsonl") require.NoError(t, os.WriteFile(sessionPath, []byte(content), 0o644)) indexPath := filepath.Join(root, "session_index.jsonl") index := `{"id":"abc-123","thread_name":"Renamed from Codex","updated_at":"2026-06-11T17:34:20.3755243Z"}` + "\n" require.NoError(t, os.WriteFile(indexPath, []byte(index), 0o644)) sess, msgs, err := parseCodexTestSession(t, sessionPath, "local", false) require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, "Renamed from Codex", sess.SessionName) assert.Equal(t, "Add rate limiting", sess.FirstMessage) assert.Len(t, msgs, 2) } func TestParseCodexSession_LeavesSessionNameEmptyWithoutThreadName(t *testing.T) { root := t.TempDir() sessionDir := filepath.Join(root, "sessions", "2026", "06", "11") require.NoError(t, os.MkdirAll(sessionDir, 0o755)) sessionPath := filepath.Join(sessionDir, "rollout-2026-06-11T12-44-06-019eb791-cf7d-75c1-8439-9ed74c1229e1.jsonl") content := loadFixture(t, "codex/standard_session.jsonl") require.NoError(t, os.WriteFile(sessionPath, []byte(content), 0o644)) indexPath := filepath.Join(root, "session_index.jsonl") index := `{"id":"abc-123","updated_at":"2026-06-11T17:34:20.3755243Z"}` + "\n" require.NoError(t, os.WriteFile(indexPath, []byte(index), 0o644)) sess, _, err := parseCodexTestSession(t, sessionPath, "local", false) require.NoError(t, err) require.NotNil(t, sess) assert.Empty(t, sess.SessionName) assert.Equal(t, "Add rate limiting", sess.FirstMessage) } func TestParseCodexSession_UsesThreadNameFromArchivedSessions(t *testing.T) { root := t.TempDir() sessionDir := filepath.Join(root, "archived_sessions") require.NoError(t, os.MkdirAll(sessionDir, 0o755)) sessionPath := filepath.Join(sessionDir, "rollout-2026-06-11T12-44-06-019eb791-cf7d-75c1-8439-9ed74c1229e1.jsonl") content := loadFixture(t, "codex/standard_session.jsonl") require.NoError(t, os.WriteFile(sessionPath, []byte(content), 0o644)) indexPath := filepath.Join(root, "session_index.jsonl") index := `{"id":"abc-123","thread_name":"Archived title","updated_at":"2026-06-11T17:34:20.3755243Z"}` + "\n" require.NoError(t, os.WriteFile(indexPath, []byte(index), 0o644)) sess, _, err := parseCodexTestSession(t, sessionPath, "local", false) require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, "Archived title", sess.SessionName) } func TestParseCodexSession_MtimeIncludesSessionIndex(t *testing.T) { root := t.TempDir() sessionDir := filepath.Join(root, "sessions", "2026", "06", "11") require.NoError(t, os.MkdirAll(sessionDir, 0o755)) sessionPath := filepath.Join(sessionDir, "rollout-2026-06-11T12-44-06-019eb791-cf7d-75c1-8439-9ed74c1229e1.jsonl") content := loadFixture(t, "codex/standard_session.jsonl") require.NoError(t, os.WriteFile(sessionPath, []byte(content), 0o644)) indexPath := filepath.Join(root, "session_index.jsonl") index := `{"id":"abc-123","thread_name":"Original","updated_at":"2026-06-11T17:34:20Z"}` + "\n" require.NoError(t, os.WriteFile(indexPath, []byte(index), 0o644)) sess1, _, err := parseCodexTestSession(t, sessionPath, "local", false) require.NoError(t, err) mtime1 := sess1.File.Mtime // Simulate a rename by rewriting the index after a delay. future := time.Now().Add(2 * time.Second) renamed := `{"id":"abc-123","thread_name":"Renamed","updated_at":"2026-06-11T18:00:00Z"}` + "\n" require.NoError(t, os.WriteFile(indexPath, []byte(renamed), 0o644)) require.NoError(t, os.Chtimes(indexPath, future, future)) sess2, _, err := parseCodexTestSession(t, sessionPath, "local", false) require.NoError(t, err) assert.Greater(t, sess2.File.Mtime, mtime1, "mtime must advance when session_index.jsonl is updated") assert.Equal(t, "Renamed", sess2.SessionName) } func TestEvictCodexSessionIndexCache(t *testing.T) { root := t.TempDir() indexPath := filepath.Join(root, "session_index.jsonl") index := `{"id":"abc-123","thread_name":"Cached title","updated_at":"2026-06-11T17:34:20Z"}` + "\n" require.NoError(t, os.WriteFile(indexPath, []byte(index), 0o644)) titles := CodexSessionIndexTitles(indexPath) require.Equal(t, "Cached title", titles["abc-123"]) codexSessionIndexCache.mu.Lock() _, cached := codexSessionIndexCache.entries[indexPath] codexSessionIndexCache.mu.Unlock() require.True(t, cached, "session index should be cached after read") EvictCodexSessionIndex(indexPath) codexSessionIndexCache.mu.Lock() _, cached = codexSessionIndexCache.entries[indexPath] codexSessionIndexCache.mu.Unlock() assert.False(t, cached, "session index cache entry should be evicted") otherPath := filepath.Join(root, "other-session_index.jsonl") require.NoError(t, os.WriteFile(otherPath, []byte(index), 0o644)) CodexSessionIndexTitles(indexPath) CodexSessionIndexTitles(otherPath) EvictAllCodexSessionIndexes() codexSessionIndexCache.mu.Lock() remaining := len(codexSessionIndexCache.entries) codexSessionIndexCache.mu.Unlock() assert.Zero(t, remaining, "all session index cache entries should be evicted") } func TestParseCodexSession_PreservesAssistantBlockquotes(t *testing.T) { content := loadFixture(t, "codex/blockquotes_session.jsonl") sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, "codex:quote-123", sess.ID) require.Len(t, msgs, 2) assert.Equal(t, RoleUser, msgs[0].Role) assert.Equal(t, "blablabla?", msgs[0].Content) assert.Equal(t, RoleAssistant, msgs[1].Role) assert.Equal(t, "blabla1\n\n> blabla2\n\nblabla3\n\n> blabla4\n\nblabla5", msgs[1].Content, ) } func TestParseCodexSession_ExecOriginator(t *testing.T) { execContent := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "codex_exec", tsEarly), testjsonl.CodexMsgJSON("user", "test", tsEarlyS1), ) t.Run("includes exec originator by default", func(t *testing.T) { sess, msgs := runCodexParserTest(t, "test.jsonl", execContent, false) require.NotNil(t, sess) assert.Equal(t, "codex:abc", sess.ID) assert.Equal(t, 1, len(msgs)) }) t.Run("includes exec when requested", func(t *testing.T) { sess, msgs := runCodexParserTest(t, "test.jsonl", execContent, true) require.NotNil(t, sess) assert.Equal(t, "codex:abc", sess.ID) assert.Equal(t, 1, len(msgs)) }) } func TestCodexInsertMessage_PreservesChronologyOnSameOrdinal(t *testing.T) { b := newCodexSessionBuilder(false) b.messages = []ParsedMessage{{ Ordinal: 2, Role: RoleAssistant, Content: "later assistant message", Timestamp: parseTimestamp("2024-01-01T10:01:06Z"), }} idx := b.insertMessage(ParsedMessage{ Ordinal: 2, Role: RoleUser, Content: "earlier orphan notification", Timestamp: parseTimestamp("2024-01-01T10:01:05Z"), }) assert.Equal(t, 0, idx) b.normalizeOrdinals() require.Len(t, b.messages, 2) assert.Equal(t, "earlier orphan notification", b.messages[0].Content) assert.Equal(t, "later assistant message", b.messages[1].Content) assert.Equal(t, 0, b.messages[0].Ordinal) assert.Equal(t, 1, b.messages[1].Ordinal) } func TestParseCodexSession_FunctionCalls(t *testing.T) { t.Run("function calls", func(t *testing.T) { content := loadFixture(t, "codex/function_calls.jsonl") sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, "codex:fc-1", sess.ID) assert.Equal(t, 3, len(msgs)) assert.Equal(t, RoleUser, msgs[0].Role) assert.False(t, msgs[0].HasToolUse) assert.Equal(t, RoleAssistant, msgs[1].Role) assert.True(t, msgs[1].HasToolUse) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ToolName: "shell_command", Category: "Bash"}}) assert.Equal(t, "[Bash: Running tests]", msgs[1].Content) assert.True(t, msgs[2].HasToolUse) assertToolCalls(t, msgs[2].ToolCalls, []ParsedToolCall{{ToolName: "apply_patch", Category: "Edit"}}) for i, m := range msgs { assert.Equal(t, i, m.Ordinal) } }) t.Run("exec_command arguments include command detail", func(t *testing.T) { content := loadFixture(t, "codex/fc_args_1.jsonl") _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, "[Bash]\n$ rg --files", msgs[1].Content) assert.Equal(t, `{"cmd":"rg --files","workdir":"/tmp"}`, msgs[1].ToolCalls[0].InputJSON) }) t.Run("multi-line command truncated to first line", func(t *testing.T) { multiLineCmd := "cat > file.toml <<'EOF'\n[package]\nname = \"foo\"\nEOF" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-ml", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "create file", tsEarlyS1), testjsonl.CodexFunctionCallArgsJSON("exec_command", map[string]any{ "cmd": multiLineCmd, }, tsEarlyS5), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, "[Bash]\n$ cat > file.toml <<'EOF'", msgs[1].Content) assert.Contains(t, msgs[1].ToolCalls[0].InputJSON, "cmd") assert.Contains(t, msgs[1].ToolCalls[0].InputJSON, "[package]") }) t.Run("apply_patch arguments summarize edited files", func(t *testing.T) { content := loadFixture(t, "codex/fc_args_2.jsonl") _, msgs := runCodexParserTest(t, "test.jsonl", content, false) want := "[Edit: internal/parser/codex.go (+1 more)]\ninternal/parser/codex.go\ninternal/parser/parser_test.go" assert.Equal(t, want, msgs[1].Content) assert.NotEmpty(t, msgs[1].ToolCalls[0].InputJSON) assert.Contains(t, msgs[1].ToolCalls[0].InputJSON, "Begin Patch") }) t.Run("custom_tool_call apply_patch input and output", func(t *testing.T) { call := `{"timestamp":"2026-07-08T03:20:43.339Z","type":"response_item","payload":{"type":"custom_tool_call","status":"completed","call_id":"call_abc","name":"apply_patch","input":"*** Begin Patch\n*** Add File: infra/scripts/with-resolved-images.sh\n+#!/bin/sh\n*** End Patch"}}` patchEnd := `{"timestamp":"2026-07-08T03:20:43.356Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"call_abc","stdout":"Success. Updated the following files:\nA infra/scripts/with-resolved-images.sh\n","stderr":"","success":true}}` output := `{"timestamp":"2026-07-08T03:20:43.376Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_abc","output":"Exit code: 0\nWall time: 0 seconds\nOutput:\nSuccess. Updated the following files:\nA infra/scripts/with-resolved-images.sh\n"}}` content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-custom-tool", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "apply the patch", tsEarlyS1), call, patchEnd, output, ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.Len(t, msgs, 2) assert.Equal(t, RoleAssistant, msgs[1].Role) assert.True(t, msgs[1].HasToolUse) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ ToolUseID: "call_abc", ToolName: "apply_patch", Category: "Edit", }}) tc := msgs[1].ToolCalls[0] assert.Contains(t, tc.InputJSON, "Begin Patch") assertToolResultEvents(t, tc.ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_abc", Source: "custom_tool_call_output", Status: "completed", Content: "Exit code: 0\nWall time: 0 seconds\nOutput:\nSuccess. Updated the following files:\nA infra/scripts/with-resolved-images.sh", }}) }) t.Run("unsupported response_item subtype is ignored", func(t *testing.T) { unsupported := `{"timestamp":"2026-07-08T03:20:43.339Z","type":"response_item","payload":{"type":"custom_tool_callish","call_id":"call_abc","name":"apply_patch","input":"*** Begin Patch\n*** End Patch"}}` content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-unsupported-response-item", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "apply the patch", tsEarlyS1), unsupported, ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.Len(t, msgs, 1) assert.False(t, msgs[0].HasToolUse) }) t.Run("custom_tool_call_output requests full parse", func(t *testing.T) { line := `{"timestamp":"2026-07-08T03:20:43.376Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_abc","output":"Exit code: 0\nWall time: 0 seconds\nOutput:\nSuccess."}}` assert.True(t, codexIncrementalNeedsFullParse(line)) }) t.Run("write_stdin formats with session and chars", func(t *testing.T) { content := loadFixture(t, "codex/fc_stdin.jsonl") _, msgs := runCodexParserTest(t, "test.jsonl", content, false) want := "[Bash: stdin -> sess-42]\nyes\\n" assert.Equal(t, want, msgs[1].Content) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ToolName: "write_stdin", Category: "Bash"}}) }) t.Run("Agent function call normalizes to Task category", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-agent", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "explore code", tsEarlyS1), testjsonl.CodexFunctionCallArgsJSON("Agent", map[string]any{ "description": "explore codebase", "subagent_type": "Explore", }, tsEarlyS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, "codex:fc-agent", sess.ID) assert.Equal(t, 2, len(msgs)) assert.Contains(t, msgs[1].Content, "[Task: explore codebase (Explore)]") assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ToolName: "Agent", Category: "Task"}}) }) t.Run("spawn_agent links child session and wait output becomes tool result", func(t *testing.T) { childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" waitSummary := "Exit code: `1`\n\nFull output:\n```text\nTraceback...\n```" notification := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" + "" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "Run the compile smoke test", }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate), testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{ "ids": []string{childID}, "timeout_ms": 600000, }, tsLateS5), testjsonl.CodexFunctionCallOutputJSON("call_wait", "{\"status\":{\""+childID+"\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}}", "2024-01-01T10:01:06Z"), testjsonl.CodexMsgJSON("user", notification, "2024-01-01T10:01:07Z"), testjsonl.CodexMsgJSON("assistant", "continuing", "2024-01-01T10:01:08Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 4, len(msgs)) assert.Equal(t, RoleAssistant, msgs[1].Role) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ ToolUseID: "call_spawn", ToolName: "spawn_agent", Category: "Task", SubagentSessionID: "codex:" + childID, }}) assert.Equal(t, RoleAssistant, msgs[2].Role) assertToolCalls(t, msgs[2].ToolCalls, []ParsedToolCall{{ ToolUseID: "call_wait", ToolName: "wait", Category: "Other", }}) assertToolResultEvents(t, msgs[2].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_wait", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "wait_output", Status: "completed", Content: waitSummary, }}) assert.Equal(t, RoleAssistant, msgs[3].Role) assert.Equal(t, "continuing", msgs[3].Content) }) t.Run("subagent notification without wait result falls back to spawn_agent output", func(t *testing.T) { childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" summary := "Exit code: `1`\n\nFull output:\n```text\nTraceback...\n```" notification := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" + "" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-notify", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "Run the compile smoke test", }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate), testjsonl.CodexMsgJSON("user", notification, tsLateS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 2, len(msgs)) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ ToolUseID: "call_spawn", ToolName: "spawn_agent", Category: "Task", SubagentSessionID: "codex:" + childID, }}) assertToolResultEvents(t, msgs[1].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_spawn", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "subagent_notification", Status: "completed", Content: summary, }}) }) t.Run("no-wait fallback preserves chronology before later messages", func(t *testing.T) { childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" summary := "Exit code: `1`\n\nFull output:\n```text\nTraceback...\n```" notification := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" + "" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-notify-order", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "Run the compile smoke test", }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate), testjsonl.CodexMsgJSON("user", notification, tsLateS5), testjsonl.CodexMsgJSON("assistant", "continuing", "2024-01-01T10:01:06Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 3, len(msgs)) assertToolResultEvents(t, msgs[1].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_spawn", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "subagent_notification", Status: "completed", Content: summary, }}) assert.Equal(t, RoleAssistant, msgs[2].Role) assert.Equal(t, "continuing", msgs[2].Content) }) t.Run("codex app wait_agent and agent_path notifications link child session", func(t *testing.T) { childID := "019df406-3bd3-7343-b3d8-f8d5996c428b" summary := "Inspected only. I did not modify files." notification := "\n" + "{\"agent_path\":\"" + childID + "\",\"status\":{\"completed\":\"" + summary + "\"}}\n" + "" spawnEnd := "{\"timestamp\":\"2024-01-01T10:01:05Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"collab_agent_spawn_end\",\"call_id\":\"call_spawn\",\"sender_thread_id\":\"parent-thread\",\"new_thread_id\":\"" + childID + "\",\"new_agent_nickname\":\"Einstein\",\"new_agent_role\":\"explorer\",\"status\":\"pending_init\"}}" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-codex-app-subagent", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "explorer", "message": "Inspect the code", }, tsEarlyS5), spawnEnd, testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Einstein"}`, tsLate), testjsonl.CodexFunctionCallWithCallIDJSON("wait_agent", "call_wait", map[string]any{ "targets": []string{childID}, "timeout_ms": 10000, }, tsLateS5), testjsonl.CodexMsgJSON("user", notification, "2024-01-01T10:01:07Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 3, len(msgs)) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ ToolUseID: "call_spawn", ToolName: "spawn_agent", Category: "Task", SubagentSessionID: "codex:" + childID, }}) assertToolCalls(t, msgs[2].ToolCalls, []ParsedToolCall{{ ToolUseID: "call_wait", ToolName: "wait_agent", Category: "Other", }}) assertToolResultEvents(t, msgs[2].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_wait", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "subagent_notification", Status: "completed", Content: summary, }}) }) t.Run("codex app pending notification waits for later wait_agent binding", func(t *testing.T) { childID := "019df406-3bd3-7343-b3d8-f8d5996c428b" summary := "Inspected before wait_agent existed." notification := "\n" + "{\"agent_path\":\"" + childID + "\",\"status\":{\"completed\":\"" + summary + "\"}}\n" + "" spawnEnd := "{\"timestamp\":\"2024-01-01T10:01:05Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"collab_agent_spawn_end\",\"call_id\":\"call_spawn\",\"sender_thread_id\":\"parent-thread\",\"new_thread_id\":\"" + childID + "\",\"new_agent_nickname\":\"Einstein\",\"new_agent_role\":\"explorer\",\"status\":\"pending_init\"}}" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-codex-app-subagent-pending", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "explorer", "message": "Inspect the code", }, tsEarlyS5), spawnEnd, testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Einstein"}`, tsLate), testjsonl.CodexMsgJSON("user", notification, "2024-01-01T10:01:07Z"), testjsonl.CodexFunctionCallWithCallIDJSON("wait_agent", "call_wait", map[string]any{ "targets": []string{childID}, "timeout_ms": 10000, }, "2024-01-01T10:01:08Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 3, len(msgs)) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ ToolUseID: "call_spawn", ToolName: "spawn_agent", Category: "Task", SubagentSessionID: "codex:" + childID, }}) assert.Empty(t, msgs[1].ToolCalls[0].ResultEvents) assertToolCalls(t, msgs[2].ToolCalls, []ParsedToolCall{{ ToolUseID: "call_wait", ToolName: "wait_agent", Category: "Other", }}) assertToolResultEvents(t, msgs[2].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_wait", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "subagent_notification", Status: "completed", Content: summary, }}) }) t.Run("duplicate pending notification preserves earliest chronology", func(t *testing.T) { childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" summary := "Exit code: `1`\n\nFull output:\n```text\nTraceback...\n```" notification := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" + "" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-notify-dupe-order", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "Run the compile smoke test", }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate), testjsonl.CodexMsgJSON("user", notification, tsLateS5), testjsonl.CodexMsgJSON("assistant", "continuing", "2024-01-01T10:01:06Z"), testjsonl.CodexMsgJSON("user", notification, "2024-01-01T10:01:07Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 3, len(msgs)) assertToolResultEvents(t, msgs[1].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_spawn", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "subagent_notification", Status: "completed", Content: summary, }}) assert.Equal(t, RoleAssistant, msgs[2].Role) assert.Equal(t, "continuing", msgs[2].Content) }) t.Run("running subagent notification does not suppress later completion", func(t *testing.T) { childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" running := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"running\":\"Still working\"}}\n" + "" completed := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" + "" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-running", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "Run the compile smoke test", }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate), testjsonl.CodexMsgJSON("user", running, tsLateS5), testjsonl.CodexMsgJSON("user", completed, "2024-01-01T10:01:06Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 2, len(msgs)) assertToolResultEvents(t, msgs[1].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{ { ToolUseID: "call_spawn", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "subagent_notification", Status: "running", Content: "Still working", }, { ToolUseID: "call_spawn", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "subagent_notification", Status: "completed", Content: "Finished successfully", }, }) }) t.Run("notification after wait binds to wait call", func(t *testing.T) { childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" completed := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" + "" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-wait-bind", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "Run the compile smoke test", }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate), testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{ "ids": []string{childID}, }, tsLateS5), testjsonl.CodexMsgJSON("user", completed, "2024-01-01T10:01:06Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 3, len(msgs)) assertToolCalls(t, msgs[2].ToolCalls, []ParsedToolCall{{ ToolUseID: "call_wait", ToolName: "wait", Category: "Other", }}) assertToolResultEvents(t, msgs[2].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_wait", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "subagent_notification", Status: "completed", Content: "Finished successfully", }}) }) t.Run("notification before wait binds to later wait call", func(t *testing.T) { childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" completed := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" + "" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-wait-rebind", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "Run the compile smoke test", }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate), testjsonl.CodexMsgJSON("user", completed, tsLateS5), testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{ "ids": []string{childID}, }, "2024-01-01T10:01:06Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 3, len(msgs)) assertToolResultEvents(t, msgs[2].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_wait", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "subagent_notification", Status: "completed", Content: "Finished successfully", }}) }) t.Run("late spawn output does not override wait binding", func(t *testing.T) { childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" completed := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" + "" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-late-spawn-output", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "Run the compile smoke test", }, tsEarlyS5), testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{ "ids": []string{childID}, }, tsLate), testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLateS5), testjsonl.CodexMsgJSON("user", completed, "2024-01-01T10:01:06Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 3, len(msgs)) assertToolResultEvents(t, msgs[2].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_wait", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "subagent_notification", Status: "completed", Content: "Finished successfully", }}) }) t.Run("wait output does not duplicate terminal notification result", func(t *testing.T) { childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" completed := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" + "" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-wait-dedupe", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "Run the compile smoke test", }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate), testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{ "ids": []string{childID}, }, tsLateS5), testjsonl.CodexMsgJSON("user", completed, "2024-01-01T10:01:06Z"), testjsonl.CodexFunctionCallOutputJSON("call_wait", "{\"status\":{\""+childID+"\":{\"completed\":\"Finished successfully\"}}}", "2024-01-01T10:01:07Z", ), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 3, len(msgs)) assertToolResultEvents(t, msgs[2].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_wait", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "subagent_notification", Status: "completed", Content: "Finished successfully", }}) }) t.Run("mixed wait status preserves later completion for running agent", func(t *testing.T) { completedID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" runningID := "019c9c96-6ee7-77c0-ba4c-380f844289d6" laterCompleted := "\n" + "{\"agent_id\":\"" + runningID + "\",\"status\":{\"completed\":\"Second agent finished\"}}\n" + "" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-mixed-wait", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run child agents", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{ "ids": []string{completedID, runningID}, }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_wait", "{\"status\":{\""+completedID+"\":{\"completed\":\"First agent finished\"},\""+runningID+"\":{\"running\":\"Still working\"}}}", tsLate, ), testjsonl.CodexMsgJSON("user", laterCompleted, tsLateS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 2, len(msgs)) assertToolResultEvents(t, msgs[1].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{ { ToolUseID: "call_wait", AgentID: completedID, SubagentSessionID: "codex:" + completedID, Source: "wait_output", Status: "completed", Content: "First agent finished", }, { ToolUseID: "call_wait", AgentID: runningID, SubagentSessionID: "codex:" + runningID, Source: "wait_output", Status: "running", Content: "Still working", }, { ToolUseID: "call_wait", AgentID: runningID, SubagentSessionID: "codex:" + runningID, Source: "subagent_notification", Status: "completed", Content: "Second agent finished", }, }) }) t.Run("running-only wait output is preserved as a result event", func(t *testing.T) { childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-running-wait", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run child agent", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{ "ids": []string{childID}, }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_wait", "{\"status\":{\""+childID+"\":{\"running\":\"Still working\"}}}", tsLate, ), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 2, len(msgs)) assertToolResultEvents(t, msgs[1].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{{ ToolUseID: "call_wait", AgentID: childID, SubagentSessionID: "codex:" + childID, Source: "wait_output", Status: "running", Content: "Still working", }}) }) t.Run("wait result events preserve JSON order for multiple agents", func(t *testing.T) { firstID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" secondID := "019c9c96-6ee7-77c0-ba4c-380f844289d6" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-order", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run child agents", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{ "ids": []string{firstID, secondID}, }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_wait", "{\"status\":{\""+secondID+"\":{\"completed\":\"Second agent finished\"},\""+firstID+"\":{\"completed\":\"First agent finished\"}}}", tsLate, ), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 2, len(msgs)) assertToolResultEvents(t, msgs[1].ToolCalls[0].ResultEvents, []ParsedToolResultEvent{ { ToolUseID: "call_wait", AgentID: secondID, SubagentSessionID: "codex:" + secondID, Source: "wait_output", Status: "completed", Content: "Second agent finished", }, { ToolUseID: "call_wait", AgentID: firstID, SubagentSessionID: "codex:" + firstID, Source: "wait_output", Status: "completed", Content: "First agent finished", }, }) }) t.Run("orphaned terminal notifications dedupe", func(t *testing.T) { childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" completed := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" + "" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-subagent-orphan", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", completed, tsEarlyS1), testjsonl.CodexMsgJSON("user", completed, tsEarlyS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 1, len(msgs)) assert.Equal(t, "Finished successfully", msgs[0].Content) }) t.Run("function call no name skipped", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-2", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), testjsonl.CodexFunctionCallJSON("", "", tsEarlyS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, "codex:fc-2", sess.ID) assert.Equal(t, 1, len(msgs)) }) t.Run("mixed content and function calls", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-3", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "Fix it", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "Looking at it", tsEarlyS5), testjsonl.CodexFunctionCallJSON("shell_command", "Running rg", tsLate), testjsonl.CodexMsgJSON("assistant", "Found the issue", tsLateS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, "codex:fc-3", sess.ID) assert.Equal(t, 4, len(msgs)) for i, m := range msgs { assert.Equal(t, i, m.Ordinal) assert.Equal(t, i == 2, m.HasToolUse) } }) t.Run("function call without summary", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-4", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "do it", tsEarlyS1), testjsonl.CodexFunctionCallJSON("exec_command", "", tsEarlyS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, "codex:fc-4", sess.ID) assert.Equal(t, 2, len(msgs)) assert.Equal(t, "[Bash]", msgs[1].Content) }) t.Run("empty arguments falls through to input", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-empty-args", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run command", tsEarlyS1), testjsonl.CodexFunctionCallFieldsJSON("exec_command", map[string]any{}, `{"cmd":"ls -la"}`, tsEarlyS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, "codex:fc-empty-args", sess.ID) assert.Equal(t, 2, len(msgs)) assert.Equal(t, "[Bash]\n$ ls -la", msgs[1].Content) }) t.Run("empty array arguments falls through to input", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("fc-empty-arr", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run command", tsEarlyS1), testjsonl.CodexFunctionCallFieldsJSON("exec_command", []any{}, `{"cmd":"echo hello"}`, tsEarlyS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, "codex:fc-empty-arr", sess.ID) assert.Equal(t, 2, len(msgs)) assert.Equal(t, "[Bash]\n$ echo hello", msgs[1].Content) }) } func TestParseCodexSession_InputJSON(t *testing.T) { t.Run("object arguments populates InputJSON", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("ij-1", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "do it", tsEarlyS1), testjsonl.CodexFunctionCallArgsJSON("shell_command", map[string]any{ "cmd": "ls -la", }, tsEarlyS5), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ ToolName: "shell_command", Category: "Bash", InputJSON: `{"cmd":"ls -la"}`, }}) }) t.Run("string-encoded JSON arguments", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("ij-2", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "do it", tsEarlyS1), testjsonl.CodexFunctionCallArgsJSON("exec_command", `{"cmd":"rg foo","workdir":"/tmp"}`, tsEarlyS5), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ ToolName: "exec_command", Category: "Bash", InputJSON: `{"cmd":"rg foo","workdir":"/tmp"}`, }}) }) t.Run("non-JSON string arguments preserved", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("ij-3", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "do it", tsEarlyS1), testjsonl.CodexFunctionCallArgsJSON("shell_command", "echo hello world", tsEarlyS5), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, "echo hello world", msgs[1].ToolCalls[0].InputJSON) }) t.Run("input field used when arguments empty", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("ij-4", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run", tsEarlyS1), testjsonl.CodexFunctionCallFieldsJSON("exec_command", map[string]any{}, `{"cmd":"echo hi"}`, tsEarlyS5), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ ToolName: "exec_command", Category: "Bash", InputJSON: `{"cmd":"echo hi"}`, }}) }) t.Run("string-encoded empty JSON falls through to input", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("ij-str-empty", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "run", tsEarlyS1), testjsonl.CodexFunctionCallFieldsJSON("exec_command", `{}`, `{"cmd":"echo fallback"}`, tsEarlyS5), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ ToolName: "exec_command", Category: "Bash", InputJSON: `{"cmd":"echo fallback"}`, }}) }) t.Run("no arguments yields empty InputJSON", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("ij-5", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "do it", tsEarlyS1), testjsonl.CodexFunctionCallJSON("exec_command", "", tsEarlyS5), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Empty(t, msgs[1].ToolCalls[0].InputJSON) }) } func TestParseCodexSession_TurnContextModel(t *testing.T) { t.Run("model from turn_context applied to subsequent messages", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("m-1", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextJSON("gpt-5-codex", tsEarlyS1), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "hi there", tsEarlyS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 2, len(msgs)) assert.Equal(t, "gpt-5-codex", msgs[0].Model) assert.Equal(t, "gpt-5-codex", msgs[1].Model) }) t.Run("model changes across turns", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("m-2", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextJSON("gpt-5-codex", tsEarlyS1), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "hi", tsEarlyS5), testjsonl.CodexTurnContextJSON("o3-pro", tsLate), testjsonl.CodexMsgJSON("user", "think harder", tsLate), testjsonl.CodexMsgJSON("assistant", "deep thought", tsLateS5), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, 4, len(msgs)) assert.Equal(t, "gpt-5-codex", msgs[0].Model) assert.Equal(t, "gpt-5-codex", msgs[1].Model) assert.Equal(t, "o3-pro", msgs[2].Model) assert.Equal(t, "o3-pro", msgs[3].Model) }) t.Run("empty model in turn_context clears previous model", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("m-4", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextJSON("gpt-5-codex", tsEarlyS1), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "hi", tsEarlyS5), testjsonl.CodexTurnContextJSON("", tsLate), testjsonl.CodexMsgJSON("user", "follow up", tsLate), testjsonl.CodexMsgJSON("assistant", "reply", tsLateS5), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, 4, len(msgs)) assert.Equal(t, "gpt-5-codex", msgs[0].Model) assert.Equal(t, "gpt-5-codex", msgs[1].Model) assert.Empty(t, msgs[2].Model) assert.Empty(t, msgs[3].Model) }) t.Run("no turn_context leaves model empty", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("m-3", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "hi", tsEarlyS5), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, 2, len(msgs)) assert.Empty(t, msgs[0].Model) assert.Empty(t, msgs[1].Model) }) } func TestParseCodexSession_TokenUsage(t *testing.T) { t.Run("token_count attached to assistant message", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("tu-1", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextJSON("gpt-5.4", tsEarlyS1), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "hi there", tsEarlyS5), testjsonl.CodexTokenCountJSON(tsEarlyS5, 10000, 500, 6000), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) require.Len(t, msgs, 2) // User message has no usage. assert.Empty(t, msgs[0].TokenUsage) // Assistant message has normalized usage. Codex reports // input_tokens=10000 as the full input (cached included); // after normalization the stored input_tokens is the // uncached remainder (10000-6000=4000). assert.NotEmpty(t, msgs[1].TokenUsage) assert.Contains(t, string(msgs[1].TokenUsage), `"input_tokens":4000`) assert.Contains(t, string(msgs[1].TokenUsage), `"output_tokens":500`) assert.Contains(t, string(msgs[1].TokenUsage), `"cache_read_input_tokens":6000`) assert.Equal(t, 500, msgs[1].OutputTokens) assert.Equal(t, 10000, msgs[1].ContextTokens) // 4000+6000 assert.True(t, msgs[1].HasOutputTokens) assert.True(t, msgs[1].HasContextTokens) // Session-level accumulation. assert.True(t, sess.HasTotalOutputTokens) assert.Equal(t, 500, sess.TotalOutputTokens) assert.True(t, sess.HasPeakContextTokens) assert.Equal(t, 10000, sess.PeakContextTokens) }) t.Run("duplicate token_count events deduplicated", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("tu-2", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextJSON("gpt-5.4", tsEarlyS1), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "hi", tsEarlyS5), testjsonl.CodexTokenCountJSON(tsEarlyS5, 10000, 500, 6000), // Streaming duplicates. testjsonl.CodexTokenCountJSON(tsEarlyS5, 10000, 500, 6000), testjsonl.CodexTokenCountJSON(tsEarlyS5, 10000, 500, 6000), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.Len(t, msgs, 2) assert.NotEmpty(t, msgs[1].TokenUsage) assert.Equal(t, 500, msgs[1].OutputTokens) }) t.Run("multiple turns get separate usage", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("tu-3", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextJSON("gpt-5.4", tsEarlyS1), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "hi", tsEarlyS5), testjsonl.CodexTokenCountJSON(tsEarlyS5, 10000, 500, 6000), testjsonl.CodexMsgJSON("user", "think more", tsLate), testjsonl.CodexMsgJSON("assistant", "deep thought", tsLateS5), testjsonl.CodexTokenCountJSON(tsLateS5, 20000, 800, 12000), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.Len(t, msgs, 4) // First assistant msg (10000 total, 6000 cached). assert.Equal(t, 500, msgs[1].OutputTokens) assert.Equal(t, 10000, msgs[1].ContextTokens) // Second assistant msg (20000 total, 12000 cached). assert.Equal(t, 800, msgs[3].OutputTokens) assert.Equal(t, 20000, msgs[3].ContextTokens) // Session totals. assert.Equal(t, 1300, sess.TotalOutputTokens) assert.Equal(t, 20000, sess.PeakContextTokens) }) t.Run("multiple API calls in one turn", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("tu-5", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextJSON("gpt-5.4", tsEarlyS1), testjsonl.CodexMsgJSON("user", "do stuff", tsEarlyS1), // First API call: assistant + function call. testjsonl.CodexMsgJSON("assistant", "let me check", tsEarlyS5), testjsonl.CodexFunctionCallJSON("exec_command", "ls", tsEarlyS5), testjsonl.CodexTokenCountJSON(tsEarlyS5, 10000, 300, 6000), // Second API call after tool output. testjsonl.CodexMsgJSON("assistant", "here is the result", tsLate), testjsonl.CodexTokenCountJSON(tsLate, 15000, 400, 10000), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) // First token_count attaches to function_call (last // assistant msg before it). assert.Equal(t, 300, msgs[2].OutputTokens) assert.Empty(t, msgs[1].TokenUsage) // Second token_count attaches to second assistant msg. assert.Equal(t, 400, msgs[3].OutputTokens) }) t.Run("no token_count leaves usage empty", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("tu-4", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "hi", tsEarlyS5), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.Len(t, msgs, 2) assert.Empty(t, msgs[1].TokenUsage) assert.Equal(t, 0, msgs[1].OutputTokens) }) } // testUUIDv7 builds a syntactically valid UUIDv7 whose embedded // timestamp is the given unix-millisecond value. func testUUIDv7(ms int64, seq byte) string { h := fmt.Sprintf("%012x", ms) return fmt.Sprintf( "%s-%s-7000-8000-0000000000%02x", h[:8], h[8:12], seq, ) } func TestParseCodexSession_ForkedSessionSkipsReplayedHistory(t *testing.T) { // `codex fork` replays the parent's history — its session_meta, // turns, messages and token_count events — into the top of the new // rollout with re-stamped envelope timestamps, so the same usage // lives in two files and was counted twice (#643). Turn ids are // UUIDv7 values minted when the turn originally ran, which is what // locates the replay/genuine boundary. forkCreatedMs := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC).UnixMilli() // == tsEarly forkID := testUUIDv7(forkCreatedMs, 1) parentTurnID := testUUIDv7(forkCreatedMs-3600_000, 2) // minted an hour earlier genuineTurnID := testUUIDv7(forkCreatedMs+1000, 3) t.Run("replayed history is dropped, genuine turns kept", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexForkedSessionMetaJSON(forkID, "parent-1", "/tmp", "user", tsEarly), // Replayed parent history (all re-stamped at fork creation): testjsonl.CodexSessionMetaJSON("parent-1", "/other-project", "user", tsEarly), testjsonl.CodexTurnContextWithIDJSON("gpt-5.4", parentTurnID, tsEarly), testjsonl.CodexMsgJSON("user", "replayed question", tsEarly), testjsonl.CodexMsgJSON("assistant", "replayed answer", tsEarly), testjsonl.CodexTokenCountJSON(tsEarly, 50_000, 9_000, 0), // A replayed turn from before Codex stamped turn ids: testjsonl.CodexTurnContextJSON("gpt-5.3", tsEarly), testjsonl.CodexMsgJSON("assistant", "older replayed answer", tsEarly), // The fork's own first turn: testjsonl.CodexTurnContextWithIDJSON("gpt-5.5", genuineTurnID, tsEarlyS1), testjsonl.CodexMsgJSON("user", "genuine question", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "genuine answer", tsEarlyS5), testjsonl.CodexTokenCountJSON(tsEarlyS5, 10_000, 500, 6_000), ) sess, msgs := runCodexParserTest(t, "fork.jsonl", content, false) require.NotNil(t, sess) // The fork keeps its own identity — the replayed parent // session_meta must not overwrite the id. assert.Equal(t, "codex:"+forkID, sess.ID) require.Len(t, msgs, 2) assert.Equal(t, "genuine question", msgs[0].Content) assert.Equal(t, "genuine answer", msgs[1].Content) assert.Equal(t, "gpt-5.5", msgs[1].Model) // Usage comes only from the genuine turn: 9,000 replayed // output tokens must not be re-billed to the fork. assert.Equal(t, 500, sess.TotalOutputTokens) assert.Equal(t, 10_000, sess.PeakContextTokens) }) t.Run("fork with no genuine turns yields no messages", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexForkedSessionMetaJSON(forkID, "parent-1", "/tmp", "user", tsEarly), testjsonl.CodexSessionMetaJSON("parent-1", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextWithIDJSON("gpt-5.4", parentTurnID, tsEarly), testjsonl.CodexMsgJSON("user", "replayed question", tsEarly), testjsonl.CodexMsgJSON("assistant", "replayed answer", tsEarly), testjsonl.CodexTokenCountJSON(tsEarly, 50_000, 9_000, 0), ) sess, msgs := runCodexParserTest(t, "fork.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, "codex:"+forkID, sess.ID) assert.Empty(t, msgs) assert.Equal(t, 0, sess.TotalOutputTokens) }) t.Run("non-v7 fork id anchors the boundary from the envelope timestamp", func(t *testing.T) { // Neither the fork id nor the payload carries a usable // timestamp here — the gate must fall back to the JSONL // envelope timestamp and still suppress the replay. content := testjsonl.JoinJSONL( testjsonl.CodexForkedSessionMetaJSON("fork-plain-1", "parent-1", "/tmp", "user", tsEarly), testjsonl.CodexSessionMetaJSON("parent-1", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextWithIDJSON("gpt-5.4", parentTurnID, tsEarly), testjsonl.CodexMsgJSON("user", "replayed question", tsEarly), testjsonl.CodexMsgJSON("assistant", "replayed answer", tsEarly), testjsonl.CodexTokenCountJSON(tsEarly, 50_000, 9_000, 0), testjsonl.CodexTurnContextWithIDJSON("gpt-5.5", genuineTurnID, tsEarlyS1), testjsonl.CodexMsgJSON("user", "genuine question", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "genuine answer", tsEarlyS5), testjsonl.CodexTokenCountJSON(tsEarlyS5, 10_000, 500, 6_000), ) sess, msgs := runCodexParserTest(t, "fork.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, "codex:fork-plain-1", sess.ID) require.Len(t, msgs, 2) assert.Equal(t, "genuine question", msgs[0].Content) assert.Equal(t, 500, sess.TotalOutputTokens) }) t.Run("unparseable turn_id fails open instead of dropping live data", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexForkedSessionMetaJSON(forkID, "parent-1", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextWithIDJSON("gpt-5.5", "not-a-uuid", tsEarlyS1), testjsonl.CodexMsgJSON("user", "kept question", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "kept answer", tsEarlyS5), ) _, msgs := runCodexParserTest(t, "fork.jsonl", content, false) require.Len(t, msgs, 2) assert.Equal(t, "kept question", msgs[0].Content) }) t.Run("non-forked sessions are untouched", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("plain-1", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextJSON("gpt-5.4", tsEarlyS1), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "hi", tsEarlyS5), testjsonl.CodexTokenCountJSON(tsEarlyS5, 10_000, 500, 6_000), ) sess, msgs := runCodexParserTest(t, "plain.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, "codex:plain-1", sess.ID) require.Len(t, msgs, 2) assert.Equal(t, 500, sess.TotalOutputTokens) }) } // TestParseCodexSessionFrom_ForkReplaySpansOffset covers the // incremental case of the fork replay gate (#643): a sync boundary // lands inside the replayed parent history, so the rest of the replay // arrives via ParseCodexSessionFrom. The incremental parser must // restore the still-active gate from the prefix and keep suppressing // the replay until the fork's first genuine turn. func TestParseCodexSessionFrom_ForkReplaySpansOffset(t *testing.T) { t.Parallel() forkCreatedMs := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC).UnixMilli() // == tsEarly forkID := testUUIDv7(forkCreatedMs, 1) parentTurnID := testUUIDv7(forkCreatedMs-3600_000, 2) genuineTurnID := testUUIDv7(forkCreatedMs+1000, 3) // The initial sync sees only part of the replayed parent history. initial := testjsonl.JoinJSONL( testjsonl.CodexForkedSessionMetaJSON(forkID, "parent-1", "/tmp", "user", tsEarly), testjsonl.CodexSessionMetaJSON("parent-1", "/tmp", "user", tsEarly), testjsonl.CodexTurnContextWithIDJSON("gpt-5.4", parentTurnID, tsEarly), testjsonl.CodexMsgJSON("user", "replayed question", tsEarly), ) path := createTestFile(t, "fork-incremental.jsonl", initial) sess, msgs, err := parseCodexTestSession(t, path, "local", false) require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, "codex:"+forkID, sess.ID) require.Empty(t, msgs, "replayed prefix must not produce messages") info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() // The rest of the replay plus the fork's first genuine turn // arrive after the sync boundary. appended := testjsonl.JoinJSONL( testjsonl.CodexMsgJSON("assistant", "replayed answer", tsEarly), testjsonl.CodexTokenCountJSON(tsEarly, 50_000, 9_000, 0), testjsonl.CodexTurnContextWithIDJSON("gpt-5.5", genuineTurnID, tsEarlyS1), testjsonl.CodexMsgJSON("user", "genuine question", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "genuine answer", tsEarlyS5), testjsonl.CodexTokenCountJSON(tsEarlyS5, 10_000, 500, 6_000), ) f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, _, _, err := parseCodexTestSessionFrom(t, path, offset, 0, false) require.NoError(t, err) // Only the genuine turn survives; the replayed assistant answer // and its 9,000 output tokens stay suppressed. require.Len(t, newMsgs, 2) assert.Equal(t, "genuine question", newMsgs[0].Content) assert.Equal(t, "genuine answer", newMsgs[1].Content) assert.Equal(t, "gpt-5.5", newMsgs[1].Model) assert.Equal(t, 500, newMsgs[1].OutputTokens) } func TestParseCodexSession_EdgeCases(t *testing.T) { t.Run("skips system messages", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "# AGENTS.md\nsome instructions", tsEarlyS1), testjsonl.CodexMsgJSON("user", "stuff", "2024-01-01T10:00:02Z"), testjsonl.CodexMsgJSON("user", "ignore", "2024-01-01T10:00:03Z"), testjsonl.CodexMsgJSON("user", "Actual user message", "2024-01-01T10:00:04Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 1, len(msgs)) assert.Equal(t, "Actual user message", msgs[0].Content) }) t.Run("strips recommended plugins from initial context", func(t *testing.T) { plugins := "\n" + "Install Google Drive when useful.\n" + "" initialContext := fmt.Sprintf( `{"timestamp":%q,"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":%q},{"type":"input_text","text":%q},{"type":"input_text","text":%q}]}}`, tsEarlyS1, plugins, "# AGENTS.md instructions for /tmp/project\n\n\nrepo rules\n", "\n /tmp/project\n", ) content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "user", tsEarly), initialContext, testjsonl.CodexMsgJSON("user", "Review the changes", tsEarlyS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) require.Len(t, msgs, 1) assert.Equal(t, "Review the changes", msgs[0].Content) assert.Equal(t, "Review the changes", sess.FirstMessage) assert.Equal(t, 1, sess.UserMessageCount) }) t.Run("preserves prompt bundled with initial context", func(t *testing.T) { plugins := "\n" + "Install Google Drive when useful.\n" + "" initialContext := fmt.Sprintf( `{"timestamp":%q,"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":%q},{"type":"input_text","text":%q},{"type":"input_text","text":%q},{"type":"input_text","text":%q}]}}`, tsEarlyS1, plugins, "# AGENTS.md instructions for /tmp/project\n\n\nrepo rules\n", "\n /tmp/project\n", "Review the changes", ) content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "user", tsEarly), initialContext, ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) require.Len(t, msgs, 1) assert.Equal(t, "Review the changes", msgs[0].Content) assert.Equal(t, "Review the changes", sess.FirstMessage) assert.Equal(t, 1, sess.UserMessageCount) }) t.Run("preserves prompt sharing a block with initial context", func(t *testing.T) { initialContext := fmt.Sprintf( `{"timestamp":%q,"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":%q}]}}`, tsEarlyS1, "\nplugin list\n\n"+ "# AGENTS.md instructions for /tmp/project\n\n"+ "\nrepo rules\n\n\n"+ "Review the changes", ) content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "user", tsEarly), initialContext, ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) require.Len(t, msgs, 1) assert.Equal(t, "Review the changes", msgs[0].Content) assert.Equal(t, "Review the changes", sess.FirstMessage) assert.Equal(t, 1, sess.UserMessageCount) }) t.Run("preserves user prompt after recommended plugins", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON( "user", "\nplugin list\n\nFix the parser", tsEarlyS1, ), testjsonl.CodexMsgJSON( "user", "later user text", tsEarlyS5, ), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) require.Len(t, msgs, 2) assert.Equal(t, "Fix the parser", msgs[0].Content) assert.Equal(t, "later user text", msgs[1].Content) assert.Equal(t, "Fix the parser", sess.FirstMessage) assert.Equal(t, 2, sess.UserMessageCount) }) t.Run("finds recommended plugins after leading whitespace", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON( "user", "\nplugin list\nFix the parser", tsEarlyS1, ), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) require.Len(t, msgs, 1) assert.Equal(t, "Fix the parser", msgs[0].Content) }) // Codex injects skill template content as role=user JSONL // entries when the model invokes a skill. These look like // follow-up user turns to a naive count, which inflates // user_message_count past the single-turn classifier gate // and prevents automated sessions from being recognized. // Treat them as system content and drop from the message // list, the same way and similar // envelopes are handled. t.Run("skips skill template injections", func(t *testing.T) { skill := "\n roborev:fix\n " + "/Users/wesm/.codex/skills/roborev-fix/SKILL.md\n" + "---\nname: roborev:fix\n..." content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "You are a code reviewer.", tsEarlyS1), testjsonl.CodexMsgJSON("user", skill, "2024-01-01T10:00:02Z"), testjsonl.CodexMsgJSON("assistant", "OK", "2024-01-01T10:00:03Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) require.Len(t, msgs, 2) assert.Equal(t, "You are a code reviewer.", msgs[0].Content) assert.Equal(t, "OK", msgs[1].Content) assert.Equal(t, 1, sess.UserMessageCount, "skill injection must not count as a user turn") }) // Codex /goal continuation turns are emitted as role=user JSONL // entries whose content is the harness-injected goal context, not // anything the user typed. Treat them as system content and drop // them from the transcript and user counts, the same way // and skill injections are handled. Match // the structured wrapper rather than the inner sentence so a real // user message that happens to quote the goal text is preserved. t.Run("skips codex goal continuation context", func(t *testing.T) { goalBody := "Continue working toward the active thread goal.\n" + "The objective below is user-provided data." current := "\n" + goalBody + "\n" attrBeforeSource := "\n" + goalBody + "\n" attrAfterSource := "\n" + goalBody + "\n" legacy := "\n" + goalBody + "\n" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "Real first request", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "Working on it", "2024-01-01T10:00:02Z"), testjsonl.CodexMsgJSON("user", current, "2024-01-01T10:00:03Z"), testjsonl.CodexMsgJSON("assistant", "Still working", "2024-01-01T10:00:04Z"), testjsonl.CodexMsgJSON("user", attrBeforeSource, "2024-01-01T10:00:05Z"), testjsonl.CodexMsgJSON("user", attrAfterSource, "2024-01-01T10:00:06Z"), testjsonl.CodexMsgJSON("user", legacy, "2024-01-01T10:00:07Z"), testjsonl.CodexMsgJSON("user", "Real second request", "2024-01-01T10:00:08Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) require.Len(t, msgs, 4) assert.Equal(t, "Real first request", msgs[0].Content) assert.Equal(t, "Working on it", msgs[1].Content) assert.Equal(t, "Still working", msgs[2].Content) assert.Equal(t, "Real second request", msgs[3].Content) assert.Equal(t, 2, sess.UserMessageCount, "goal continuation context must not count as user turns") }) t.Run("keeps non-goal codex internal contexts", func(t *testing.T) { cases := []struct { name string content string }{ { name: "data source goal", content: "\n" + "Preserve this internal context.\n", }, { name: "other source", content: "\n" + "Preserve this internal context.\n", }, { name: "no source", content: "\n" + "Preserve this internal context.\n", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", tc.content, tsEarlyS1), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) require.Len(t, msgs, 1) assert.Equal(t, tc.content, msgs[0].Content) assert.Equal(t, 1, sess.UserMessageCount, "non-goal internal contexts must count as user turns") }) } }) // Only the structured goal wrapper is system content; a real user // message that merely quotes the goal sentence stays in the transcript. t.Run("keeps unwrapped goal-like user text", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", "Continue working toward the active thread goal.", tsEarlyS1), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.Len(t, msgs, 1) assert.Equal(t, "Continue working toward the active thread goal.", msgs[0].Content) }) t.Run("fallback ID from filename", func(t *testing.T) { content := testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1) sess, _ := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, "codex:test", sess.ID) }) t.Run("fallback ID from hyphenated filename", func(t *testing.T) { content := testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1) sess, _ := runCodexParserTest(t, "my-codex-session.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, "codex:my-codex-session", sess.ID) }) t.Run("large message within scanner limit", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("big", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", generateLargeString(1024*1024), tsEarlyS1), ) _, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, 1024*1024, msgs[0].ContentLength) }) t.Run("second session_meta with unparsable cwd resets project", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("multi", "/Users/alice/code/my-api", "user", tsEarly), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), testjsonl.CodexSessionMetaJSON("multi", "/", "user", "2024-01-01T10:00:02Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) assert.Equal(t, "codex:multi", sess.ID) assert.Equal(t, 1, len(msgs)) assert.Equal(t, "unknown", sess.Project) }) } // TestParseCodexSession_DeduplicatesReemittedPrompt covers Codex // re-emitting the initial user prompt verbatim when it continues a // task after a turn_aborted signal. The replay must not inflate // user_message_count past the single-turn gate that drives // automated-session classification, but a normal repeated prompt // must be preserved unless the parser has a positive replay signal. func TestParseCodexSession_DeduplicatesReemittedPrompt(t *testing.T) { const prompt = "You are a code reviewer. Review the code changes shown below." t.Run("keeps repeated initial prompt without replay signal", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("rev", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", prompt, tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "looking", "2024-01-01T10:00:02Z"), testjsonl.CodexMsgJSON("user", prompt, "2024-01-01T10:00:03Z"), testjsonl.CodexMsgJSON("assistant", "No issues found.", "2024-01-01T10:00:04Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 2, sess.UserMessageCount, "repeated prompt without a replay signal must count as a real user turn") require.Len(t, msgs, 4) assert.Equal(t, prompt, msgs[0].Content) assert.Equal(t, RoleAssistant, msgs[1].Role) assert.Equal(t, prompt, msgs[2].Content) assert.Equal(t, RoleAssistant, msgs[3].Role) }) t.Run("drops re-emitted prompt after turn_aborted", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("rev", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", prompt, tsEarlyS1), testjsonl.CodexMsgJSON("user", "\ninterrupted", "2024-01-01T10:00:02Z"), testjsonl.CodexMsgJSON("user", prompt, "2024-01-01T10:00:03Z"), testjsonl.CodexMsgJSON("assistant", "No issues found.", "2024-01-01T10:00:04Z"), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 1, sess.UserMessageCount, "re-emitted prompt after a turn_aborted must not count as a second user turn") require.Len(t, msgs, 2) assert.Equal(t, prompt, msgs[0].Content) }) t.Run("keeps a genuine repeat after a distinct user turn", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("chat", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", prompt, tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "ok", "2024-01-01T10:00:02Z"), testjsonl.CodexMsgJSON("user", "something else entirely", "2024-01-01T10:00:03Z"), testjsonl.CodexMsgJSON("assistant", "ok2", "2024-01-01T10:00:04Z"), testjsonl.CodexMsgJSON("user", prompt, "2024-01-01T10:00:05Z"), ) sess, _ := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 3, sess.UserMessageCount, "a deliberate repeat after a distinct user turn must be preserved") }) t.Run("keeps distinct prompts that share the first 300 runes", func(t *testing.T) { // Two different prompts whose first 300 runes are identical // collapse to the same first_message preview. The dedup must // match on full content, not the preview, so the second turn // is recognised as distinct and kept rather than dropped as a // replay. shared := strings.Repeat("x", 300) content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("chat", "/tmp", "user", tsEarly), testjsonl.CodexMsgJSON("user", shared+" first", tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "ok", "2024-01-01T10:00:02Z"), testjsonl.CodexMsgJSON("user", shared+" second", "2024-01-01T10:00:03Z"), ) sess, _ := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 2, sess.UserMessageCount, "distinct prompts sharing only the 300-rune preview must both be kept") }) } // codexEventMsgJSON builds a Codex event_msg line for lifecycle // events like task_complete / task_started / turn_aborted. The // shape mirrors what the Codex CLI emits in real session files. func codexEventMsgJSON( eventType, timestamp string, ) string { return `{"type":"event_msg","timestamp":"` + timestamp + `","payload":{"type":"` + eventType + `"}}` } // TestParseCodexSession_TerminationStatus exercises the lifecycle // event tracking that drives termination_status for Codex sessions. // Codex doesn't go through Classify() — it sets the status from the // most recent task_started / task_complete / turn_aborted event // seen on the file, so a regression in handleEventMsg or the // session-builder wiring would silently leave Codex sessions as // NULL or misclassified. func TestParseCodexSession_TerminationStatus(t *testing.T) { t.Run("task_complete -> awaiting_user", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "sess-tc", "/Users/me/proj", "user", tsEarly), testjsonl.CodexMsgJSON( "user", "build it", tsEarlyS1), codexEventMsgJSON( "task_started", "2024-01-01T10:00:02Z"), testjsonl.CodexMsgJSON( "assistant", "done", "2024-01-01T10:00:03Z"), codexEventMsgJSON( "task_complete", "2024-01-01T10:00:04Z"), ) sess, _ := runCodexParserTest( t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal( t, TerminationAwaitingUser, sess.TerminationStatus) }) t.Run("task_started in flight -> tool_call_pending", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "sess-ts", "/Users/me/proj", "user", tsEarly), testjsonl.CodexMsgJSON( "user", "long task", tsEarlyS1), codexEventMsgJSON( "task_started", "2024-01-01T10:00:02Z"), ) sess, _ := runCodexParserTest( t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal( t, TerminationToolCallPending, sess.TerminationStatus) }) t.Run("turn_aborted -> tool_call_pending", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "sess-ta", "/Users/me/proj", "user", tsEarly), testjsonl.CodexMsgJSON( "user", "interrupt me", tsEarlyS1), codexEventMsgJSON( "task_started", "2024-01-01T10:00:02Z"), codexEventMsgJSON( "turn_aborted", "2024-01-01T10:00:03Z"), ) sess, _ := runCodexParserTest( t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal( t, TerminationToolCallPending, sess.TerminationStatus) }) t.Run("no lifecycle events -> empty (unknown)", func(t *testing.T) { content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "sess-empty", "/Users/me/proj", "user", tsEarly), testjsonl.CodexMsgJSON( "user", "hi", tsEarlyS1), ) sess, _ := runCodexParserTest( t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal( t, TerminationStatus(""), sess.TerminationStatus) }) } func TestCodexCursorWarmColdParity(t *testing.T) { prefix := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "cursor-parity", "/workspace/project-a", "codex_cli_rs", tsEarly, ), testjsonl.CodexTurnContextJSON("gpt-5.4", tsEarlyS1), testjsonl.CodexMsgJSON("user", "First request", "2024-01-01T10:00:02Z"), codexEventMsgJSON("task_started", "2024-01-01T10:00:03Z"), testjsonl.CodexMsgJSON("assistant", "Working", "2024-01-01T10:00:04Z"), codexEventMsgJSON("task_complete", "2024-01-01T10:00:05Z"), ) path := createTestFile(t, "cursor-parity.jsonl", prefix) warmProvider := newCodexTestProvider(t) sess, prefixMessages, err := warmProvider.parseSession(path, "local", false) require.NoError(t, err) require.NotNil(t, sess) require.Len(t, prefixMessages, 2) prefixInfo, err := os.Stat(path) require.NoError(t, err) prefixOffset := prefixInfo.Size() inode, device := sourceFileIdentity(prefixInfo) _, cursorHit := warmProvider.cursorCache.Get( path, prefixOffset, inode, device, ) require.True(t, cursorHit, "full parse must seed the warm continuation cursor") tail := testjsonl.JoinJSONL( testjsonl.CodexTurnContextJSON("gpt-5.5", "2024-01-01T10:00:06Z"), testjsonl.CodexMsgJSON("user", "Second request", "2024-01-01T10:00:07Z"), codexEventMsgJSON("task_started", "2024-01-01T10:00:08Z"), testjsonl.CodexMsgJSON("assistant", "Second answer", "2024-01-01T10:00:09Z"), codexEventMsgJSON("task_complete", "2024-01-01T10:00:10Z"), ) f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(tail) require.NoError(t, err) require.NoError(t, f.Close()) warm, err := warmProvider.parseSessionFromDetailed( path, prefixOffset, 2, false, ) require.NoError(t, err) coldProvider := newCodexTestProvider(t) cold, err := coldProvider.parseSessionFromDetailed( path, prefixOffset, 2, false, ) require.NoError(t, err) assert.Equal(t, cold, warm) require.Len(t, warm.messages, 2) assert.Equal(t, RoleUser, warm.messages[0].Role) assert.Equal(t, "Second request", warm.messages[0].Content) assert.Equal(t, 2, warm.messages[0].Ordinal) assert.Equal(t, "gpt-5.5", warm.messages[0].Model) assert.Equal(t, RoleAssistant, warm.messages[1].Role) assert.Equal(t, "Second answer", warm.messages[1].Content) assert.Equal(t, 3, warm.messages[1].Ordinal) assert.Equal(t, "gpt-5.5", warm.messages[1].Model) assert.Equal(t, "/workspace/project-a", warm.cursor.cwd) assert.Equal(t, "task_complete", warm.cursor.lastTaskEvent) } func TestCodexPromptReplayDigestParity(t *testing.T) { const prompt = "Implement the bounded continuation cursor" initialEnvelope := "\nplugin list\n\n" + "\n/workspace/project-a\n\n" + prompt prefix := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "cursor-replay", "/workspace/project-a", "codex_cli_rs", tsEarly, ), testjsonl.CodexTurnContextJSON("gpt-5.4", tsEarlyS1), testjsonl.CodexMsgJSON("user", initialEnvelope, "2024-01-01T10:00:02Z"), testjsonl.CodexMsgJSON("assistant", "Starting", "2024-01-01T10:00:03Z"), codexEventMsgJSON("turn_aborted", "2024-01-01T10:00:04Z"), ) path := createTestFile(t, "cursor-replay.jsonl", prefix) warmProvider := newCodexTestProvider(t) sess, prefixMessages, err := warmProvider.parseSession(path, "local", false) require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, prompt, sess.FirstMessage) require.Len(t, prefixMessages, 2) assert.Equal(t, prompt, prefixMessages[0].Content) prefixInfo, err := os.Stat(path) require.NoError(t, err) prefixOffset := prefixInfo.Size() inode, device := sourceFileIdentity(prefixInfo) seed, cursorHit := warmProvider.cursorCache.Get( path, prefixOffset, inode, device, ) require.True(t, cursorHit, "full parse must seed the warm continuation cursor") assert.True(t, seed.firstUserSeen) assert.True(t, seed.mayReplayFirstUserPrompt) assert.Equal(t, "turn_aborted", seed.lastTaskEvent) tail := testjsonl.JoinJSONL( testjsonl.CodexMsgJSON("user", prompt, "2024-01-01T10:00:05Z"), testjsonl.CodexTurnContextJSON("gpt-5.5", "2024-01-01T10:00:06Z"), testjsonl.CodexMsgJSON("assistant", "Cursor implemented", "2024-01-01T10:00:07Z"), codexEventMsgJSON("task_complete", "2024-01-01T10:00:08Z"), ) f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(tail) require.NoError(t, err) require.NoError(t, f.Close()) warm, err := warmProvider.parseSessionFromDetailed( path, prefixOffset, 2, false, ) require.NoError(t, err) coldProvider := newCodexTestProvider(t) cold, err := coldProvider.parseSessionFromDetailed( path, prefixOffset, 2, false, ) require.NoError(t, err) assert.Equal(t, cold, warm) require.Len(t, warm.messages, 1, "the first-prompt replay must be suppressed") assert.Equal(t, RoleAssistant, warm.messages[0].Role) assert.Equal(t, "Cursor implemented", warm.messages[0].Content) assert.Equal(t, 2, warm.messages[0].Ordinal) assert.Equal(t, "gpt-5.5", warm.messages[0].Model) assert.Equal(t, "task_complete", warm.cursor.lastTaskEvent) } func TestParseCodexSessionFrom_Incremental(t *testing.T) { t.Parallel() // Build initial content with session_meta + one message. initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "inc-1", "/projects/api", "codex_cli_rs", tsEarly, ), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), ) path := createTestFile(t, "incremental.jsonl", initial) // Full parse to get baseline. sess, msgs, err := parseCodexTestSession(t, path, "local", false) require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, "codex:inc-1", sess.ID) assert.Equal(t, 1, len(msgs)) assert.Equal(t, 0, msgs[0].Ordinal) // Record the file size as the incremental offset. info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() // Append new messages. appended := testjsonl.JoinJSONL( testjsonl.CodexMsgJSON( "assistant", "world", tsEarlyS5, ), testjsonl.CodexMsgJSON( "user", "thanks", tsLate, ), ) f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) // Incremental parse from the offset. newMsgs, endedAt, _, err := parseCodexTestSessionFrom(t, path, offset, 1, false, ) require.NoError(t, err) assert.Equal(t, 2, len(newMsgs)) // Ordinals start from startOrdinal=1. assert.Equal(t, 1, newMsgs[0].Ordinal) assert.Equal(t, RoleAssistant, newMsgs[0].Role) assert.Contains(t, newMsgs[0].Content, "world") assert.Equal(t, 2, newMsgs[1].Ordinal) assert.Equal(t, RoleUser, newMsgs[1].Role) // endedAt reflects the latest timestamp. assert.False(t, endedAt.IsZero()) } func TestParseCodexSessionFrom_LateTokenCountRequiresFullParse(t *testing.T) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "inc-late-usage", "/projects/api", "codex_cli_rs", tsEarly, ), testjsonl.CodexTurnContextJSON("gpt-5.5", tsEarlyS1), testjsonl.CodexMsgJSON("user", "run command", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON( "exec_command", "call_cmd", map[string]any{"cmd": "sleep 1"}, tsEarlyS5, ), ) path := createTestFile(t, "late-token-count.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(testjsonl.JoinJSONL( testjsonl.CodexFunctionCallOutputJSON( "call_cmd", "done", tsLate, ), testjsonl.CodexTokenCountJSON( tsLate, 100_000, 250, 64_000, ), )) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = parseCodexTestSessionFrom(t, path, offset, 2, false) require.Error(t, err) assert.True(t, IsIncrementalFullParseFallback(err)) } func TestParseCodexSessionFrom_FunctionCallOutputRequiresFullParse(t *testing.T) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "inc-function-output", "/projects/api", "codex_exec", tsEarly, ), testjsonl.CodexMsgJSON("user", "run command", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON( "exec_command", "call_cmd", map[string]any{"cmd": "sleep 1"}, tsEarlyS5, ), ) path := createTestFile(t, "function-call-output.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(testjsonl.JoinJSONL( testjsonl.CodexFunctionCallOutputJSON( "call_cmd", "done", tsLate, ), )) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = parseCodexTestSessionFrom(t, path, offset, 2, false) require.Error(t, err) assert.True(t, IsIncrementalFullParseFallback(err)) } // TestParseCodexSessionFrom_DedupsReemittedPrompt covers the // incremental-sync case of the re-emitted-prompt dedup: when Codex // appends a positive replay signal followed by a verbatim replay of // the initial prompt after the session was already synced, the // incremental parser must recover the prior context and drop the // replay, mirroring a full parse. func TestParseCodexSessionFrom_DedupsReemittedPrompt(t *testing.T) { const prompt = "You are a code reviewer. Review the code changes shown below." appendLines := func(t *testing.T, path, content string) { t.Helper() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(content) require.NoError(t, err) require.NoError(t, f.Close()) } t.Run("keeps repeated prompt appended without replay signal", func(t *testing.T) { initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("inc-rev", "/tmp", "codex_cli_rs", tsEarly), testjsonl.CodexMsgJSON("user", prompt, tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "looking", tsEarlyS5), ) path := createTestFile(t, "incremental.jsonl", initial) sess, msgs, err := parseCodexTestSession(t, path, "local", false) require.NoError(t, err) require.Equal(t, 1, sess.UserMessageCount) require.Len(t, msgs, 2) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appendLines(t, path, testjsonl.JoinJSONL( testjsonl.CodexMsgJSON("user", prompt, tsLate), testjsonl.CodexMsgJSON("assistant", "No issues found.", tsLateS5), )) newMsgs, _, _, err := parseCodexTestSessionFrom(t, path, offset, len(msgs), false) require.NoError(t, err) require.Len(t, newMsgs, 2) assert.Equal(t, RoleUser, newMsgs[0].Role) assert.Equal(t, prompt, newMsgs[0].Content) assert.Equal(t, RoleAssistant, newMsgs[1].Role) }) t.Run("drops a re-emitted prompt appended after turn_aborted", func(t *testing.T) { initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("inc-rev", "/tmp", "codex_cli_rs", tsEarly), testjsonl.CodexMsgJSON("user", prompt, tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "looking", tsEarlyS5), ) path := createTestFile(t, "incremental.jsonl", initial) sess, msgs, err := parseCodexTestSession(t, path, "local", false) require.NoError(t, err) require.Equal(t, 1, sess.UserMessageCount) require.Len(t, msgs, 2) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appendLines(t, path, testjsonl.JoinJSONL( testjsonl.CodexMsgJSON("user", "\ninterrupted", tsLate), testjsonl.CodexMsgJSON("user", prompt, tsLate), testjsonl.CodexMsgJSON("assistant", "No issues found.", tsLateS5), )) newMsgs, _, _, err := parseCodexTestSessionFrom(t, path, offset, len(msgs), false) require.NoError(t, err) require.Len(t, newMsgs, 1) assert.Equal(t, RoleAssistant, newMsgs[0].Role) assert.Contains(t, newMsgs[0].Content, "No issues found.") assert.Equal(t, len(msgs), newMsgs[0].Ordinal, "kept message must keep contiguous ordinals (no gap from the dropped replay)") }) t.Run("dedups replay after stripped recommended plugins", func(t *testing.T) { initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("inc-plugins", "/tmp", "codex_cli_rs", tsEarly), testjsonl.CodexMsgJSON( "user", "\nplugin list\n\n"+prompt, tsEarlyS1, ), testjsonl.CodexMsgJSON("assistant", "looking", tsEarlyS5), ) path := createTestFile(t, "incremental-plugins.jsonl", initial) sess, msgs, err := parseCodexTestSession(t, path, "local", false) require.NoError(t, err) require.Equal(t, prompt, sess.FirstMessage) require.Len(t, msgs, 2) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appendLines(t, path, testjsonl.JoinJSONL( testjsonl.CodexMsgJSON("user", "\ninterrupted", tsLate), testjsonl.CodexMsgJSON("user", prompt, tsLate), testjsonl.CodexMsgJSON("assistant", "No issues found.", tsLateS5), )) newMsgs, _, _, err := parseCodexTestSessionFrom(t, path, offset, len(msgs), false) require.NoError(t, err) require.Len(t, newMsgs, 1) assert.Equal(t, RoleAssistant, newMsgs[0].Role) assert.Equal(t, "No issues found.", newMsgs[0].Content) }) t.Run("keeps a re-emitted prompt when the prefix already had a distinct turn", func(t *testing.T) { initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("inc-chat", "/tmp", "codex_cli_rs", tsEarly), testjsonl.CodexMsgJSON("user", prompt, tsEarlyS1), testjsonl.CodexMsgJSON("assistant", "ok", "2024-01-01T10:00:02Z"), testjsonl.CodexMsgJSON("user", "something else entirely", "2024-01-01T10:00:03Z"), ) path := createTestFile(t, "incremental.jsonl", initial) sess, msgs, err := parseCodexTestSession(t, path, "local", false) require.NoError(t, err) require.Equal(t, 2, sess.UserMessageCount) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appendLines(t, path, testjsonl.CodexMsgJSON("user", prompt, tsLate)) newMsgs, _, _, err := parseCodexTestSessionFrom(t, path, offset, len(msgs), false) require.NoError(t, err) require.Len(t, newMsgs, 1) assert.Equal(t, RoleUser, newMsgs[0].Role) assert.Equal(t, prompt, newMsgs[0].Content) }) } func TestParseCodexSessionFrom_SkipsSessionMeta(t *testing.T) { t.Parallel() // File where session_meta appears after the offset // (shouldn't happen in practice but should be skipped). initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "meta-2", "/tmp", "codex_cli_rs", tsEarly, ), testjsonl.CodexMsgJSON("user", "first", tsEarlyS1), ) path := createTestFile(t, "meta-skip.jsonl", initial) info, _ := os.Stat(path) offset := info.Size() // Append a duplicate session_meta + a message. extra := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "meta-2", "/tmp", "codex_cli_rs", tsEarlyS5, ), testjsonl.CodexMsgJSON( "assistant", "reply", tsLate, ), ) f, _ := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) f.WriteString(extra) f.Close() newMsgs, _, _, err := parseCodexTestSessionFrom(t, path, offset, 5, false, ) require.NoError(t, err) // Only the assistant message, not the session_meta. assert.Equal(t, 1, len(newMsgs)) assert.Equal(t, 5, newMsgs[0].Ordinal) } func TestParseCodexSessionFrom_NoNewData(t *testing.T) { t.Parallel() content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "empty-1", "/tmp", "codex_cli_rs", tsEarly, ), testjsonl.CodexMsgJSON("user", "hi", tsEarlyS1), ) path := createTestFile(t, "no-new.jsonl", content) info, _ := os.Stat(path) offset := info.Size() // Parse from end of file — no new data. newMsgs, endedAt, _, err := parseCodexTestSessionFrom(t, path, offset, 10, false, ) require.NoError(t, err) assert.Equal(t, 0, len(newMsgs)) assert.True(t, endedAt.IsZero()) } func TestParseCodexSessionFrom_SubagentOutputRequiresFullParse(t *testing.T) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("inc-sub", "/tmp", "codex_cli_rs", tsEarly), testjsonl.CodexMsgJSON("user", "run child", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "run it", }, tsEarlyS5), ) path := createTestFile(t, "codex-subagent-inc.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(testjsonl.JoinJSONL( testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"019c9c96-6ee7-77c0-ba4c-380f844289d5","nickname":"Fennel"}`, tsLate), )) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = parseCodexTestSessionFrom(t, path, offset, 2, false) require.Error(t, err) assert.Contains(t, err.Error(), "full parse") } func TestParseCodexSessionFrom_CollabAgentSpawnEndRequiresFullParse(t *testing.T) { t.Parallel() childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("inc-sub-event", "/tmp", "codex_cli_rs", tsEarly), testjsonl.CodexMsgJSON("user", "run child", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "run it", }, tsEarlyS5), ) path := createTestFile(t, "codex-subagent-event-inc.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(testjsonl.JoinJSONL( "{\"timestamp\":\"2024-01-01T10:01:05Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"collab_agent_spawn_end\",\"call_id\":\"call_spawn\",\"new_thread_id\":\"" + childID + "\",\"status\":\"pending_init\"}}", )) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = parseCodexTestSessionFrom(t, path, offset, 2, false) require.Error(t, err) assert.Contains(t, err.Error(), "full parse") } func TestParseCodexSessionFrom_WaitCallRequiresFullParse(t *testing.T) { t.Parallel() childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" notification := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" + "" initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("inc-wait", "/tmp", "codex_cli_rs", tsEarly), testjsonl.CodexMsgJSON("user", "run child", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "run it", }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate), testjsonl.CodexMsgJSON("user", notification, tsLateS5), ) path := createTestFile(t, "codex-wait-inc.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(testjsonl.JoinJSONL( testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{ "ids": []string{childID}, }, "2024-01-01T10:01:06Z"), )) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = parseCodexTestSessionFrom(t, path, offset, 4, false) require.Error(t, err) assert.Contains(t, err.Error(), "full parse") } func TestParseCodexSessionFrom_WaitAgentCallRequiresFullParse(t *testing.T) { t.Parallel() childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" notification := "\n" + "{\"agent_path\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" + "" initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("inc-wait-agent", "/tmp", "codex_cli_rs", tsEarly), testjsonl.CodexMsgJSON("user", "run child", tsEarlyS1), testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{ "agent_type": "awaiter", "message": "run it", }, tsEarlyS5), testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate), testjsonl.CodexMsgJSON("user", notification, tsLateS5), ) path := createTestFile(t, "codex-wait-agent-inc.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(testjsonl.JoinJSONL( testjsonl.CodexFunctionCallWithCallIDJSON("wait_agent", "call_wait", map[string]any{ "targets": []string{childID}, }, "2024-01-01T10:01:06Z"), )) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = parseCodexTestSessionFrom(t, path, offset, 4, false) require.Error(t, err) assert.Contains(t, err.Error(), "full parse") } func TestParseCodexSessionFrom_SystemMessageDoesNotRequireFullParse(t *testing.T) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("inc-system", "/tmp", "codex_cli_rs", tsEarly), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), ) path := createTestFile(t, "codex-system-inc.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(testjsonl.JoinJSONL( testjsonl.CodexMsgJSON("user", "# AGENTS.md\nsome instructions", tsLate), )) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, endedAt, _, err := parseCodexTestSessionFrom(t, path, offset, 1, false) require.NoError(t, err) assert.Equal(t, 0, len(newMsgs)) assert.False(t, endedAt.IsZero()) } func TestParseCodexSessionFrom_RunningNotificationRequiresFullParse(t *testing.T) { t.Parallel() childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5" running := "\n" + "{\"agent_id\":\"" + childID + "\",\"status\":{\"running\":\"Still working\"}}\n" + "" initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("inc-running", "/tmp", "codex_cli_rs", tsEarly), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), ) path := createTestFile(t, "codex-running-inc.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(testjsonl.JoinJSONL( testjsonl.CodexMsgJSON("user", running, tsLate), )) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = parseCodexTestSessionFrom(t, path, offset, 1, false) require.Error(t, err) assert.Contains(t, err.Error(), "full parse") } func TestParseCodexSessionFrom_NonSubagentFunctionOutputRequiresFullParse(t *testing.T) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("inc-nonsubagent-output", "/tmp", "codex_cli_rs", tsEarly), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), ) path := createTestFile(t, "codex-nonsubagent-output-inc.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(testjsonl.JoinJSONL( testjsonl.CodexFunctionCallOutputJSON("call_other", `{"status":"ok"}`, tsLate), )) require.NoError(t, err) require.NoError(t, f.Close()) _, _, _, err = parseCodexTestSessionFrom(t, path, offset, 1, false) require.Error(t, err) assert.True(t, IsIncrementalFullParseFallback(err)) } func TestParseCodexSessionFrom_SeedsModelFromTurnContext( t *testing.T, ) { t.Parallel() initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "model-seed", "/tmp", "codex_cli_rs", tsEarly, ), testjsonl.CodexTurnContextJSON( "gpt-5.4", tsEarlyS1, ), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS5), testjsonl.CodexMsgJSON( "assistant", "hi there", tsLate, ), ) path := createTestFile(t, "model-seed.jsonl", initial) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appended := testjsonl.JoinJSONL( testjsonl.CodexMsgJSON( "assistant", "second reply", tsLateS5, ), ) f2, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f2.WriteString(appended) require.NoError(t, err) require.NoError(t, f2.Close()) newMsgs2, _, _, err := parseCodexTestSessionFrom(t, path, offset, 2, false, ) require.NoError(t, err) require.Equal(t, 1, len(newMsgs2)) assert.Equal(t, "gpt-5.4", newMsgs2[0].Model, "incremental parse should seed model from "+ "prior turn_context via file scan") } func TestParseCodexSessionFrom_SeedsBoundaryAfterTurnContext( t *testing.T, ) { t.Parallel() // Offset lands immediately after a turn_context with no // following message — the exact sync boundary edge case. initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "tc-boundary", "/tmp", "codex_cli_rs", tsEarly, ), testjsonl.CodexTurnContextJSON( "gpt-5.4", tsEarlyS1, ), ) path := createTestFile( t, "tc-boundary.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appended := testjsonl.JoinJSONL( testjsonl.CodexMsgJSON("user", "hello", tsEarlyS5), testjsonl.CodexMsgJSON( "assistant", "world", tsLate, ), ) f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, _, _, err := parseCodexTestSessionFrom(t, path, offset, 0, false, ) require.NoError(t, err) require.Equal(t, 2, len(newMsgs)) assert.Equal(t, "gpt-5.4", newMsgs[0].Model, "user message after turn_context boundary") assert.Equal(t, "gpt-5.4", newMsgs[1].Model, "assistant message after turn_context boundary") } func TestParseCodexSessionFrom_EmptyModelReset( t *testing.T, ) { t.Parallel() // turn_context clears model to "" — incremental parse // must honor the reset, not retain the old model. initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "model-reset", "/tmp", "codex_cli_rs", tsEarly, ), testjsonl.CodexTurnContextJSON( "gpt-5.4", tsEarlyS1, ), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS5), testjsonl.CodexTurnContextJSON("", tsLate), ) path := createTestFile( t, "model-reset.jsonl", initial, ) info, err := os.Stat(path) require.NoError(t, err) offset := info.Size() appended := testjsonl.JoinJSONL( testjsonl.CodexMsgJSON( "assistant", "after reset", tsLateS5, ), ) f, err := os.OpenFile( path, os.O_APPEND|os.O_WRONLY, 0o644, ) require.NoError(t, err) _, err = f.WriteString(appended) require.NoError(t, err) require.NoError(t, f.Close()) newMsgs, _, _, err := parseCodexTestSessionFrom(t, path, offset, 2, false, ) require.NoError(t, err) require.Equal(t, 1, len(newMsgs)) assert.Equal(t, "", newMsgs[0].Model, "empty-model turn_context should reset model") } func TestSeedCodexIncrementalState_SkipsInvalidJSON( t *testing.T, ) { t.Parallel() // Truncated turn_context between a valid one and the // offset — must not override the valid model. validTC := testjsonl.CodexTurnContextJSON( "gpt-5.4", tsEarlyS1, ) truncated := `{"type":"turn_context","payload":{"model":"wrong` content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "invalid-json", "/tmp", "codex_cli_rs", tsEarly, ), ) + validTC + "\n" + truncated + "\n" path := createTestFile( t, "invalid-tc.jsonl", content, ) info, err := os.Stat(path) require.NoError(t, err) seed, err := seedCodexIncrementalState(path, info.Size()) require.NoError(t, err) got := seed.model assert.Equal(t, "gpt-5.4", got, "truncated turn_context should be skipped") } func TestSeedCodexIncrementalState_Model(t *testing.T) { t.Parallel() content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( "model-at-offset", "/tmp", "codex_cli_rs", tsEarly, ), testjsonl.CodexTurnContextJSON( "gpt-5", tsEarlyS1, ), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS5), testjsonl.CodexTurnContextJSON( "gpt-5.4", tsLate, ), testjsonl.CodexMsgJSON("user", "bye", tsLateS5), ) path := createTestFile( t, "model-at-offset.jsonl", content, ) t.Run("full file returns last model", func(t *testing.T) { info, err := os.Stat(path) require.NoError(t, err) seed, err := seedCodexIncrementalState(path, info.Size()) require.NoError(t, err) got := seed.model assert.Equal(t, "gpt-5.4", got) }) t.Run("zero offset returns empty", func(t *testing.T) { seed, err := seedCodexIncrementalState(path, 0) require.NoError(t, err) got := seed.model assert.Equal(t, "", got) }) t.Run("nonexistent file returns error", func(t *testing.T) { _, err := seedCodexIncrementalState("/no/such/file", 100) require.Error(t, err) }) } func TestSeedCodexIncrementalStatePropagatesReaderError(t *testing.T) { wantErr := errors.New("prefix read failed") _, err := seedCodexIncrementalStateFromReader( iotest.ErrReader(wantErr), ) require.ErrorIs(t, err, wantErr) } // TestParseCodexSession_TurnAbortedNotCountedAsUser pins the // behavior that Codex's synthetic "user" message // (emitted when codex exec is interrupted) is filtered like other // system messages and does not inflate UserMessageCount. Without // this, a single-turn roborev review session whose codex process // was killed during shutdown gets UserMessageCount=2 and falls // through the IsAutomatedSession single-turn gate. func TestParseCodexSession_TurnAbortedNotCountedAsUser(t *testing.T) { turnAborted := "\nThe user interrupted the previous turn on purpose. " + "Any running unified exec processes may still be running in the background. " + "If any tools/commands were aborted, they may have partially executed.\n" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON("abc", "/tmp", "codex_exec", tsEarly), testjsonl.CodexMsgJSON("user", "You are a code reviewer. Review the diff.", tsEarlyS1), testjsonl.CodexMsgJSON("user", turnAborted, tsEarlyS5), ) sess, msgs := runCodexParserTest(t, "test.jsonl", content, false) require.NotNil(t, sess) assert.Equal(t, 1, sess.UserMessageCount, " synthetic must not be counted as a user message") for _, m := range msgs { assert.NotContains(t, m.Content, "", " synthetic must be filtered from message list") } }