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"],
+ );
+});