diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb82299c..56c821e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Docs: https://docs.clawd.bot - Android: remove legacy bridge transport code now that nodes use the gateway protocol. - Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects. +### Fixes +- Gateway: strip inbound envelope headers from chat history messages to keep clients clean. + ## 2026.1.19-2 ### Changes diff --git a/src/gateway/chat-sanitize.ts b/src/gateway/chat-sanitize.ts new file mode 100644 index 000000000..398fabf1f --- /dev/null +++ b/src/gateway/chat-sanitize.ts @@ -0,0 +1,89 @@ +const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/; +const ENVELOPE_CHANNELS = [ + "WebChat", + "WhatsApp", + "Telegram", + "Signal", + "Slack", + "Discord", + "iMessage", + "Teams", + "Matrix", + "Zalo", + "Zalo Personal", + "BlueBubbles", +]; + +function looksLikeEnvelopeHeader(header: string): boolean { + if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true; + if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true; + return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); +} + +export function stripEnvelope(text: string): string { + const match = text.match(ENVELOPE_PREFIX); + if (!match) return text; + const header = match[1] ?? ""; + if (!looksLikeEnvelopeHeader(header)) return text; + return text.slice(match[0].length); +} + +function stripEnvelopeFromContent(content: unknown[]): { content: unknown[]; changed: boolean } { + let changed = false; + const next = content.map((item) => { + if (!item || typeof item !== "object") return item; + const entry = item as Record; + if (entry.type !== "text" || typeof entry.text !== "string") return item; + const stripped = stripEnvelope(entry.text); + if (stripped === entry.text) return item; + changed = true; + return { + ...entry, + text: stripped, + }; + }); + return { content: next, changed }; +} + +export function stripEnvelopeFromMessage(message: unknown): unknown { + if (!message || typeof message !== "object") return message; + const entry = message as Record; + const role = typeof entry.role === "string" ? entry.role.toLowerCase() : ""; + if (role !== "user") return message; + + let changed = false; + const next: Record = { ...entry }; + + if (typeof entry.content === "string") { + const stripped = stripEnvelope(entry.content); + if (stripped !== entry.content) { + next.content = stripped; + changed = true; + } + } else if (Array.isArray(entry.content)) { + const updated = stripEnvelopeFromContent(entry.content); + if (updated.changed) { + next.content = updated.content; + changed = true; + } + } else if (typeof entry.text === "string") { + const stripped = stripEnvelope(entry.text); + if (stripped !== entry.text) { + next.text = stripped; + changed = true; + } + } + + return changed ? next : message; +} + +export function stripEnvelopeFromMessages(messages: unknown[]): unknown[] { + if (messages.length === 0) return messages; + let changed = false; + const next = messages.map((message) => { + const stripped = stripEnvelopeFromMessage(message); + if (stripped !== message) changed = true; + return stripped; + }); + return changed ? next : messages; +} diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 7ba466e9f..92fd5f16a 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -35,6 +35,7 @@ import { readSessionMessages, resolveSessionModelRef, } from "../session-utils.js"; +import { stripEnvelopeFromMessages } from "../chat-sanitize.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -64,7 +65,8 @@ export const chatHandlers: GatewayRequestHandlers = { const requested = typeof limit === "number" ? limit : defaultLimit; const max = Math.min(hardMax, requested); const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages; - const capped = capArrayByJsonBytes(sliced, MAX_CHAT_HISTORY_MESSAGES_BYTES).items; + const sanitized = stripEnvelopeFromMessages(sliced); + const capped = capArrayByJsonBytes(sanitized, MAX_CHAT_HISTORY_MESSAGES_BYTES).items; let thinkingLevel = entry?.thinkingLevel; if (!thinkingLevel) { const configured = cfg.agents?.defaults?.thinkingDefault; diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 3e21ced55..766212b74 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -303,6 +303,47 @@ describe("gateway server chat", () => { await server.close(); }); + test("chat.history strips inbound envelopes for user messages", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + }); + + const enveloped = "[WebChat agent:main:main +2m 2026-01-19 09:29 UTC] hello world"; + await fs.writeFile( + path.join(dir, "sess-main.jsonl"), + JSON.stringify({ + message: { + role: "user", + content: [{ type: "text", text: enveloped }], + timestamp: Date.now(), + }, + }), + "utf-8", + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { + sessionKey: "main", + }); + expect(res.ok).toBe(true); + const message = (res.payload?.messages ?? [])[0] as + | { content?: Array<{ text?: string }> } + | undefined; + expect(message?.content?.[0]?.text).toBe("hello world"); + + ws.close(); + await server.close(); + }); + test("chat.history prefers sessionFile when set", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json");