mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Channels/Discord: keep read-only allowlist/default-target accessors from resolving SecretRef-backed bot tokens, so status and channel summaries no longer fail when tokens are only available in gateway runtime. (#74737) Thanks @eusine.
|
||||
- Agents/output: drop copied inbound metadata-only assistant replay turns before provider replay instead of synthesizing a placeholder, so Telegram and other channels cannot receive `[assistant copied inbound metadata omitted]` as model output. Fixes #74745. Thanks @adamwdear and @Marvae.
|
||||
- Channels/groups: preserve observe-only turn suppression for prepared dispatch paths and restore deprecated channel turn runtime aliases, so passive observer/group flows stay silent while older plugins keep compiling. Thanks @vincentkoc.
|
||||
- Feishu/Bitable: clean up newly created placeholder rows whose fields contain only default empty values while preserving meaningful link, attachment, user, number, boolean, and location values during create-app cleanup. (#73920) Carries forward #40602. Thanks @boat2moon.
|
||||
- macOS app: keep attach-only mode and the Debug Settings launchd toggle marker-only, so launching with `--attach-only`/`--no-launchd` no longer uninstalls the Gateway LaunchAgent or drops active sessions. (#72174) Thanks @DolencLuka.
|
||||
|
||||
@@ -1131,6 +1131,59 @@ describe("sanitizeSessionHistory", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops metadata-only assistant replay turns before provider validation", async () => {
|
||||
setNonGoogleModelApi();
|
||||
|
||||
const metadataOnlyText = [
|
||||
"Conversation info (untrusted metadata):",
|
||||
"```json",
|
||||
'{"chat_id":"channel:123","sender":"OpenClaw"}',
|
||||
"```",
|
||||
].join("\n");
|
||||
const messages = castAgentMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "First" }],
|
||||
timestamp: nextTimestamp(),
|
||||
},
|
||||
makeAssistantMessage([{ type: "text", text: metadataOnlyText }]),
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Second" }],
|
||||
timestamp: nextTimestamp(),
|
||||
},
|
||||
]);
|
||||
|
||||
const sanitized = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
sessionManager: makeMockSessionManager(),
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
expect(sanitized.map((msg) => msg.role)).toEqual(["user", "user"]);
|
||||
expect(JSON.stringify(sanitized)).not.toContain("assistant copied inbound metadata omitted");
|
||||
|
||||
const validated = await validateReplayTurns({
|
||||
messages: sanitized,
|
||||
modelApi: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
expect(validated).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "text", text: "Second" },
|
||||
],
|
||||
timestamp: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips prior assistant reasoning for Gemma 4 OpenAI-compatible replay", async () => {
|
||||
setNonGoogleModelApi();
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { normalizeAssistantReplayContent } from "./replay-history.js";
|
||||
|
||||
const FALLBACK_TEXT = "[assistant turn failed before producing content]";
|
||||
const COPIED_INBOUND_METADATA_ONLY_TEXT = `Conversation info (untrusted metadata):
|
||||
\`\`\`json
|
||||
{"message_id":"msg-abc","sender":"+1555000"}
|
||||
\`\`\``;
|
||||
|
||||
function bedrockAssistant(
|
||||
content: unknown,
|
||||
@@ -134,6 +138,33 @@ describe("normalizeAssistantReplayContent", () => {
|
||||
expect(wrapped.content).toEqual([{ type: "text", text: "plain string content" }]);
|
||||
});
|
||||
|
||||
it("drops metadata-only legacy string assistant content from replay", () => {
|
||||
const messages = [
|
||||
userMessage("first"),
|
||||
bedrockAssistant(COPIED_INBOUND_METADATA_ONLY_TEXT),
|
||||
userMessage("second"),
|
||||
];
|
||||
const out = normalizeAssistantReplayContent(messages);
|
||||
expect(out).toEqual([messages[0], messages[2]]);
|
||||
expect(JSON.stringify(out)).not.toContain("assistant copied inbound metadata omitted");
|
||||
});
|
||||
|
||||
it("drops metadata-only assistant text blocks without fabricating placeholder output", () => {
|
||||
const toolCall = { type: "toolCall", id: "call_1", name: "read", arguments: {} };
|
||||
const messages = [
|
||||
userMessage("hi"),
|
||||
bedrockAssistant([
|
||||
{ type: "text", text: COPIED_INBOUND_METADATA_ONLY_TEXT },
|
||||
{ type: "text", text: `${COPIED_INBOUND_METADATA_ONLY_TEXT}\n\nVisible reply` },
|
||||
toolCall,
|
||||
]),
|
||||
];
|
||||
const out = normalizeAssistantReplayContent(messages);
|
||||
const normalized = out[1] as AgentMessage & { content: unknown[] };
|
||||
expect(normalized.content).toEqual([{ type: "text", text: "Visible reply" }, toolCall]);
|
||||
expect(JSON.stringify(out)).not.toContain("assistant copied inbound metadata omitted");
|
||||
});
|
||||
|
||||
it("filters openclaw delivery-mirror and gateway-injected assistant messages from replay", () => {
|
||||
const messages = [
|
||||
userMessage("hello"),
|
||||
|
||||
@@ -234,7 +234,6 @@ function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]
|
||||
// content and, on Bedrock or strict OpenAI-compatible providers, can also
|
||||
// trigger turn-ordering rejections.
|
||||
const TRANSCRIPT_ONLY_OPENCLAW_MODELS = new Set<string>(["delivery-mirror", "gateway-injected"]);
|
||||
const OMITTED_INBOUND_METADATA_TEXT = "[assistant copied inbound metadata omitted]";
|
||||
|
||||
function sanitizeUserReplayContent(message: AgentMessage): AgentMessage | null {
|
||||
if (!message || message.role !== "user") {
|
||||
@@ -282,6 +281,49 @@ function isTranscriptOnlyOpenclawAssistant(message: AgentMessage): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAssistantReplayTextContent(message: AgentMessage, replayContent: string) {
|
||||
const strippedText = stripInboundMetadata(replayContent);
|
||||
if (!strippedText.trim()) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content: [{ type: "text", text: strippedText }],
|
||||
} as AgentMessage;
|
||||
}
|
||||
|
||||
function normalizeAssistantReplayBlockContent(message: AgentMessage, replayContent: unknown[]) {
|
||||
let touched = false;
|
||||
const sanitizedContent: unknown[] = [];
|
||||
for (const block of replayContent) {
|
||||
if (!block || typeof block !== "object") {
|
||||
sanitizedContent.push(block);
|
||||
continue;
|
||||
}
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text !== "string") {
|
||||
sanitizedContent.push(block);
|
||||
continue;
|
||||
}
|
||||
const strippedText = stripInboundMetadata(text);
|
||||
if (strippedText === text) {
|
||||
sanitizedContent.push(block);
|
||||
continue;
|
||||
}
|
||||
touched = true;
|
||||
if (strippedText.trim()) {
|
||||
sanitizedContent.push({ ...block, text: strippedText });
|
||||
}
|
||||
}
|
||||
if (!touched) {
|
||||
return message;
|
||||
}
|
||||
if (sanitizedContent.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { ...message, content: sanitizedContent } as AgentMessage;
|
||||
}
|
||||
|
||||
export function normalizeAssistantReplayContent(messages: AgentMessage[]): AgentMessage[] {
|
||||
let touched = false;
|
||||
const out: AgentMessage[] = [];
|
||||
@@ -308,44 +350,19 @@ export function normalizeAssistantReplayContent(messages: AgentMessage[]): Agent
|
||||
}
|
||||
const replayContent = (message as { content?: unknown }).content;
|
||||
if (typeof replayContent === "string") {
|
||||
const strippedText = stripInboundMetadata(replayContent);
|
||||
out.push({
|
||||
...message,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: strippedText.trim() ? strippedText : OMITTED_INBOUND_METADATA_TEXT,
|
||||
},
|
||||
],
|
||||
});
|
||||
const normalized = normalizeAssistantReplayTextContent(message, replayContent);
|
||||
if (normalized) {
|
||||
out.push(normalized);
|
||||
}
|
||||
touched = true;
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(replayContent)) {
|
||||
let contentTouched = false;
|
||||
const sanitizedContent = replayContent.map((block) => {
|
||||
if (!block || typeof block !== "object") {
|
||||
return block;
|
||||
const normalized = normalizeAssistantReplayBlockContent(message, replayContent);
|
||||
if (normalized !== message) {
|
||||
if (normalized) {
|
||||
out.push(normalized);
|
||||
}
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text !== "string") {
|
||||
return block;
|
||||
}
|
||||
const strippedText = stripInboundMetadata(text);
|
||||
if (strippedText === text) {
|
||||
return block;
|
||||
}
|
||||
contentTouched = true;
|
||||
return {
|
||||
...block,
|
||||
text: strippedText.trim() ? strippedText : OMITTED_INBOUND_METADATA_TEXT,
|
||||
};
|
||||
});
|
||||
if (contentTouched) {
|
||||
out.push({
|
||||
...message,
|
||||
content: sanitizedContent,
|
||||
});
|
||||
touched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user