diff --git a/internal/parser/codex.go b/internal/parser/codex.go index c2300fc..8e3e9db 100644 --- a/internal/parser/codex.go +++ b/internal/parser/codex.go @@ -141,12 +141,16 @@ func buildTraceFromCodex(entries []codexEntry, sourcePath string) (*trace.Trace, model = inf.Model } - if entry.ID != "" && seenInference[entry.ID] { - continue + dedupKey := entry.ID + if dedupKey == "" { + // Empty IDs used to bypass dedup and double-count tokens/cost. + dedupKey = fmt.Sprintf("%s|%d|%d|%d|%s", + inf.Model, inf.InputTokens, inf.OutputTokens, inf.ReasoningOut, entry.Timestamp) } - if entry.ID != "" { - seenInference[entry.ID] = true + if seenInference[dedupKey] { + continue } + seenInference[dedupKey] = true if currentIter != nil { currentIter.Tokens.InputTokens += inf.InputTokens diff --git a/internal/parser/codex_dedup_test.go b/internal/parser/codex_dedup_test.go new file mode 100644 index 0000000..a5b3204 --- /dev/null +++ b/internal/parser/codex_dedup_test.go @@ -0,0 +1,83 @@ +package parser + +import ( + "encoding/json" + "testing" +) + +func TestBuildTraceFromCodex_DedupsEmptyInferenceIDs(t *testing.T) { + inf, err := json.Marshal(codexInference{ + Model: "gpt-5", + InputTokens: 100, + OutputTokens: 50, + }) + if err != nil { + t.Fatal(err) + } + + entries := []codexEntry{ + { + Type: "tool_call_started", + ID: "tool-1", + SessionID: "s1", + Timestamp: "2026-07-18T10:00:00.000Z", + Data: json.RawMessage(`{"name":"Edit","input":"{\"file_path\":\"a.go\"}"}`), + }, + { + Type: "inference_completed", + ID: "", + SessionID: "s1", + Timestamp: "2026-07-18T10:00:01.000Z", + Data: inf, + }, + { + // Duplicate empty-ID inference with identical payload/timestamp. + Type: "inference_completed", + ID: "", + SessionID: "s1", + Timestamp: "2026-07-18T10:00:01.000Z", + Data: inf, + }, + } + + tr, err := buildTraceFromCodex(entries, "synthetic.codex") + if err != nil { + t.Fatalf("buildTraceFromCodex: %v", err) + } + it := tr.Get(0) + if it == nil { + t.Fatal("expected iteration") + } + if it.Tokens.InputTokens != 100 { + t.Fatalf("InputTokens = %d, want 100 (deduped once)", it.Tokens.InputTokens) + } + if it.Tokens.OutputTokens != 50 { + t.Fatalf("OutputTokens = %d, want 50 (deduped once)", it.Tokens.OutputTokens) + } +} + +func TestBuildTraceFromCodex_CountsDistinctEmptyIDInferences(t *testing.T) { + inf1, _ := json.Marshal(codexInference{Model: "gpt-5", InputTokens: 10, OutputTokens: 5}) + inf2, _ := json.Marshal(codexInference{Model: "gpt-5", InputTokens: 20, OutputTokens: 7}) + entries := []codexEntry{ + { + Type: "tool_call_started", + SessionID: "s1", + Timestamp: "2026-07-18T10:00:00.000Z", + Data: json.RawMessage(`{"name":"Edit","input":"{\"file_path\":\"a.go\"}"}`), + }, + {Type: "inference_completed", Timestamp: "2026-07-18T10:00:01.000Z", Data: inf1}, + {Type: "inference_completed", Timestamp: "2026-07-18T10:00:02.000Z", Data: inf2}, + } + tr, err := buildTraceFromCodex(entries, "synthetic.codex") + if err != nil { + t.Fatal(err) + } + it := tr.Get(0) + if it.Tokens.InputTokens != 30 { + t.Fatalf("InputTokens = %d, want 30", it.Tokens.InputTokens) + } + if it.Tokens.OutputTokens != 12 { + t.Fatalf("OutputTokens = %d, want 12", it.Tokens.OutputTokens) + } +}