diff --git a/backend/internal/handler/openai_automation_bootstrap_test.go b/backend/internal/handler/openai_automation_bootstrap_test.go
index 7bb7fd68b1..698a3bbd5e 100644
--- a/backend/internal/handler/openai_automation_bootstrap_test.go
+++ b/backend/internal/handler/openai_automation_bootstrap_test.go
@@ -49,6 +49,20 @@ func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) {
}
}
+func TestNormalizeCodexAutomationBootstrapHeartbeat(t *testing.T) {
+ output := `wiki`
+ got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, ""))
+ require.True(t, changed)
+ require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
+ require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
+ require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
+ require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
+
+ again, changedAgain := normalizeCodexAutomationBootstrap(got)
+ require.False(t, changedAgain)
+ require.Equal(t, got, again)
+}
+
func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
tests := []struct {
@@ -75,10 +89,6 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
name: "real call context",
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`),
},
- {
- name: "heartbeat output",
- body: codexAutomationBootstrapBody(t, `wiki`, ""),
- },
{
name: "mismatched memory id",
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""),
@@ -117,6 +127,31 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
}
}
+func TestNormalizeCodexAutomationBootstrapRejectsUnsafeHeartbeatShapes(t *testing.T) {
+ tests := []struct {
+ name string
+ output string
+ }{
+ {name: "arbitrary tool output", output: `wiki`},
+ {name: "root attribute", output: `wiki`},
+ {name: "namespaced root", output: `wiki`},
+ {name: "extra child", output: `wikiok`},
+ {name: "nested id content", output: `wiki`},
+ {name: "padded id", output: ` wiki `},
+ {name: "unsafe id", output: `../wiki`},
+ {name: "comment", output: `wiki`},
+ {name: "trailing content", output: `wikiextra`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ body := codexAutomationBootstrapBody(t, tt.output, "")
+ got, changed := normalizeCodexAutomationBootstrap(body)
+ require.False(t, changed)
+ require.Equal(t, body, got)
+ })
+ }
+}
+
func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`)
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index ce67e07e85..c895b90c53 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -1756,7 +1756,7 @@ func isCodexAutomationCandidate(item map[string]any) bool {
return false
}
output, ok := item["output"].(string)
- return ok && validCodexAutomationBootstrap(output)
+ return ok && (validCodexAutomationBootstrap(output) || validCodexAutomationHeartbeat(output))
}
func stringField(item map[string]any, key string) string {
@@ -1834,6 +1834,56 @@ func validCodexAutomationLastRun(value string) bool {
return err == nil && runAt.UnixMilli() == epochMillis
}
+func validCodexAutomationHeartbeat(value string) bool {
+ decoder := xml.NewDecoder(strings.NewReader(value))
+ var rootSeen, automationIDSeen bool
+ var automationID bytes.Buffer
+ depth := 0
+ for {
+ token, err := decoder.Token()
+ if err == io.EOF {
+ id := automationID.String()
+ return rootSeen && automationIDSeen && depth == 0 &&
+ strings.TrimSpace(id) == id && validCodexAutomationID(id)
+ }
+ if err != nil {
+ return false
+ }
+ switch current := token.(type) {
+ case xml.StartElement:
+ depth++
+ if current.Name.Space != "" || len(current.Attr) != 0 || depth > 2 {
+ return false
+ }
+ if depth == 1 {
+ if rootSeen || current.Name.Local != "heartbeat" {
+ return false
+ }
+ rootSeen = true
+ } else if automationIDSeen || current.Name.Local != "automation_id" {
+ return false
+ }
+ automationIDSeen = depth == 2
+ case xml.EndElement:
+ if current.Name.Space != "" {
+ return false
+ }
+ depth--
+ if depth < 0 {
+ return false
+ }
+ case xml.CharData:
+ if depth == 2 {
+ _, _ = automationID.Write(current)
+ } else if len(bytes.TrimSpace(current)) != 0 {
+ return false
+ }
+ case xml.Comment, xml.ProcInst, xml.Directive:
+ return false
+ }
+ }
+}
+
func validCodexDelegationEnvelope(value string) bool {
decoder := xml.NewDecoder(strings.NewReader(value))
var rootSeen, sourceSeen, inputSeen bool