mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
fix(openai): accept scheduled automation bootstrap without call id
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user