diff --git a/backend/internal/handler/openai_automation_bootstrap_test.go b/backend/internal/handler/openai_automation_bootstrap_test.go index 698a3bbd5e..d881315553 100644 --- a/backend/internal/handler/openai_automation_bootstrap_test.go +++ b/backend/internal/handler/openai_automation_bootstrap_test.go @@ -166,3 +166,44 @@ func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testi require.False(t, changedAgain) require.Equal(t, got, again) } + +func TestNormalizeCodexAutomationBootstrapFullHeartbeat(t *testing.T) { + const output = ` + flutter + 2026-09-09T00:33:34.775Z + +Read the reference index and report changes. Preserve A & B and <tags>. + +` + body := codexAutomationBootstrapBody(t, output, "") + got, changed := normalizeCodexAutomationBootstrap(body) + require.True(t, changed) + require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String()) + require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String()) + again, changedAgain := normalizeCodexAutomationBootstrap(got) + require.False(t, changedAgain) + require.Equal(t, got, again) + + for _, field := range []string{"automation_id", "current_time_iso", "instructions"} { + duplicate := strings.Replace(output, "", "<"+field+">duplicate", 1) + require.False(t, validCodexAutomationHeartbeat(duplicate), field) + } + for _, invalid := range []string{ + strings.Replace(output, "2026-09-09T00:33:34.775Z", "yesterday", 1), + strings.Replace(output, "2026-09-09T00:33:34.775Z", "", 1), + strings.Replace(output, "Read the reference index and report changes. Preserve A & B and <tags>.", " ", 1), + strings.Replace(output, "", ``, 1), + strings.Replace(output, "", "", 1), + } { + require.False(t, validCodexAutomationHeartbeat(invalid)) + } + for _, guarded := range [][]byte{ + codexAutomationBootstrapBody(t, output, `,"call_id":"call-1"`), + []byte(strings.Replace(string(body), `"model":"gpt-5"`, `"model":"gpt-5","previous_response_id":"resp-1"`, 1)), + []byte(strings.Replace(string(body), `"namespace":"codex_app"`, `"namespace":"other"`, 1)), + } { + unchanged, normalized := normalizeCodexAutomationBootstrap(guarded) + require.False(t, normalized) + require.Equal(t, guarded, unchanged) + } +} diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 15a7cca8a2..d327710423 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1889,12 +1889,24 @@ func validCodexAutomationLastRun(value string) bool { func validCodexAutomationHeartbeat(value string) bool { decoder := xml.NewDecoder(strings.NewReader(value)) var rootSeen, automationIDSeen bool - var automationID bytes.Buffer + var childName string + var childText bytes.Buffer + fields := make(map[string]string) depth := 0 for { token, err := decoder.Token() if err == io.EOF { - id := automationID.String() + id := fields["automation_id"] + timestamp, hasTime := fields["current_time_iso"] + instructions, hasInstructions := fields["instructions"] + if hasTime != hasInstructions { + return false + } + if hasTime { + if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil || strings.TrimSpace(instructions) == "" { + return false + } + } return rootSeen && automationIDSeen && depth == 0 && strings.TrimSpace(id) == id && validCodexAutomationID(id) } @@ -1912,21 +1924,33 @@ func validCodexAutomationHeartbeat(value string) bool { return false } rootSeen = true - } else if automationIDSeen || current.Name.Local != "automation_id" { - return false + } else { + childName = current.Name.Local + switch childName { + case "automation_id", "current_time_iso", "instructions": + default: + return false + } + if _, duplicate := fields[childName]; duplicate { + return false + } + childText.Reset() } - automationIDSeen = depth == 2 case xml.EndElement: if current.Name.Space != "" { return false } + if depth == 2 { + fields[childName] = childText.String() + automationIDSeen = automationIDSeen || childName == "automation_id" + } depth-- if depth < 0 { return false } case xml.CharData: if depth == 2 { - _, _ = automationID.Write(current) + _, _ = childText.Write(current) } else if len(bytes.TrimSpace(current)) != 0 { return false }