Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

2905 lines
112 KiB
Go

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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_path\":\"" + childID + "\",\"status\":{\"completed\":\"" + summary + "\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_path\":\"" + childID + "\",\"status\":{\"completed\":\"" + summary + "\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"running\":\"Still working\"}}\n" +
"</subagent_notification>"
completed := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + runningID + "\",\"status\":{\"completed\":\"Second agent finished\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
"</subagent_notification>"
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", "<environment_context>stuff</environment_context>", "2024-01-01T10:00:02Z"),
testjsonl.CodexMsgJSON("user", "<INSTRUCTIONS>ignore</INSTRUCTIONS>", "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 := "<recommended_plugins>\n" +
"Install Google Drive when useful.\n" +
"</recommended_plugins>"
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<INSTRUCTIONS>\nrepo rules\n</INSTRUCTIONS>",
"<environment_context>\n <cwd>/tmp/project</cwd>\n</environment_context>",
)
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 := "<recommended_plugins>\n" +
"Install Google Drive when useful.\n" +
"</recommended_plugins>"
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<INSTRUCTIONS>\nrepo rules\n</INSTRUCTIONS>",
"<environment_context>\n <cwd>/tmp/project</cwd>\n</environment_context>",
"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,
"<recommended_plugins>\nplugin list\n</recommended_plugins>\n"+
"# AGENTS.md instructions for /tmp/project\n\n"+
"<INSTRUCTIONS>\nrepo rules\n</INSTRUCTIONS>\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",
"<recommended_plugins>\nplugin list\n</recommended_plugins>\nFix the parser",
tsEarlyS1,
),
testjsonl.CodexMsgJSON(
"user",
"<recommended_plugins>later user text</recommended_plugins>",
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, "<recommended_plugins>later user text</recommended_plugins>", 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",
"\n<recommended_plugins>plugin list</recommended_plugins>\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 <environment_context> and similar
// envelopes are handled.
t.Run("skips skill template injections", func(t *testing.T) {
skill := "<skill>\n <name>roborev:fix</name>\n <path>" +
"/Users/wesm/.codex/skills/roborev-fix/SKILL.md</path>\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
// <environment_context> 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 := "<codex_internal_context source=\"goal\">\n" +
goalBody + "\n</codex_internal_context>"
attrBeforeSource := "<codex_internal_context data-id=\"1\" source=\"goal\">\n" +
goalBody + "\n</codex_internal_context>"
attrAfterSource := "<codex_internal_context source=\"goal\" data-id=\"1\">\n" +
goalBody + "\n</codex_internal_context>"
legacy := "<goal_context>\n" + goalBody + "\n</goal_context>"
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: "<codex_internal_context data-source=\"goal\">\n" +
"Preserve this internal context.\n</codex_internal_context>",
},
{
name: "other source",
content: "<codex_internal_context source=\"tool\">\n" +
"Preserve this internal context.\n</codex_internal_context>",
},
{
name: "no source",
content: "<codex_internal_context data-id=\"1\">\n" +
"Preserve this internal context.\n</codex_internal_context>",
},
}
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", "<turn_aborted>\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 := "<recommended_plugins>\nplugin list\n</recommended_plugins>\n" +
"<environment_context>\n<cwd>/workspace/project-a</cwd>\n</environment_context>\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", "<turn_aborted>\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",
"<recommended_plugins>\nplugin list\n</recommended_plugins>\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", "<turn_aborted>\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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_path\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
"</subagent_notification>"
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 := "<subagent_notification>\n" +
"{\"agent_id\":\"" + childID + "\",\"status\":{\"running\":\"Still working\"}}\n" +
"</subagent_notification>"
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 <turn_aborted> "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 := "<turn_aborted>\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</turn_aborted>"
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,
"<turn_aborted> synthetic must not be counted as a user message")
for _, m := range msgs {
assert.NotContains(t, m.Content, "<turn_aborted>",
"<turn_aborted> synthetic must be filtered from message list")
}
}