package handler import ( "strings" "testing" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) const automationBootstrapPrompt = "Review the project and report any important changes." func codexAutomationBootstrap(automationID, lastRun, prompt string) string { return "Automation: Scheduled project review\n" + "Automation ID: " + automationID + "\n" + "Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md\n" + "Last run: " + lastRun + "\n\n" + prompt } func codexAutomationBootstrapBody(t *testing.T, output, callID string) []byte { t.Helper() return []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + callID + `}]}`) } func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) { tests := []struct { name string lastRun string crlf bool }{ {name: "never", lastRun: "never"}, {name: "timestamp", lastRun: "2026-09-01T02:06:34.536Z (1788228394536)"}, {name: "crlf", lastRun: "never", crlf: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { output := codexAutomationBootstrap("wiki-maintenance", tt.lastRun, automationBootstrapPrompt) if tt.crlf { output = strings.ReplaceAll(output, "\n", "\r\n") } 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()) }) } } 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 { name string body []byte }{ { name: "ordinary missing call id", body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"other","output":` + mustJSON(t, validOutput) + `}]}`), }, { name: "tui namespace", body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_tui","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`), }, { name: "valid call id", body: codexAutomationBootstrapBody(t, validOutput, `,"call_id":"call-1"`), }, { name: "previous response", body: []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`), }, { 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: "mismatched memory id", body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""), }, { name: "unsafe automation id", body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("../wiki", "never", automationBootstrapPrompt), ""), }, { name: "invalid timestamp", body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "yesterday", automationBootstrapPrompt), ""), }, { name: "mismatched timestamp epoch", body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "2026-09-01T02:06:34.536Z (1)", automationBootstrapPrompt), ""), }, { name: "missing separator", body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "\n\n"+automationBootstrapPrompt, "\n"+automationBootstrapPrompt, 1), ""), }, { name: "empty prompt", body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "never", " \n"), ""), }, { name: "mixed missing call id output", body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"computer_call_output","output":"done"}]}`), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, changed := normalizeCodexAutomationBootstrap(tt.body) require.False(t, changed) require.Equal(t, tt.body, got) }) } } 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"}]}`) got, changed := normalizeCodexAutomationBootstrap(body) require.True(t, changed) require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String()) require.Equal(t, output, gjson.GetBytes(got, "input.1.content.0.text").String()) require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String()) again, changedAgain := normalizeCodexAutomationBootstrap(got) 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) } }