fix(openai): accept scheduled automation bootstrap without call id

This commit is contained in:
WizardPro
2026-09-01 11:36:26 +08:00
parent a2fb09260a
commit 421a83282a
2 changed files with 225 additions and 3 deletions
@@ -0,0 +1,133 @@
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 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: "heartbeat output",
body: codexAutomationBootstrapBody(t, `<heartbeat><automation_id>wiki</automation_id></heartbeat>`, ""),
},
{
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 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)
}
@@ -438,6 +438,12 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
body = cappedBody
}
if normalizedBody, changed := normalizeCodexAutomationBootstrap(body); changed {
body = normalizedBody
reqLog.Info("openai.codex_automation_bootstrap_normalized",
zap.String("normalization", "call_output_to_user_message"),
)
}
if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed {
body = normalizedBody
reqLog.Info("openai.codex_delegation_bootstrap_normalized",
@@ -1591,6 +1597,14 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context,
}
func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate)
}
func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) {
return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate)
}
func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]byte, bool) {
if !hasUniqueJSONMembers(body) {
return body, false
}
@@ -1629,7 +1643,7 @@ func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
if exists && (!isString || strings.TrimSpace(callID) != "") {
return body, false
}
if !isCodexDelegationCandidate(item) {
if !isCandidate(item) {
return body, false
}
}
@@ -1638,11 +1652,11 @@ func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
changed := false
for i, raw := range input {
item, ok := raw.(map[string]any)
if !ok || !isCodexDelegationCandidate(item) {
if !ok || !isCandidate(item) {
continue
}
output, ok := item["output"].(string)
if !ok || !validCodexDelegationEnvelope(output) {
if !ok {
continue
}
input[i] = map[string]any{
@@ -1732,6 +1746,16 @@ func isCodexDelegationCandidate(item map[string]any) bool {
return ok && validCodexDelegationEnvelope(output)
}
func isCodexAutomationCandidate(item map[string]any) bool {
if stringField(item, "type") != "function_call_output" ||
stringField(item, "namespace") != "codex_app" ||
stringField(item, "name") != "automation_update" {
return false
}
output, ok := item["output"].(string)
return ok && validCodexAutomationBootstrap(output)
}
func stringField(item map[string]any, key string) string {
value, _ := item[key].(string)
return value
@@ -1742,6 +1766,71 @@ func isCodexDelegationTool(namespace, name string) bool {
(name == "create_thread" || name == "send_message_to_thread")
}
func validCodexAutomationBootstrap(value string) bool {
normalized := strings.ReplaceAll(value, "\r\n", "\n")
if strings.ContainsRune(normalized, '\r') {
return false
}
lines := strings.Split(normalized, "\n")
if len(lines) < 6 {
return false
}
if _, ok := codexAutomationHeaderValue(lines[0], "Automation: "); !ok {
return false
}
automationID, ok := codexAutomationHeaderValue(lines[1], "Automation ID: ")
if !ok || !validCodexAutomationID(automationID) {
return false
}
expectedMemory := "Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md"
if lines[2] != expectedMemory {
return false
}
lastRun, ok := codexAutomationHeaderValue(lines[3], "Last run: ")
if !ok || !validCodexAutomationLastRun(lastRun) || lines[4] != "" {
return false
}
return strings.TrimSpace(strings.Join(lines[5:], "\n")) != ""
}
func codexAutomationHeaderValue(line, prefix string) (string, bool) {
if !strings.HasPrefix(line, prefix) {
return "", false
}
value := strings.TrimPrefix(line, prefix)
return value, value != "" && strings.TrimSpace(value) == value
}
func validCodexAutomationID(value string) bool {
if len(value) == 0 || len(value) > 128 || value == "." || value == ".." {
return false
}
for i := 0; i < len(value); i++ {
c := value[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' {
continue
}
return false
}
return true
}
func validCodexAutomationLastRun(value string) bool {
if value == "never" {
return true
}
separator := strings.LastIndex(value, " (")
if separator <= 0 || !strings.HasSuffix(value, ")") {
return false
}
runAt, err := time.Parse(time.RFC3339Nano, value[:separator])
if err != nil {
return false
}
epochMillis, err := strconv.ParseInt(value[separator+2:len(value)-1], 10, 64)
return err == nil && runAt.UnixMilli() == epochMillis
}
func validCodexDelegationEnvelope(value string) bool {
decoder := xml.NewDecoder(strings.NewReader(value))
var rootSeen, sourceSeen, inputSeen bool