diff --git a/src/wire/openai.ts b/src/wire/openai.ts index 2e856dd..40e35d4 100644 --- a/src/wire/openai.ts +++ b/src/wire/openai.ts @@ -36,15 +36,29 @@ export type OpenAIRequestBody = { [key: string]: unknown; }; -type Flat = { msgs: BiliMessage[] }; +type Flat = { msgs: BiliMessage[]; systemText: string }; +/** Hoist the contiguous leading system/developer prefix out of the fold + * space (openai-chat variant of the responses codec's systemParts and the + * anthropic codec's top-level `system` field). The system prompt is host + * runtime state: its content varies across restarts (injected reminders, + * host-composed instructions), so keeping it inside the id space made every + * downstream fingerprint spanning it unstable, and a compress range that + * covered it removed the model's system prompt from the rebuilt wire + * entirely. Mid-conversation system messages (rare, host-synthetic) stay in + * the fold space unchanged. */ export function openaiToCore(body: OpenAIRequestBody): Flat { const msgs: BiliMessage[] = []; + const systemParts: string[] = []; const clusters = new ClusterCounter(); for (const m of body.messages) { switch (m.role) { case "system": case "developer": { + if (msgs.length === 0) { + systemParts.push(stringContent(m.content)); + break; + } const base = deriveMessageId(m.role, "text", stringContent(m.content)); msgs.push({ id: clusters.next(base), role: "system", contentType: "text", text: stringContent(m.content), originalRole: m.role }); break; @@ -134,7 +148,7 @@ export function openaiToCore(body: OpenAIRequestBody): Flat { } } } - return { msgs }; + return { msgs, systemText: systemParts.join("\n\n") }; } export function coreToOpenai(messages: BiliMessage[]): OpenAIMessage[] { diff --git a/tests/openai-system-hoist.test.ts b/tests/openai-system-hoist.test.ts new file mode 100644 index 0000000..0c678f3 --- /dev/null +++ b/tests/openai-system-hoist.test.ts @@ -0,0 +1,115 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + openaiToCore, + coreToOpenai, + injectOpenaiSystem, +} from "../src/wire/openai.js"; +import { mirrorOpenaiToCore } from "../src/wire/mirror.js"; +import type { MirrorMessage } from "../src/wire/mirror.js"; + +const conv: Array> = [ + { role: "user", content: "hello" }, + { role: "assistant", content: "hi there" }, + { role: "user", content: "what is 2+2" }, + { role: "assistant", content: "4" }, +]; + +test("openaiToCore hoists the contiguous leading system/developer prefix out of the fold space", () => { + const body = { + model: "m", + messages: [ + { role: "system", content: "you are terse" }, + { role: "developer", content: "second system part" }, + ...conv, + ], + }; + const { msgs, systemText } = openaiToCore(body as never); + assert.equal(systemText, "you are terse\n\nsecond system part"); + assert.ok(msgs.length > 0); + assert.notEqual(msgs[0].role, "system"); + assert.ok( + msgs.every((m) => m.role !== "system"), + "no system pieces remain in the fold space", + ); + // conversation ids unchanged vs a body with no system prefix at all + const bare = openaiToCore({ model: "m", messages: conv } as never); + assert.deepEqual( + msgs.map((m) => m.id), + bare.msgs.map((m) => m.id), + ); +}); + +test("openaiToCore with no system prefix returns empty systemText", () => { + const { systemText } = openaiToCore({ model: "m", messages: conv } as never); + assert.equal(systemText, ""); +}); + +test("mid-conversation system messages stay in the fold space", () => { + const { msgs } = openaiToCore({ + model: "m", + messages: [ + ...conv.slice(0, 2), + { role: "system", content: "mid-stream system" }, + ...conv.slice(2), + ], + } as never); + assert.equal( + msgs.filter((m) => m.role === "system").length, + 1, + "mid-stream system kept as a fold piece", + ); +}); + +test("system content changes never shift conversation ids (restart regression)", () => { + // Live wire carries the host system prompt; after restart the prime-fold + // reconstruction differs. Ids must not depend on system content, or every + // span fingerprint covering pos 0 breaks and restart replay is rejected. + const a = openaiToCore({ + model: "m", + messages: [{ role: "system", content: "LIVE system, 45k chars of host state" }, ...conv], + } as never); + const b = openaiToCore({ + model: "m", + messages: [{ role: "system", content: "RECONSTRUCTED system, shorter" }, ...conv], + } as never); + assert.deepEqual( + a.msgs.map((m) => m.id), + b.msgs.map((m) => m.id), + ); +}); + +test("mirrorOpenaiToCore converges with the live space regardless of systemText", () => { + const t = (text: string): MirrorMessage => ({ role: "user", blocks: [{ type: "text", text }] }); + const a = (text: string): MirrorMessage => ({ role: "assistant", blocks: [{ type: "text", text }] }); + const view: MirrorMessage[] = [ + t("hello"), + a("hi there"), + t("what is 2+2"), + a("4"), + ]; + const live = openaiToCore({ model: "m", messages: conv } as never); + const mirrorA = mirrorOpenaiToCore(view, "live 45k system"); + const mirrorB = mirrorOpenaiToCore(view, ""); + assert.deepEqual( + mirrorA.map((m) => m.id), + live.msgs.map((m) => m.id), + ); + assert.deepEqual( + mirrorB.map((m) => m.id), + live.msgs.map((m) => m.id), + ); +}); + +test("coreToOpenai output has no head system; injectOpenaiSystem re-injects it", () => { + const { msgs, systemText } = openaiToCore({ + model: "m", + messages: [{ role: "system", content: "you are terse" }, ...conv], + } as never); + const rebuilt = coreToOpenai(msgs); + assert.notEqual(rebuilt[0]?.role, "system"); + const withSystem = injectOpenaiSystem(rebuilt, [systemText, "compress prompt"]); + assert.equal(withSystem[0]?.role, "system"); + assert.match(String(withSystem[0]?.content), /you are terse/); + assert.match(String(withSystem[0]?.content), /compress prompt/); +}); diff --git a/tests/wire-bili-message-roundtrip.test.ts b/tests/wire-bili-message-roundtrip.test.ts index db4d5aa..36b9833 100644 --- a/tests/wire-bili-message-roundtrip.test.ts +++ b/tests/wire-bili-message-roundtrip.test.ts @@ -92,24 +92,38 @@ test("anthropic: tool_result without is_error stays clean", () => { assert.equal(tr.is_error, undefined, "no spurious is_error"); }); -// 4. OpenAI developer role round-trips (was collapsed to system). -test("openai: developer role is restored", () => { - const body: OpenAIRequestBody = { messages: [{ role: "developer", content: "you are a dev" }] }; - const { msgs } = openaiToCore(body); - assert.equal(msgs[0]?.role, "system", "kernel sees system"); - assert.equal(msgs[0]?.originalRole, "developer", "originalRole sidecar stored"); - const rebuilt = coreToOpenai(msgs); - assert.equal(rebuilt[0]?.role, "developer", "developer role reconstructed"); - assert.equal(rebuilt[0]?.content, "you are a dev"); +// 4. OpenAI leading system/developer prefix is hoisted OUT of the fold space +// (restart-regression fix: host system content is runtime-unstable, so it must +// never enter the id/fingerprint space); originalRole still round-trips for +// mid-conversation system traffic. +test("openai: leading developer/system prefix is hoisted, not folded", () => { + const body: OpenAIRequestBody = { + messages: [ + { role: "developer", content: "you are a dev" }, + { role: "system", content: "sys" }, + { role: "user", content: "hi" }, + ], + }; + const { msgs, systemText } = openaiToCore(body); + assert.equal(systemText, "you are a dev\n\nsys", "prefix returned separately"); + assert.ok(msgs.every((m) => m.role !== "system"), "no system piece in the fold space"); + assert.equal(msgs[0]?.role, "user"); }); -// 4b. Sanity: a plain system role is NOT promoted to developer. -test("openai: system role stays system", () => { - const body: OpenAIRequestBody = { messages: [{ role: "system", content: "sys" }] }; +// 4b. Mid-conversation system traffic keeps the role sidecar round-trip. +test("openai: mid-conversation developer role is restored", () => { + const body: OpenAIRequestBody = { + messages: [ + { role: "user", content: "hi" }, + { role: "developer", content: "you are a dev" }, + ], + }; const { msgs } = openaiToCore(body); - assert.equal(msgs[0]?.originalRole, "system"); + const dev = msgs.find((m) => m.role === "system")!; + assert.equal(dev.originalRole, "developer", "originalRole sidecar stored"); const rebuilt = coreToOpenai(msgs); - assert.equal(rebuilt[0]?.role, "system"); + assert.equal(rebuilt[1]?.role, "developer", "developer role reconstructed"); + assert.equal(rebuilt[1]?.content, "you are a dev"); }); // 5. OpenAI image_url round-trips (image was previously dropped entirely).