diff --git a/src/wire/responses.ts b/src/wire/responses.ts index f31cb8c..97d0e26 100644 --- a/src/wire/responses.ts +++ b/src/wire/responses.ts @@ -90,6 +90,19 @@ function isOpaqueItem(item: ResponseInputItem): boolean { return OPAQUE_ITEM_TYPES.has(item.type); } +const EASY_INPUT_ROLES = new Set(["user", "assistant", "system", "developer"]); + +/** OpenAI's EasyInputMessage shorthand legally omits `type` (e.g. omp sends + * `{ role: "user", content: "..." }`); without normalization those items fall + * through the switch's `default` into the preamble and are never folded. */ +function normalizeEasyInputItem(item: ResponseInputItem): ResponseInputItem { + if (typeof item.type === "string") return item; + if (EASY_INPUT_ROLES.has(String((item as { role?: unknown }).role))) { + return { ...(item as { [key: string]: unknown }), type: "message" } as ResponseInputMessage; + } + return item; +} + function shouldDropAllReasoning(): boolean { return (process.env.ACP_REASONING_KEEP ?? "").trim().toLowerCase() === "none"; } @@ -120,7 +133,8 @@ export function responsesToCore(body: ResponsesRequestBody): ResponsesProjection msgs.push({ id, role: "user", contentType: "text", text: body.input }); return { msgs, systemParts, preamble, customToolCallIds, layout, droppedReasoning, stringInput: { original: body.input, coreId: id } }; } - for (const item of body.input) { + for (const raw of body.input) { + const item = normalizeEasyInputItem(raw); let coreId: string | undefined; if (isOpaqueItem(item)) preamble.push(item); switch (item.type) { diff --git a/tests/wire-bili-message-roundtrip.test.ts b/tests/wire-bili-message-roundtrip.test.ts index db4d5aa..72bb397 100644 --- a/tests/wire-bili-message-roundtrip.test.ts +++ b/tests/wire-bili-message-roundtrip.test.ts @@ -2,10 +2,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { anthropicToCore, coreToAnthropic } from "../src/wire/anthropic.js"; import { openaiToCore, coreToOpenai } from "../src/wire/openai.js"; -import { responsesToCore, coreToResponses } from "../src/wire/responses.js"; +import { responsesToCore, coreToResponses, patchResponsesInput } from "../src/wire/responses.js"; import type { AnthropicBlock, AnthropicRequestBody } from "../src/wire/anthropic.js"; import type { OpenAIRequestBody } from "../src/wire/openai.js"; -import type { ResponsesRequestBody } from "../src/wire/responses.js"; +import type { ResponseInputItem, ResponsesRequestBody } from "../src/wire/responses.js"; const IMG_DATA = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; const DATA_URL = `data:image/png;base64,${IMG_DATA}`; @@ -432,3 +432,68 @@ test("responses: ACP_REASONING_KEEP=none drops reasoning but keeps call items", else process.env.ACP_REASONING_KEEP = prev; } }); + +// 10. OpenAI's EasyInputMessage shorthand legally omits `type` (omp and bare +// /v1/responses clients send `{ role, content }`): those items must fold +// exactly like their typed `message` form instead of falling into the preamble. +test("responses: EasyInputMessage without type folds like a typed message", () => { + const body: ResponsesRequestBody = { + input: [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi there" }, + { type: "message", role: "user", content: "second" }, + ] as unknown as ResponseInputItem[], + }; + const { msgs, preamble, systemParts } = responsesToCore(body); + assert.equal(preamble.length, 0, "type-less messages are not opaque"); + assert.equal(msgs.length, 3); + assert.equal(msgs[0]?.role, "user"); + assert.equal(msgs[0]?.text, "hello"); + assert.equal(msgs[1]?.role, "assistant"); + assert.equal(msgs[1]?.text, "hi there"); + assert.equal(systemParts.length, 0); +}); + +// 11. Folded text splices back into the EasyInputMessage slot; re-emitted +// items carry the canonical typed form (interchangeable on the wire). +test("responses: EasyInputMessage round-trips through patchResponsesInput", () => { + const body: ResponsesRequestBody = { + input: [ + { role: "user", content: "fold me" }, + { role: "assistant", content: "ok" }, + { role: "user", content: "and me" }, + ] as unknown as ResponseInputItem[], + }; + const projection = responsesToCore(body); + const patched = projection.msgs.map((m, i) => (i === 0 ? { ...m, text: `[sum] ${m.text}` } : m)); + const rebuilt = patchResponsesInput(projection, patched); + assert.ok(Array.isArray(rebuilt)); + const first = rebuilt[0] as { type?: string; role?: string; content?: unknown }; + assert.equal(first.type, "message", "re-emitted in canonical typed form"); + assert.equal(first.role, "user"); + assert.equal(first.content, "[sum] fold me"); + assert.equal((rebuilt[1] as { content?: unknown }).content, "ok", "untouched slots keep content"); +}); + +// 12. Type-less system/developer shorthands follow the typed path into +// systemParts (hosts re-inject them via injectResponsesDeveloperMessage). +test("responses: EasyInputMessage system role joins systemParts", () => { + const body: ResponsesRequestBody = { + instructions: "be brief", + input: [{ role: "system", content: "extra rules" }] as unknown as ResponseInputItem[], + }; + const { msgs, systemParts } = responsesToCore(body); + assert.deepEqual(systemParts, ["be brief", "extra rules"]); + assert.equal(msgs.length, 0); +}); + +// 13. Items with neither `type` nor a message `role` stay opaque. +test("responses: type-less non-message items stay in the preamble", () => { + const body: ResponsesRequestBody = { + input: [{ foo: "bar" }] as unknown as ResponseInputItem[], + }; + const { msgs, preamble } = responsesToCore(body); + assert.equal(msgs.length, 0); + assert.equal(preamble.length, 1); + assert.deepEqual(preamble[0], { foo: "bar" }); +});