fix: strip envelopes in chat history
This commit is contained in:
@@ -8,6 +8,9 @@ Docs: https://docs.clawd.bot
|
|||||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
- 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.
|
- 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
|
## 2026.1.19-2
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
89
src/gateway/chat-sanitize.ts
Normal file
89
src/gateway/chat-sanitize.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
const role = typeof entry.role === "string" ? entry.role.toLowerCase() : "";
|
||||||
|
if (role !== "user") return message;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, unknown> = { ...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;
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
readSessionMessages,
|
readSessionMessages,
|
||||||
resolveSessionModelRef,
|
resolveSessionModelRef,
|
||||||
} from "../session-utils.js";
|
} from "../session-utils.js";
|
||||||
|
import { stripEnvelopeFromMessages } from "../chat-sanitize.js";
|
||||||
import { formatForLog } from "../ws-log.js";
|
import { formatForLog } from "../ws-log.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
@@ -64,7 +65,8 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
const requested = typeof limit === "number" ? limit : defaultLimit;
|
const requested = typeof limit === "number" ? limit : defaultLimit;
|
||||||
const max = Math.min(hardMax, requested);
|
const max = Math.min(hardMax, requested);
|
||||||
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
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;
|
let thinkingLevel = entry?.thinkingLevel;
|
||||||
if (!thinkingLevel) {
|
if (!thinkingLevel) {
|
||||||
const configured = cfg.agents?.defaults?.thinkingDefault;
|
const configured = cfg.agents?.defaults?.thinkingDefault;
|
||||||
|
|||||||
@@ -303,6 +303,47 @@ describe("gateway server chat", () => {
|
|||||||
await server.close();
|
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 () => {
|
test("chat.history prefers sessionFile when set", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
|
|||||||
Reference in New Issue
Block a user