From 2edff0866813a4afa8a22b3070983c4eb6f0c5ca Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Mon, 6 Apr 2026 22:18:37 -0500 Subject: [PATCH] fix(openclaw): normalize engine messages for discord --- plugins/openclaw/src/convert.ts | 231 ++++++++++++++++-- plugins/openclaw/src/engine.ts | 11 +- plugins/openclaw/src/index.ts | 2 +- plugins/openclaw/test/convert.test.ts | 70 +++++- .../test/engine-normalization.test.ts | 21 ++ plugins/openclaw/test/engine.test.ts | 3 +- 6 files changed, 313 insertions(+), 25 deletions(-) create mode 100644 plugins/openclaw/test/engine-normalization.test.ts diff --git a/plugins/openclaw/src/convert.ts b/plugins/openclaw/src/convert.ts index d613c9187..63fab8c6d 100644 --- a/plugins/openclaw/src/convert.ts +++ b/plugins/openclaw/src/convert.ts @@ -20,6 +20,7 @@ export interface OpenAIMessage { tool_calls?: any[]; tool_call_id?: string; name?: string; + _headroomMeta?: Record; } /** @@ -29,12 +30,24 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] { const result: OpenAIMessage[] = []; for (const msg of messages) { - const role = msg.role; + const normalized = normalizeAgentMessage(msg); + const role = normalized.role; + + const buildMeta = (): Record => { + const meta = { ...normalized } as Record; + delete meta.role; + delete meta.content; + return meta; + }; if (role === "system") { result.push({ role: "system", - content: typeof msg.content === "string" ? msg.content : extractText(msg.content), + content: + typeof normalized.content === "string" + ? normalized.content + : extractText(normalized.content), + _headroomMeta: buildMeta(), }); continue; } @@ -42,15 +55,19 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] { if (role === "user") { result.push({ role: "user", - content: typeof msg.content === "string" ? msg.content : extractText(msg.content), + content: + typeof normalized.content === "string" + ? normalized.content + : extractText(normalized.content), + _headroomMeta: buildMeta(), }); continue; } if (role === "assistant") { - const content = msg.content; + const content = normalized.content; if (typeof content === "string") { - result.push({ role: "assistant", content }); + result.push({ role: "assistant", content, _headroomMeta: buildMeta() }); continue; } @@ -87,6 +104,7 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] { const openaiMsg: OpenAIMessage = { role: "assistant", content: textParts.length > 0 ? textParts.join("") : null, + _headroomMeta: buildMeta(), }; if (toolCalls.length > 0) { openaiMsg.tool_calls = toolCalls; @@ -98,16 +116,21 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] { if (role === "toolResult" || role === "tool_result") { const content = - typeof msg.content === "string" - ? msg.content - : Array.isArray(msg.content) - ? extractText(msg.content) - : JSON.stringify(msg.content); + typeof normalized.content === "string" + ? normalized.content + : Array.isArray(normalized.content) + ? extractText(normalized.content) + : JSON.stringify(normalized.content); result.push({ role: "tool", content, - tool_call_id: msg.tool_use_id ?? msg.toolCallId ?? msg.id ?? "unknown", + tool_call_id: + normalized.toolCallId ?? + normalized.tool_use_id ?? + normalized.id ?? + "unknown", + _headroomMeta: buildMeta(), }); continue; } @@ -115,7 +138,11 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] { // Fallback: pass through as user message result.push({ role: "user", - content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content), + content: + typeof normalized.content === "string" + ? normalized.content + : JSON.stringify(normalized.content), + _headroomMeta: buildMeta(), }); } @@ -129,11 +156,15 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] { const result: any[] = []; for (const msg of messages) { + const meta = (msg._headroomMeta ?? {}) as Record; + const timestamp = + typeof meta.timestamp === "number" ? meta.timestamp : Date.now(); + if (msg.role === "system") { result.push({ role: "system", content: msg.content ?? "", - timestamp: Date.now(), + timestamp, }); continue; } @@ -142,7 +173,7 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] { result.push({ role: "user", content: msg.content ?? "", - timestamp: Date.now(), + timestamp, }); continue; } @@ -172,9 +203,26 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] { // OpenClaw's Pi agent expects content to always be an array for assistant messages // (it calls .flatMap() on it). Never flatten to a string. result.push({ + ...(meta as object), role: "assistant", content: blocks, - timestamp: Date.now(), + api: typeof meta.api === "string" ? meta.api : "headroom", + provider: typeof meta.provider === "string" ? meta.provider : "headroom", + model: typeof meta.model === "string" ? meta.model : "headroom", + usage: + isRecord(meta.usage) + ? meta.usage + : { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: + typeof meta.stopReason === "string" ? meta.stopReason : "stop", + timestamp, }); continue; } @@ -188,12 +236,18 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] { : JSON.stringify(msg.content); const toolCallId = msg.tool_call_id ?? "unknown"; result.push({ + ...(meta as object), role: "toolResult", // OpenClaw transport layers expect toolResult content blocks, not a raw string. content: [{ type: "text", text: textContent }], - toolCallId, - tool_use_id: toolCallId, - timestamp: Date.now(), + toolCallId: + typeof meta.toolCallId === "string" ? meta.toolCallId : toolCallId, + tool_use_id: + typeof meta.tool_use_id === "string" ? meta.tool_use_id : toolCallId, + toolName: + typeof meta.toolName === "string" ? meta.toolName : "headroom", + isError: typeof meta.isError === "boolean" ? meta.isError : false, + timestamp, }); continue; } @@ -202,6 +256,10 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] { return result; } +export function normalizeAgentMessages(messages: any[]): any[] { + return messages.map((message) => normalizeAgentMessage(message)); +} + /** * Extract text from content blocks. */ @@ -221,3 +279,140 @@ function extractText(content: any): string { .filter(Boolean) .join("\n"); } + +function normalizeAgentMessage(message: any): any { + if (!isRecord(message)) return message; + + if (message.role === "assistant") { + return normalizeAssistantMessage(message); + } + + if (message.role === "toolResult" || message.role === "tool_result") { + return normalizeToolResultMessage(message); + } + + return message; +} + +function normalizeAssistantMessage(message: Record): Record { + const normalizedContent = normalizeAssistantContent(message.content); + + return { + ...message, + content: normalizedContent, + api: typeof message.api === "string" ? message.api : "headroom", + provider: typeof message.provider === "string" ? message.provider : "headroom", + model: typeof message.model === "string" ? message.model : "headroom", + usage: isRecord(message.usage) + ? message.usage + : { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: typeof message.stopReason === "string" ? message.stopReason : "stop", + timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(), + }; +} + +function normalizeToolResultMessage(message: Record): Record { + const normalizedContent = normalizeToolResultContent(message.content); + const toolCallId = + typeof message.toolCallId === "string" + ? message.toolCallId + : typeof message.tool_use_id === "string" + ? message.tool_use_id + : typeof message.id === "string" + ? message.id + : "unknown"; + + return { + ...message, + role: "toolResult", + content: normalizedContent, + toolCallId, + tool_use_id: + typeof message.tool_use_id === "string" ? message.tool_use_id : toolCallId, + toolName: typeof message.toolName === "string" ? message.toolName : "headroom", + isError: typeof message.isError === "boolean" ? message.isError : false, + timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(), + }; +} + +function normalizeAssistantContent(content: unknown): any[] { + if (Array.isArray(content)) { + return content.flatMap((block) => { + if (typeof block === "string") return [{ type: "text", text: block }]; + if (!isRecord(block) || typeof block.type !== "string") return []; + if (block.type === "text" && typeof block.text === "string") return [block]; + if (block.type === "thinking" && typeof block.thinking === "string") return [block]; + if ( + (block.type === "toolCall" || block.type === "tool_use") && + typeof block.name === "string" + ) { + return [ + { + type: "toolCall", + id: typeof block.id === "string" ? block.id : "unknown", + name: block.name, + arguments: + "arguments" in block + ? block.arguments + : "input" in block + ? block.input + : {}, + }, + ]; + } + return []; + }); + } + + if (typeof content === "string" && content.length > 0) { + return [{ type: "text", text: content }]; + } + + if (content == null) { + return []; + } + + return [{ type: "text", text: JSON.stringify(content) }]; +} + +function normalizeToolResultContent(content: unknown): any[] { + if (Array.isArray(content)) { + return content.flatMap((block) => { + if (typeof block === "string") return [{ type: "text", text: block }]; + if (!isRecord(block) || typeof block.type !== "string") return []; + if (block.type === "text" && typeof block.text === "string") return [block]; + if ( + block.type === "image" && + typeof block.data === "string" && + typeof block.mimeType === "string" + ) { + return [block]; + } + if (block.type === "tool_result" && "content" in block) { + return normalizeToolResultContent(block.content); + } + return []; + }); + } + + if (typeof content === "string" && content.length > 0) { + return [{ type: "text", text: content }]; + } + + if (content == null) { + return []; + } + + return [{ type: "text", text: JSON.stringify(content) }]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/plugins/openclaw/src/engine.ts b/plugins/openclaw/src/engine.ts index 4e0b44eb1..6511eee8e 100644 --- a/plugins/openclaw/src/engine.ts +++ b/plugins/openclaw/src/engine.ts @@ -9,7 +9,7 @@ import { compress } from "headroom-ai"; import { ProxyManager, defaultLogger, type ProxyManagerConfig, type ProxyManagerLogger } from "./proxy-manager.js"; -import { agentToOpenAI, openAIToAgent } from "./convert.js"; +import { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js"; export interface HeadroomEngineConfig extends ProxyManagerConfig { enabled?: boolean; @@ -96,7 +96,7 @@ export class HeadroomContextEngine { }> { if (!this.proxyUrl || this.config.enabled === false) { // Fallback: return messages unchanged - return { messages: params.messages, estimatedTokens: 0 }; + return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 }; } try { @@ -112,7 +112,10 @@ export class HeadroomContextEngine { } as any); if (!result.compressed || result.tokensSaved === 0) { - return { messages: params.messages, estimatedTokens: result.tokensBefore }; + return { + messages: normalizeAgentMessages(params.messages), + estimatedTokens: result.tokensBefore, + }; } // Convert back to AgentMessage format @@ -138,7 +141,7 @@ export class HeadroomContextEngine { } catch (error) { this.logger.error(`Assemble failed: ${error}`); // Graceful fallback: return original messages - return { messages: params.messages, estimatedTokens: 0 }; + return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 }; } } diff --git a/plugins/openclaw/src/index.ts b/plugins/openclaw/src/index.ts index 915853fb4..16a3ae2cd 100644 --- a/plugins/openclaw/src/index.ts +++ b/plugins/openclaw/src/index.ts @@ -1,5 +1,5 @@ export { default } from "./plugin/index.js"; export { HeadroomContextEngine } from "./engine.js"; export { ProxyManager, normalizeAndValidateProxyUrl, isLocalProxyUrl, defaultLogger, probeHeadroomProxy } from "./proxy-manager.js"; -export { agentToOpenAI, openAIToAgent } from "./convert.js"; +export { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js"; export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js"; diff --git a/plugins/openclaw/test/convert.test.ts b/plugins/openclaw/test/convert.test.ts index 5f30d637b..4eb76dc7a 100644 --- a/plugins/openclaw/test/convert.test.ts +++ b/plugins/openclaw/test/convert.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { openAIToAgent, type OpenAIMessage } from "../src/convert"; +import { agentToOpenAI, normalizeAgentMessages, openAIToAgent, type OpenAIMessage } from "../src/convert"; describe("openAIToAgent", () => { it("emits toolResult content as blocks so transports can safely filter", () => { @@ -26,3 +26,71 @@ describe("openAIToAgent", () => { expect(toolResult.tool_use_id).toBe("call_123"); }); }); + +describe("normalizeAgentMessages", () => { + it("normalizes assistant string content into OpenClaw blocks", () => { + const result = normalizeAgentMessages([ + { + role: "assistant", + content: "hello from headroom", + }, + ]); + + expect(result[0]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "hello from headroom" }], + api: "headroom", + provider: "headroom", + model: "headroom", + stopReason: "stop", + }); + }); + + it("normalizes tool result string content into OpenClaw blocks", () => { + const result = normalizeAgentMessages([ + { + role: "toolResult", + content: "tool output", + }, + ]); + + expect(result[0]).toMatchObject({ + role: "toolResult", + content: [{ type: "text", text: "tool output" }], + toolCallId: "unknown", + tool_use_id: "unknown", + toolName: "headroom", + isError: false, + }); + }); +}); + +describe("agentToOpenAI", () => { + it("captures assistant metadata needed for OpenClaw round-trips", () => { + const result = agentToOpenAI([ + { + role: "assistant", + content: "hello", + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason: "stop", + usage: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 3, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + ]); + + expect(result[0]._headroomMeta).toMatchObject({ + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + stopReason: "stop", + }); + }); +}); diff --git a/plugins/openclaw/test/engine-normalization.test.ts b/plugins/openclaw/test/engine-normalization.test.ts new file mode 100644 index 000000000..0ae13a59a --- /dev/null +++ b/plugins/openclaw/test/engine-normalization.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { HeadroomContextEngine } from "../src/engine.js"; + +describe("HeadroomContextEngine", () => { + it("normalizes pass-through assistant messages when no proxy is available", async () => { + const engine = new HeadroomContextEngine({ enabled: false }); + + const result = await engine.assemble({ + sessionId: "test-session", + messages: [ + { role: "user", content: "hi", timestamp: Date.now() }, + { role: "assistant", content: "hello there", timestamp: Date.now() }, + ], + }); + + expect(result.messages[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "hello there" }], + }); + }); +}); diff --git a/plugins/openclaw/test/engine.test.ts b/plugins/openclaw/test/engine.test.ts index 756ce1946..0d93d0900 100644 --- a/plugins/openclaw/test/engine.test.ts +++ b/plugins/openclaw/test/engine.test.ts @@ -25,7 +25,8 @@ describe("AgentMessage conversion", () => { it("converts user message", () => { const agent = [{ role: "user", content: "hello", timestamp: Date.now() }]; const openai = agentToOpenAI(agent); - expect(openai).toEqual([{ role: "user", content: "hello" }]); + expect(openai).toHaveLength(1); + expect(openai[0]).toMatchObject({ role: "user", content: "hello" }); }); it("converts assistant with tool_use blocks", () => {