diff --git a/internal/parser/codex.go b/internal/parser/codex.go index 1dce8c98a..f4b872269 100644 --- a/internal/parser/codex.go +++ b/internal/parser/codex.go @@ -1746,7 +1746,30 @@ func isCodexSystemMessage(content string) bool { strings.HasPrefix(content, "") || isCodexTurnAbortedMessage(content) || strings.HasPrefix(trimmed, "") || - isCodexSubagentNotification(content) + isCodexSubagentNotification(content) || + isCodexGoalContext(content) +} + +// isCodexGoalContext reports whether content is a Codex /goal +// continuation envelope. These are harness-injected as role=user +// records to keep the model working toward an active thread goal, but +// they are not user-authored turns and should be treated as system +// content. Current sessions wrap the body in +// ; older sessions used +// . Detection is scoped to the structured wrapper (and, +// for the modern form, the goal source specifically) so that other +// internal-context envelopes and real user messages quoting the goal +// text are left untouched. +func isCodexGoalContext(content string) bool { + trimmed := strings.TrimSpace(content) + if strings.HasPrefix(trimmed, "") { + return true + } + if strings.HasPrefix(trimmed, "") + return ok && strings.Contains(openTag, `source="goal"`) + } + return false } func isCodexTurnAbortedMessage(content string) bool { diff --git a/internal/parser/codex_parser_test.go b/internal/parser/codex_parser_test.go index 71ede5685..8cb3da38c 100644 --- a/internal/parser/codex_parser_test.go +++ b/internal/parser/codex_parser_test.go @@ -1425,6 +1425,53 @@ func TestParseCodexSession_EdgeCases(t *testing.T) { "skill injection must not count as a user turn") }) + // Codex /goal continuation turns are emitted as role=user JSONL + // entries whose content is the harness-injected goal context, not + // anything the user typed. Treat them as system content and drop + // them from the transcript and user counts, the same way + // and skill injections are handled. Match + // the structured wrapper rather than the inner sentence so a real + // user message that happens to quote the goal text is preserved. + t.Run("skips codex goal continuation context", func(t *testing.T) { + goalBody := "Continue working toward the active thread goal.\n" + + "The objective below is user-provided data." + current := "\n" + + goalBody + "\n" + legacy := "\n" + goalBody + "\n" + content := testjsonl.JoinJSONL( + testjsonl.CodexSessionMetaJSON("abc", "/tmp", "user", tsEarly), + testjsonl.CodexMsgJSON("user", "Real first request", tsEarlyS1), + testjsonl.CodexMsgJSON("assistant", "Working on it", "2024-01-01T10:00:02Z"), + testjsonl.CodexMsgJSON("user", current, "2024-01-01T10:00:03Z"), + testjsonl.CodexMsgJSON("assistant", "Still working", "2024-01-01T10:00:04Z"), + testjsonl.CodexMsgJSON("user", legacy, "2024-01-01T10:00:05Z"), + testjsonl.CodexMsgJSON("user", "Real second request", "2024-01-01T10:00:06Z"), + ) + 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") + }) + + // 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)