From 1dd7ec91fe414c9caa6f324d56e054cf7bfe7d0c Mon Sep 17 00:00:00 2001 From: Payne Date: Sat, 25 Apr 2026 13:39:40 +0000 Subject: [PATCH] chatgpt-web: fold history into system message to fix turn concatenation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by user testing in Open WebUI: across three turns user "test 1" -> "1" user "test 2" -> "12" (should be "2") user "test 3. reply only with 3" -> "1123" (should be "3") The model was literally APPENDING prior assistant outputs into the new generation instead of producing a fresh response. Root cause: when sending each prior turn as a separate `assistant`-role entry in `/backend-api/f/conversation`'s `messages` array, ChatGPT's web API ("action: next") treats those as in-progress messages the model can continue rather than as completed turns. So the new generation extends the most recent assistant message. Fix: don't replay prior turns as separate messages. Instead fold the full history into the system message as plain text and send only the current user query as a single new turn. Verified end-to-end: Turn 1 -> "1" Turn 2 -> "2" Turn 3 -> "3" user "favorite color is teal" / assistant "Got it" / user "what color?" -> "Teal" (memory still preserved through the system-message channel) Streaming + multi-turn -> correct, real-time chunks Updated two unit tests that previously asserted history items showed up as separate `user`/`assistant` messages in the request — they now check for the single-user-message + history-in-system-message shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- open-sse/executors/chatgpt-web.ts | 39 +++++++++++++++++++------------ tests/unit/chatgpt-web.test.ts | 15 ++++++++---- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 673495449d..9a4eb3cf36 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -649,28 +649,37 @@ function buildConversationBody( conversationId: string | null, parentMessageId: string ): Record { - const messages: ChatGptMessage[] = []; + // Critical: do NOT send prior turns as separate `assistant` and `user` + // messages in the `messages` array. ChatGPT's web API ("action: next") + // treats those as in-progress turns and the model will literally CONTINUE + // a prior assistant response in the new generation — observed as + // `[1] -> [12] -> [1123]` across three turns. + // + // Instead, fold all prior history into the system message and send only + // the current user message as a single new turn. The model then sees a + // single prompt with full context and responds fresh. + const systemParts: string[] = []; + if (parsed.systemMsg.trim()) { + systemParts.push(parsed.systemMsg.trim()); + } + if (parsed.history.length > 0 && !conversationId) { + const formatted = parsed.history + .map((h) => `${h.role === "assistant" ? "Assistant" : "User"}: ${h.content}`) + .join("\n\n"); + systemParts.push( + `Prior conversation (for context — answer only the new user message below):\n\n${formatted}` + ); + } - if (parsed.systemMsg.trim() && !conversationId) { + const messages: ChatGptMessage[] = []; + if (systemParts.length > 0 && !conversationId) { messages.push({ id: randomUUID(), author: { role: "system" }, - content: { content_type: "text", parts: [parsed.systemMsg.trim()] }, + content: { content_type: "text", parts: [systemParts.join("\n\n")] }, }); } - // Replay history only on a brand-new conversation; ChatGPT remembers prior turns - // server-side once a conversation_id exists. - if (!conversationId) { - for (const h of parsed.history) { - messages.push({ - id: randomUUID(), - author: { role: h.role }, - content: { content_type: "text", parts: [h.content] }, - }); - } - } - messages.push({ id: randomUUID(), author: { role: "user" }, diff --git a/tests/unit/chatgpt-web.test.ts b/tests/unit/chatgpt-web.test.ts index 081e9b42fe..1f1ec730b3 100644 --- a/tests/unit/chatgpt-web.test.ts +++ b/tests/unit/chatgpt-web.test.ts @@ -805,9 +805,15 @@ test("Session continuity: each call starts a fresh conversation (Temporary Chat assert.equal(convIndices.length, 2); const secondBody = JSON.parse(m.calls.bodies[convIndices[1]]); assert.equal(secondBody.conversation_id, null, "should start a fresh conversation"); - // The full history should be replayed in the messages array. + // History is folded into the system message (so the model doesn't try to + // continue prior assistant turns); only the latest user message is sent. const userMessages = secondBody.messages.filter((m) => m.author?.role === "user"); - assert.equal(userMessages.length, 2, "should include First question + Follow-up"); + assert.equal(userMessages.length, 1, "only the latest user message is in the messages array"); + assert.equal(userMessages[0].content.parts[0], "Follow-up"); + const systemMsg = secondBody.messages.find((m) => m.author?.role === "system"); + assert.ok(systemMsg, "history should be packaged in a system message"); + assert.match(systemMsg.content.parts[0], /First question/); + assert.match(systemMsg.content.parts[0], /Hello, world!/); } finally { m.restore(); } @@ -864,9 +870,10 @@ test("Request: payload has correct ChatGPT shape", async () => { assert.equal(body.action, "next"); assert.equal(body.model, "gpt-5-3"); assert.equal(body.history_and_training_disabled, true); - // System + user in messages + // System message preserves the user-supplied system prompt; the user + // message is the latest query. assert.equal(body.messages[0].author.role, "system"); - assert.equal(body.messages[0].content.parts[0], "Be concise"); + assert.match(body.messages[0].content.parts[0], /Be concise/); assert.equal(body.messages[body.messages.length - 1].author.role, "user"); assert.equal(body.messages[body.messages.length - 1].content.parts[0], "What is 2+2?"); } finally {