From d18f00b9166e584c4801dd5f56178765a33749e0 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Sat, 22 Aug 2026 17:44:54 +0800 Subject: [PATCH] feat(wire): mirror constructors for primeFold projections (Phase 2) Move the three hand-rolled wire mirrors out of the omp plugin (wire-fold.ts) into the kernel, next to the codecs that define their identity space. Protocol knowledge stops being scattered: one place fixed no longer breaks another (issue #64 class). - MirrorMessage/MirrorBlock: neutral view, caller pre-normalizes text (ref-tag stripping stays a host-app concern) - mirrorOpenaiMessages / mirrorAnthropicMessages / mirrorResponsesInput: host-encoder wire rules per protocol family (thinking placement, whitespace handling, tool shapes) - mirrorOpenaiToCore / mirrorAnthropicToCore / mirrorResponsesToCore: fold through the matching codec - parity tests: mirror ids == live reasoning_content wire == live inline- wire (issues #103, #64 and its demoted + responses variants) --- src/wire/index.ts | 1 + src/wire/mirror.ts | 212 +++++++++++++++++++++++++++++++ tests/wire-mirror.test.ts | 259 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 472 insertions(+) create mode 100644 src/wire/mirror.ts create mode 100644 tests/wire-mirror.test.ts diff --git a/src/wire/index.ts b/src/wire/index.ts index e77f9e3..a2b378b 100644 --- a/src/wire/index.ts +++ b/src/wire/index.ts @@ -5,4 +5,5 @@ export * from "./formats.js"; export * from "./bili-message.js"; export * from "./message-id.js"; export * from "./demoted-thinking.js"; +export * from "./mirror.js"; export * from "./util.js"; diff --git a/src/wire/mirror.ts b/src/wire/mirror.ts new file mode 100644 index 0000000..d0b2aa3 --- /dev/null +++ b/src/wire/mirror.ts @@ -0,0 +1,212 @@ +import { anthropicToCore } from "./anthropic.js"; +import { openaiToCore } from "./openai.js"; +import { responsesToCore } from "./responses.js"; +import type { BiliMessage } from "./bili-message.js"; + +/** + * Mirror constructors: rebuild the WIRE-SHAPE projection of a persisted + * conversation (the mirror of "what the host will put on the wire after a + * restart") for each protocol family, then fold it through the matching + * `*ToCore` codec so the projection lands in the same identity/fingerprint + * space as the live request. + * + * This used to live as three hand-rolled builders in the omp plugin + * (wire-fold.ts) — protocol knowledge scattered across consumers is exactly + * how the issue-#64 class of restart divergences happened (one place fixed, + * another broke). The wire layouts now live here, next to the codecs that + * define their identity space. + * + * CONTRACT: the caller maps its own persisted message shape into + * {@link MirrorMessage} FIRST and normalizes text there (e.g. ref-tag + * stripping is a host-app concern, not a wire concern). Builders only apply + * host-encoder wire rules: + * - thinking rides each wire the way the live encoder sends it + * (openai: `reasoning_content` field — hosts that demote inline as + * `` land in the same identity space anyway because + * `openaiToCore` normalizes the inline form; anthropic: signed + * `{type:"thinking"}` blocks; responses: `{type:"reasoning"}` items); + * - whitespace-only text survives on the openai wire, is dropped on the + * anthropic/responses wires (host encoder behaviour); + * - tool calls/results map to each wire's native shape. + */ + +export type MirrorBlock = + | { type: "text"; text: string } + | { type: "thinking"; thinking: string; signature?: string } + | { type: "toolCall"; id?: string; name?: string; arguments?: unknown }; + +export type MirrorMessage = { + /** `meta` is anything the host sends as out-of-band/system-ish traffic. */ + role: "user" | "assistant" | "toolResult" | "meta"; + blocks?: MirrorBlock[]; + /** toolResult only. */ + toolCallId?: string; + /** meta only: extracted text or summary. */ + text?: string; +}; + +function textBlocks(blocks: MirrorBlock[] | undefined): string[] { + const out: string[] = []; + for (const b of blocks ?? []) if (b.type === "text") out.push(b.text); + return out; +} + +/** Openai wire text: text blocks joined with "\n" (whitespace-only kept). */ +function joinText(blocks: MirrorBlock[] | undefined): string { + return textBlocks(blocks).join("\n"); +} + +function thinkingText(blocks: MirrorBlock[] | undefined): string { + return blocks + ?.filter((b) => b.type === "thinking" && b.thinking.trim().length > 0) + .map((b) => (b as { thinking: string }).thinking) + .join("\n") ?? ""; +} + +/** Openai/completions mirror: system message first, then the conversation + * with thinking as the `reasoning_content` field (issue #103). */ +export function mirrorOpenaiMessages(view: MirrorMessage[], systemText: string): Array> { + const messages: Array> = [{ role: "system", content: systemText }]; + for (const message of view) { + if (message.role === "user") { + const text = joinText(message.blocks); + if (text) messages.push({ role: "user", content: text }); + } else if (message.role === "assistant") { + const calls = (message.blocks ?? []).filter((b) => b.type === "toolCall") as Array<{ id?: string; name?: string; arguments?: unknown }>; + const reasoning = thinkingText(message.blocks); + const text = joinText(message.blocks); + if (calls.length > 0) { + messages.push({ + role: "assistant", + content: text, + ...(reasoning ? { reasoning_content: reasoning } : {}), + tool_calls: calls.map((c) => ({ + id: c.id, + type: "function", + function: { name: c.name ?? "", arguments: JSON.stringify(c.arguments ?? {}) }, + })), + }); + } else if (text || reasoning) { + messages.push({ role: "assistant", content: text, ...(reasoning ? { reasoning_content: reasoning } : {}) }); + } + } else if (message.role === "toolResult") { + messages.push({ role: "tool", tool_call_id: message.toolCallId ?? "", content: joinText(message.blocks) }); + } else { + const text = message.text ?? ""; + if (text) messages.push({ role: "developer", content: text }); + } + } + return messages; +} + +/** Anthropic/messages mirror: no system message (the live request carries it + * as the top-level `system` field, out of the fold space — issue #64), tool + * results folded into user messages, thinking as signed `{type:"thinking"}` + * blocks (issue #103). Unsigned thinking is demoted to text by the live + * encoder; sending it as a thinking block diverges, so callers that persist + * unsigned thinking should send it as a text block instead. */ +export function mirrorAnthropicMessages(view: MirrorMessage[]): Array> { + const messages: Array> = []; + for (const message of view) { + if (message.role === "user") { + const text = joinText(message.blocks); + if (text) messages.push({ role: "user", content: [{ type: "text", text }] }); + } else if (message.role === "assistant") { + const content: Array> = []; + for (const b of message.blocks ?? []) { + if (b.type === "thinking" && b.thinking.trim().length > 0) { + content.push({ + type: "thinking", + thinking: b.thinking, + ...(typeof b.signature === "string" && b.signature ? { signature: b.signature } : {}), + }); + } else if (b.type === "text" && b.text.trim().length > 0) { + content.push({ type: "text", text: b.text }); + } else if (b.type === "toolCall") { + let input: unknown = {}; + try { + input = b.arguments && typeof b.arguments === "object" ? b.arguments : JSON.parse(JSON.stringify(b.arguments ?? {})); + } catch { + input = {}; + } + content.push({ type: "tool_use", id: b.id, name: b.name ?? "", input }); + } + } + if (content.length > 0) messages.push({ role: "assistant", content }); + } else if (message.role === "toolResult") { + messages.push({ + role: "user", + content: [{ type: "tool_result", tool_use_id: message.toolCallId ?? "", content: joinText(message.blocks) }], + }); + } else { + const text = message.text ?? ""; + if (text) messages.push({ role: "user", content: [{ type: "text", text }] }); + } + } + return messages; +} + +/** Responses mirror: the live /v1/responses request carries the system + * prompt in the top-level `instructions` field and the conversation as an + * `input` item array (issue #64, responses variant). Assistant blocks are + * emitted in content order so the core sequence matches the live wire + * (issue #103 parity). */ +export function mirrorResponsesInput(view: MirrorMessage[]): Array> { + const input: Array> = []; + for (const message of view) { + if (message.role === "user") { + const text = joinText(message.blocks); + if (text) input.push({ type: "message", role: "user", content: [{ type: "input_text", text }] }); + } else if (message.role === "assistant") { + for (const b of message.blocks ?? []) { + if (b.type === "thinking" && b.thinking.trim().length > 0) { + input.push({ type: "reasoning", summary: [{ type: "summary_text", text: b.thinking }] }); + } else if (b.type === "text" && b.text.trim().length > 0) { + input.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: b.text }] }); + } else if (b.type === "toolCall") { + let args = "{}"; + try { + args = JSON.stringify(b.arguments ?? {}); + } catch { + args = "{}"; + } + input.push({ type: "function_call", call_id: b.id ?? "", name: b.name ?? "", arguments: args }); + } + } + } else if (message.role === "toolResult") { + input.push({ type: "function_call_output", call_id: message.toolCallId ?? "", output: joinText(message.blocks) }); + } else { + const text = message.text ?? ""; + if (text) input.push({ type: "message", role: "user", content: [{ type: "input_text", text }] }); + } + } + return input; +} + +/** Fold the openai mirror through `openaiToCore`. */ +export function mirrorOpenaiToCore(view: MirrorMessage[], systemText: string): BiliMessage[] { + const { msgs } = openaiToCore({ + model: "prime-fold", + messages: mirrorOpenaiMessages(view, systemText) as Parameters[0]["messages"], + }); + return msgs; +} + +/** Fold the anthropic mirror through `anthropicToCore`. */ +export function mirrorAnthropicToCore(view: MirrorMessage[]): BiliMessage[] { + const { msgs } = anthropicToCore({ + model: "prime-fold", + messages: mirrorAnthropicMessages(view) as Parameters[0]["messages"], + }); + return msgs; +} + +/** Fold the responses mirror through `responsesToCore`. */ +export function mirrorResponsesToCore(view: MirrorMessage[], systemText: string): BiliMessage[] { + const { msgs } = responsesToCore({ + model: "prime-fold", + instructions: systemText, + input: mirrorResponsesInput(view) as Parameters[0]["input"], + }); + return msgs; +} diff --git a/tests/wire-mirror.test.ts b/tests/wire-mirror.test.ts new file mode 100644 index 0000000..1383c95 --- /dev/null +++ b/tests/wire-mirror.test.ts @@ -0,0 +1,259 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + mirrorAnthropicMessages, + mirrorAnthropicToCore, + mirrorOpenaiMessages, + mirrorOpenaiToCore, + mirrorResponsesInput, + mirrorResponsesToCore, + type MirrorMessage, +} from "../src/wire/mirror.js"; +import { openaiToCore, type OpenAIRequestBody } from "../src/wire/openai.js"; +import { anthropicToCore } from "../src/wire/anthropic.js"; +import { responsesToCore, type ResponsesRequestBody } from "../src/wire/responses.js"; + +const ids = (msgs: Array<{ id: string; contentType: string }>) => msgs.map((m) => `${m.contentType}:${m.id}`); + +const view: MirrorMessage[] = [ + { role: "user", blocks: [{ type: "text", text: "Q" }] }, + { + role: "assistant", + blocks: [ + { type: "thinking", thinking: "each." }, + { type: "text", text: "\n\n两个 PR:" }, + ], + }, + { + role: "assistant", + blocks: [ + { type: "thinking", thinking: "plan" }, + { type: "toolCall", id: "call_1", name: "compress", arguments: { ranges: ["m00001..m00002"] } }, + ], + }, + { role: "toolResult", toolCallId: "call_1", blocks: [{ type: "text", text: "done" }] }, + { role: "meta", text: "note" }, +]; + +// --- openai mirror --------------------------------------------------------- + +test("mirrorOpenaiMessages emits the completions wire layout", () => { + const messages = mirrorOpenaiMessages(view, "SYS"); + assert.deepEqual(messages[0], { role: "system", content: "SYS" }); + assert.deepEqual(messages[1], { role: "user", content: "Q" }); + assert.deepEqual(messages[2], { role: "assistant", content: "\n\n两个 PR:", reasoning_content: "each." }); + assert.deepEqual(messages[3], { + role: "assistant", + content: "", + reasoning_content: "plan", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "compress", arguments: '{"ranges":["m00001..m00002"]}' }, + }, + ], + }); + assert.deepEqual(messages[4], { role: "tool", tool_call_id: "call_1", content: "done" }); + assert.deepEqual(messages[5], { role: "developer", content: "note" }); + assert.equal(messages.length, 6); +}); + +test("mirrorOpenaiMessages drops empty turns and keeps whitespace-only text", () => { + const out = mirrorOpenaiMessages( + [ + { role: "user", blocks: [{ type: "text", text: "" }] }, // empty → dropped + { role: "assistant", blocks: [] }, // nothing at all → dropped + { role: "assistant", blocks: [{ type: "text", text: " " }] }, // whitespace-only → kept + { role: "meta", text: "" }, // empty meta → dropped + ], + "SYS", + ); + assert.deepEqual(out, [ + { role: "system", content: "SYS" }, + { role: "assistant", content: " " }, + ]); +}); + +test("openai mirror ids match the live reasoning_content wire (issue #103)", () => { + const live: OpenAIRequestBody = { + model: "glm-x", + messages: [ + { role: "system", content: "SYS" }, + { role: "user", content: "Q" }, + { role: "assistant", content: "\n\n两个 PR:", reasoning_content: "each." }, + { + role: "assistant", + content: "", + reasoning_content: "plan", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "compress", arguments: '{"ranges":["m00001..m00002"]}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "done" }, + { role: "developer", content: "note" }, + ], + }; + assert.deepEqual(ids(mirrorOpenaiToCore(view, "SYS")), ids(openaiToCore(live).msgs)); +}); + +test("openai mirror ids match the live inline- wire (issue #64 demoted)", () => { + // A host that demotes thinking INLINE (glm/qwen/deepseek dialect via the + // pi host encoder) puts the same turn on the wire as one text blob; the + // kernel codec normalizes it into the same identity space as the mirror. + const live: OpenAIRequestBody = { + model: "glm-x", + messages: [ + { role: "system", content: "SYS" }, + { role: "user", content: "Q" }, + { role: "assistant", content: "\neach.\n\n\n\n两个 PR:" }, + { + role: "assistant", + content: "\nplan\n\n", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "compress", arguments: '{"ranges":["m00001..m00002"]}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "done" }, + { role: "developer", content: "note" }, + ], + }; + assert.deepEqual(ids(mirrorOpenaiToCore(view, "SYS")), ids(openaiToCore(live).msgs)); +}); + +// --- anthropic mirror ------------------------------------------------------ + +test("mirrorAnthropicMessages emits the messages wire layout", () => { + const out = mirrorAnthropicMessages([ + { role: "user", blocks: [{ type: "text", text: "Q" }] }, + { + role: "assistant", + blocks: [ + { type: "thinking", thinking: "sig'd", signature: "sig-1" }, + { type: "thinking", thinking: "unsigned" }, + { type: "text", text: " " }, // whitespace-only → dropped + { type: "text", text: "\n\n答案" }, + { type: "toolCall", id: "tu_1", name: "compress", arguments: { a: 1 } }, + ], + }, + { role: "toolResult", toolCallId: "tu_1", blocks: [{ type: "text", text: "done" }] }, + { role: "meta", text: "note" }, + ]); + assert.deepEqual(out, [ + { role: "user", content: [{ type: "text", text: "Q" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "sig'd", signature: "sig-1" }, + { type: "thinking", thinking: "unsigned" }, + { type: "text", text: "\n\n答案" }, + { type: "tool_use", id: "tu_1", name: "compress", input: { a: 1 } }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "tu_1", content: "done" }] }, + { role: "user", content: [{ type: "text", text: "note" }] }, + ]); + // unsigned thinking carries no signature field at all + assert.equal("signature" in (out[1] as { content: Array> }).content[1], false); +}); + +test("anthropic mirror folds into reasoning pieces (issue #103)", () => { + const msgs = mirrorAnthropicToCore([ + { role: "user", blocks: [{ type: "text", text: "Q" }] }, + { + role: "assistant", + blocks: [ + { type: "thinking", thinking: "think A", signature: "sig" }, + { type: "text", text: "answer" }, + ], + }, + ]); + assert.deepEqual( + msgs.map((m) => m.contentType), + ["text", "reasoning", "text"], + ); + const live = anthropicToCore({ + model: "claude-x", + messages: [ + { role: "user", content: [{ type: "text", text: "Q" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "think A", signature: "sig" }, + { type: "text", text: "answer" }, + ], + }, + ], + } as Parameters[0]); + assert.deepEqual(ids(msgs), ids(live.msgs)); +}); + +// --- responses mirror ------------------------------------------------------ + +test("mirrorResponsesInput emits items in content order", () => { + const out = mirrorResponsesInput([ + { role: "user", blocks: [{ type: "text", text: "Q" }] }, + { + role: "assistant", + blocks: [ + { type: "thinking", thinking: "plan" }, + { type: "text", text: " " }, // whitespace-only → dropped + { type: "text", text: "answer" }, + { type: "toolCall", id: "call_1", name: "compress", arguments: { a: 1 } }, + ], + }, + { role: "toolResult", toolCallId: "call_1", blocks: [{ type: "text", text: "done" }] }, + { role: "meta", text: "note" }, + ]); + assert.deepEqual(out, [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Q" }] }, + { type: "reasoning", summary: [{ type: "summary_text", text: "plan" }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + { type: "function_call", call_id: "call_1", name: "compress", arguments: '{"a":1}' }, + { type: "function_call_output", call_id: "call_1", output: "done" }, + { type: "message", role: "user", content: [{ type: "input_text", text: "note" }] }, + ]); +}); + +test("responses mirror ids match the live reasoning-item wire (issue #64 responses variant)", () => { + const live: ResponsesRequestBody = { + model: "qwen-x", + instructions: "SYS", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Q" }] }, + { type: "reasoning", summary: [{ type: "summary_text", text: "plan" }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + ], + }; + const view2: MirrorMessage[] = [ + { role: "user", blocks: [{ type: "text", text: "Q" }] }, + { + role: "assistant", + blocks: [ + { type: "thinking", thinking: "plan" }, + { type: "text", text: "answer" }, + ], + }, + ]; + assert.deepEqual(ids(mirrorResponsesToCore(view2, "SYS")), ids(responsesToCore(live).msgs)); +}); + +test("responses mirror keeps thinking-only turns as reasoning items", () => { + const msgs = mirrorResponsesToCore( + [{ role: "assistant", blocks: [{ type: "thinking", thinking: "only think" }] }], + "SYS", + ); + assert.deepEqual( + msgs.map((m) => m.contentType), + ["reasoning"], + ); +});