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"+field+">", 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
}