diff --git a/open-sse/executors/chatgpt-session/messages.ts b/open-sse/executors/chatgpt-session/messages.ts index 6a44648c7d..a19c854c84 100644 --- a/open-sse/executors/chatgpt-session/messages.ts +++ b/open-sse/executors/chatgpt-session/messages.ts @@ -42,6 +42,14 @@ function textFromContent(content: unknown): string { chunks.push(typed.text); continue; } + // `{ type: "refusal", refusal: "…" }` is a legal ASSISTANT content part in the + // chat-completions spec, and a client replaying its own conversation history sends it back + // verbatim. It is text as far as the adapter is concerned. A `refusal` part whose payload is + // missing or not a string falls through to the rejection below rather than being dropped. + if (typed && typed.type === "refusal" && typeof typed.refusal === "string") { + chunks.push(typed.refusal); + continue; + } if (typed && (typed.type === "image_url" || typed.type === "image")) { throw new ChatGptSessionInputError( "vision_unsupported", diff --git a/tests/unit/chatgpt-session-messages.test.ts b/tests/unit/chatgpt-session-messages.test.ts index d6b6de9f22..4805decc2e 100644 --- a/tests/unit/chatgpt-session-messages.test.ts +++ b/tests/unit/chatgpt-session-messages.test.ts @@ -134,3 +134,50 @@ test("rejects any content part that is neither text nor an image", () => { ); } }); + +test("an assistant refusal part is folded into the mapped content as text", () => { + const parsed = buildParsedRequest({ + route, + messages: [ + { role: "user", content: "one" }, + { + role: "assistant", + content: [ + { type: "refusal", refusal: "I can't help with that." }, + { type: "text", text: "Here is something else." }, + ], + }, + { role: "user", content: "three" }, + ], + stream: true, + }); + assert.equal(parsed.context.messages.length, 3); + assert.deepEqual(parsed.context.messages[1], { + role: "assistant", + content: [{ type: "text", text: "I can't help with that.\nHere is something else." }], + timestamp: parsed.context.messages[1].timestamp, + }); +}); + +test("a refusal part whose refusal field is not a string is still rejected", () => { + for (const part of [ + { type: "refusal" }, + { type: "refusal", refusal: null }, + { type: "refusal", refusal: { text: "nope" } }, + { type: "refusal", text: "wrong field" }, + ]) { + assert.throws( + () => + buildParsedRequest({ + route, + messages: [ + { role: "user", content: "one" }, + { role: "assistant", content: [part] }, + ], + stream: false, + }), + (error: unknown) => + error instanceof ChatGptSessionInputError && error.code === "unsupported_content_part" + ); + } +});