From e45490a36031474f19888b14cbcb55d18945a801 Mon Sep 17 00:00:00 2001 From: MokoYee <33444584+MokoYee@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:08:48 +0800 Subject: [PATCH] fix(openai): stabilize chat sticky hash across dynamic system messages --- .../service/openai_content_session_seed.go | 17 ++-- .../openai_content_session_seed_test.go | 77 +++++++++++++++++-- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/backend/internal/service/openai_content_session_seed.go b/backend/internal/service/openai_content_session_seed.go index 5f8f1872e..2acf29872 100644 --- a/backend/internal/service/openai_content_session_seed.go +++ b/backend/internal/service/openai_content_session_seed.go @@ -18,8 +18,9 @@ const contentStablePrefixSessionSeedPrefix = "compat_csp_" // deriveOpenAIContentSessionSeed builds a stable session seed from an // OpenAI-format request body. Only fields constant across conversation turns -// are included: model, tools/functions definitions, system/developer prompts, -// instructions (Responses API), and the first user message. +// are included: model, tools/functions definitions, the leading system/developer +// prompt prefix in Chat messages, instructions (Responses API), and the first +// user message. // Supports both Chat Completions (messages) and Responses API (input). func deriveOpenAIContentSessionSeed(body []byte) string { if len(body) == 0 { @@ -117,15 +118,19 @@ scanRoot: msgs := fields[messagesField] if msgs.Exists() && msgs.IsArray() { + systemPrefixOpen := true msgs.ForEach(func(_, msg gjson.Result) bool { role := msg.Get("role").String() switch role { case "system", "developer": - _, _ = b.WriteString("|system=") - if c := msg.Get("content"); c.Exists() { - _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + if systemPrefixOpen { + _, _ = b.WriteString("|system=") + if c := msg.Get("content"); c.Exists() { + _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + } } case "user": + systemPrefixOpen = false if !firstUserCaptured { _, _ = b.WriteString("|first_user=") if c := msg.Get("content"); c.Exists() { @@ -133,6 +138,8 @@ scanRoot: } firstUserCaptured = true } + default: + systemPrefixOpen = false } return true }) diff --git a/backend/internal/service/openai_content_session_seed_test.go b/backend/internal/service/openai_content_session_seed_test.go index 0c6c63335..5b621e3ce 100644 --- a/backend/internal/service/openai_content_session_seed_test.go +++ b/backend/internal/service/openai_content_session_seed_test.go @@ -44,6 +44,65 @@ func TestDeriveOpenAIContentSessionSeed_ChatCompletions_StableAcrossTurns(t *tes require.NotEmpty(t, s1) } +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_IgnoresLaterSystemMessages(t *testing.T) { + turn1 := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "How are you?"} + ] + }`) + turn2 := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "system", "content": "Return JSON for this turn."}, + {"role": "user", "content": "How are you?"} + ] + }`) + + require.Equal(t, deriveOpenAIContentSessionSeed(turn1), deriveOpenAIContentSessionSeed(turn2)) +} + +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_UsesLeadingSystemDeveloperPrefix(t *testing.T) { + firstSystem := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "System A"}, + {"role": "developer", "content": "Developer B"}, + {"role": "user", "content": "Hello"} + ] + }`) + changedLaterSystem := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "System A"}, + {"role": "developer", "content": "Developer C"}, + {"role": "user", "content": "Hello"} + ] + }`) + + seed := deriveOpenAIContentSessionSeed(firstSystem) + require.Contains(t, seed, "System A") + require.Contains(t, seed, "Developer B") + require.NotEqual(t, seed, deriveOpenAIContentSessionSeed(changedLaterSystem)) + + withLaterSystem := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "System A"}, + {"role": "developer", "content": "Developer B"}, + {"role": "user", "content": "Hello"}, + {"role": "system", "content": "Dynamic system"} + ] + }`) + require.Equal(t, seed, deriveOpenAIContentSessionSeed(withLaterSystem)) +} + func TestDeriveOpenAIContentSessionSeed_ChatCompletions_DifferentFirstUserDiffers(t *testing.T) { req1 := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Question A"}]}`) req2 := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Question B"}]}`) @@ -270,7 +329,7 @@ func TestDeriveOpenAIContentSessionSeed_SingleScanMatchesLegacyBytes(t *testing. for _, test := range tests { t.Run(test.name, func(t *testing.T) { - require.Equal(t, legacyDeriveOpenAIContentSessionSeed(test.body), deriveOpenAIContentSessionSeed(test.body)) + require.Equal(t, referenceDeriveOpenAIContentSessionSeed(test.body), deriveOpenAIContentSessionSeed(test.body)) }) } } @@ -283,12 +342,12 @@ func TestDeriveOpenAIContentSessionSeed_AllTruncationOffsetsMatchLegacyBytes(t * for bodyIndex, body := range bodies { for end := 1; end < len(body); end++ { truncated := []byte(body[:end]) - require.Equalf(t, legacyDeriveOpenAIContentSessionSeed(truncated), deriveOpenAIContentSessionSeed(truncated), "body %d truncated at byte %d", bodyIndex, end) + require.Equalf(t, referenceDeriveOpenAIContentSessionSeed(truncated), deriveOpenAIContentSessionSeed(truncated), "body %d truncated at byte %d", bodyIndex, end) } } } -func legacyDeriveOpenAIContentSessionSeed(body []byte) string { +func referenceDeriveOpenAIContentSessionSeed(body []byte) string { if len(body) == 0 { return "" } @@ -319,15 +378,19 @@ func legacyDeriveOpenAIContentSessionSeed(body []byte) string { msgs := gjson.GetBytes(body, "messages") if msgs.Exists() && msgs.IsArray() { + systemPrefixOpen := true msgs.ForEach(func(_, msg gjson.Result) bool { role := msg.Get("role").String() switch role { case "system", "developer": - _, _ = b.WriteString("|system=") - if c := msg.Get("content"); c.Exists() { - _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + if systemPrefixOpen { + _, _ = b.WriteString("|system=") + if c := msg.Get("content"); c.Exists() { + _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + } } case "user": + systemPrefixOpen = false if !firstUserCaptured { _, _ = b.WriteString("|first_user=") if c := msg.Get("content"); c.Exists() { @@ -335,6 +398,8 @@ func legacyDeriveOpenAIContentSessionSeed(body []byte) string { } firstUserCaptured = true } + default: + systemPrefixOpen = false } return true })