mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:30:43 +00:00
fix(webchat): hide heartbeat history artifacts
This commit is contained in:
@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai.
|
||||
- Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported.
|
||||
- Sessions/subagents: stop stale ended runs and old store-only child reverse links from reappearing in `childSessions`, while keeping live descendants and recently-ended children visible. Fixes #57920.
|
||||
- Subagents: stop stale unended runs from counting as active or pending forever, while preserving restart-aborted recovery for recoverable child sessions. Fixes #71252. Thanks @hclsys.
|
||||
|
||||
@@ -265,6 +265,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele
|
||||
send chat output to, and it is disabled by `typingMode: "never"`.
|
||||
- Heartbeat-only replies do **not** keep the session alive; the last `updatedAt`
|
||||
is restored so idle expiry behaves normally.
|
||||
- Control UI and WebChat history hide heartbeat prompts and OK-only
|
||||
acknowledgments. The underlying session transcript can still contain those
|
||||
turns for audit/replay.
|
||||
- Detached [background tasks](/automation/tasks) can enqueue a system event and wake heartbeat when the main session should notice something quickly. That wake does not make the heartbeat run a background task.
|
||||
|
||||
## Visibility controls
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
|
||||
import { buildSessionHistorySnapshot, SessionHistorySseState } from "./session-history-state.js";
|
||||
import * as sessionUtils from "./session-utils.js";
|
||||
|
||||
@@ -107,4 +108,122 @@ describe("SessionHistorySseState", () => {
|
||||
).content?.[0]?.text,
|
||||
).toBe("visible ask");
|
||||
});
|
||||
|
||||
test("drops internal-only user messages after envelope stripping", () => {
|
||||
const snapshot = buildSessionHistorySnapshot({
|
||||
rawMessages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"subagent completion payload",
|
||||
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
__openclaw: { seq: 1 },
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "visible answer" }],
|
||||
__openclaw: { seq: 2 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.history.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "visible answer" }],
|
||||
__openclaw: { seq: 2 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("hides heartbeat prompt and ok acknowledgements from visible history", () => {
|
||||
const snapshot = buildSessionHistorySnapshot({
|
||||
rawMessages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `${HEARTBEAT_PROMPT}\nWhen reading HEARTBEAT.md, use workspace file /tmp/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.`,
|
||||
__openclaw: { seq: 1 },
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "HEARTBEAT_OK" }],
|
||||
__openclaw: { seq: 2 },
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: HEARTBEAT_PROMPT,
|
||||
__openclaw: { seq: 3 },
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Disk usage crossed 95 percent." }],
|
||||
__openclaw: { seq: 4 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.history.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Disk usage crossed 95 percent." }],
|
||||
__openclaw: { seq: 4 },
|
||||
},
|
||||
]);
|
||||
expect(snapshot.rawTranscriptSeq).toBe(4);
|
||||
});
|
||||
|
||||
test("does not append heartbeat or internal-only SSE messages", () => {
|
||||
const state = SessionHistorySseState.fromRawSnapshot({
|
||||
target: { sessionId: "sess-main" },
|
||||
rawMessages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "already visible" }],
|
||||
__openclaw: { seq: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
state.appendInlineMessage({
|
||||
message: {
|
||||
role: "user",
|
||||
content: HEARTBEAT_PROMPT,
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
state.appendInlineMessage({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "HEARTBEAT_OK" }],
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
state.appendInlineMessage({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"runtime details",
|
||||
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(state.snapshot().messages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isHeartbeatOkResponse, isHeartbeatUserMessage } from "../auto-reply/heartbeat-filter.js";
|
||||
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
|
||||
import { stripEnvelopeFromMessages } from "./chat-sanitize.js";
|
||||
import {
|
||||
DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
|
||||
@@ -31,6 +33,102 @@ type SessionHistoryTranscriptTarget = {
|
||||
sessionFile?: string;
|
||||
};
|
||||
|
||||
type RoleContentMessage = {
|
||||
role: string;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
function asRoleContentMessage(message: SessionHistoryMessage): RoleContentMessage | null {
|
||||
const role = typeof message.role === "string" ? message.role.toLowerCase() : "";
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
role,
|
||||
...(message.content !== undefined
|
||||
? { content: message.content }
|
||||
: message.text !== undefined
|
||||
? { content: message.text }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isEmptyTextOnlyContent(content: unknown): boolean {
|
||||
if (typeof content === "string") {
|
||||
return content.trim().length === 0;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return false;
|
||||
}
|
||||
if (content.length === 0) {
|
||||
return true;
|
||||
}
|
||||
let sawText = false;
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
return false;
|
||||
}
|
||||
const entry = block as { type?: unknown; text?: unknown };
|
||||
if (entry.type !== "text") {
|
||||
return false;
|
||||
}
|
||||
sawText = true;
|
||||
if (typeof entry.text !== "string" || entry.text.trim().length > 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return sawText;
|
||||
}
|
||||
|
||||
function shouldHideSanitizedHistoryMessage(message: SessionHistoryMessage): boolean {
|
||||
const roleContent = asRoleContentMessage(message);
|
||||
if (!roleContent) {
|
||||
return false;
|
||||
}
|
||||
if (roleContent.role === "user" && isEmptyTextOnlyContent(message.content ?? message.text)) {
|
||||
return true;
|
||||
}
|
||||
if (isHeartbeatUserMessage(roleContent, HEARTBEAT_PROMPT)) {
|
||||
return true;
|
||||
}
|
||||
return isHeartbeatOkResponse(roleContent);
|
||||
}
|
||||
|
||||
function filterVisibleSessionHistoryMessages(
|
||||
messages: SessionHistoryMessage[],
|
||||
): SessionHistoryMessage[] {
|
||||
if (messages.length === 0) {
|
||||
return messages;
|
||||
}
|
||||
let changed = false;
|
||||
const visible: SessionHistoryMessage[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const current = messages[i];
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
const currentRoleContent = asRoleContentMessage(current);
|
||||
const next = messages[i + 1];
|
||||
const nextRoleContent = next ? asRoleContentMessage(next) : null;
|
||||
if (
|
||||
currentRoleContent &&
|
||||
nextRoleContent &&
|
||||
isHeartbeatUserMessage(currentRoleContent, HEARTBEAT_PROMPT) &&
|
||||
isHeartbeatOkResponse(nextRoleContent)
|
||||
) {
|
||||
changed = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (shouldHideSanitizedHistoryMessage(current)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
visible.push(current);
|
||||
}
|
||||
return changed ? visible : messages;
|
||||
}
|
||||
|
||||
function resolveCursorSeq(cursor: string | undefined): number | undefined {
|
||||
if (!cursor) {
|
||||
return undefined;
|
||||
@@ -100,16 +198,15 @@ export function buildSessionHistorySnapshot(params: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): SessionHistorySnapshot {
|
||||
const history = paginateSessionMessages(
|
||||
const visibleMessages = filterVisibleSessionHistoryMessages(
|
||||
toSessionHistoryMessages(
|
||||
sanitizeChatHistoryMessages(
|
||||
stripEnvelopeFromMessages(params.rawMessages),
|
||||
params.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS,
|
||||
),
|
||||
),
|
||||
params.limit,
|
||||
params.cursor,
|
||||
);
|
||||
const history = paginateSessionMessages(visibleMessages, params.limit, params.cursor);
|
||||
const rawHistoryMessages = toSessionHistoryMessages(params.rawMessages);
|
||||
return {
|
||||
history,
|
||||
@@ -190,6 +287,9 @@ export class SessionHistorySseState {
|
||||
if (!sanitizedMessage) {
|
||||
return null;
|
||||
}
|
||||
if (shouldHideSanitizedHistoryMessage(sanitizedMessage)) {
|
||||
return null;
|
||||
}
|
||||
const nextMessages = [...this.sentHistory.messages, sanitizedMessage];
|
||||
this.sentHistory = buildPaginatedSessionHistory({
|
||||
messages: nextMessages,
|
||||
|
||||
@@ -77,6 +77,27 @@ describe("extractTextCached", () => {
|
||||
expect(extractText(message)).toBeNull();
|
||||
expect(extractTextCached(message)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips internal runtime context blocks from user text", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"internal subagent payload",
|
||||
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"",
|
||||
"visible ask",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(extractText(message)).toBe("visible ask");
|
||||
expect(extractTextCached(message)).toBe("visible ask");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractThinkingCached", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stripInternalRuntimeContext } from "../../../../src/agents/internal-runtime-context.js";
|
||||
import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js";
|
||||
import { stripEnvelope } from "../../../../src/shared/chat-envelope.js";
|
||||
import { extractAssistantVisibleText as extractSharedAssistantVisibleText } from "../../../../src/shared/chat-message-content.js";
|
||||
@@ -9,12 +10,13 @@ const thinkingCache = new WeakMap<object, string | null>();
|
||||
|
||||
function processMessageText(text: string, role: string): string {
|
||||
const shouldStripInboundMetadata = normalizeLowercaseStringOrEmpty(role) === "user";
|
||||
const withoutInternalContext = stripInternalRuntimeContext(text);
|
||||
if (role === "assistant") {
|
||||
return stripThinkingTags(text);
|
||||
return stripThinkingTags(withoutInternalContext);
|
||||
}
|
||||
return shouldStripInboundMetadata
|
||||
? stripInboundMetadata(stripEnvelope(text))
|
||||
: stripEnvelope(text);
|
||||
? stripInboundMetadata(stripEnvelope(withoutInternalContext))
|
||||
: stripEnvelope(withoutInternalContext);
|
||||
}
|
||||
|
||||
export function extractText(message: unknown): string | null {
|
||||
|
||||
@@ -753,6 +753,39 @@ describe("loadChatHistory", () => {
|
||||
expect(state.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it("filters heartbeat acknowledgements and internal-only user messages", async () => {
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
messages: [
|
||||
{ role: "assistant", content: [{ type: "text", text: "HEARTBEAT_OK" }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
"subagent completion payload",
|
||||
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "text", text: "visible answer" }] },
|
||||
],
|
||||
thinkingLevel: "low",
|
||||
});
|
||||
const state = createState({
|
||||
connected: true,
|
||||
client: { request } as unknown as ChatState["client"],
|
||||
});
|
||||
|
||||
await loadChatHistory(state);
|
||||
|
||||
expect(state.chatMessages).toEqual([
|
||||
{ role: "assistant", content: [{ type: "text", text: "visible answer" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows a targeted message when chat history is unauthorized", async () => {
|
||||
const request = vi.fn().mockRejectedValue(
|
||||
new GatewayRequestError({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isHeartbeatOkResponse } from "../../../../src/auto-reply/heartbeat-filter.js";
|
||||
import { resetToolStream } from "../app-tool-stream.ts";
|
||||
import { extractText } from "../chat/message-extract.ts";
|
||||
import { formatConnectError } from "../connect-error.ts";
|
||||
@@ -71,8 +72,67 @@ function isSyntheticTranscriptRepairToolResult(message: unknown): boolean {
|
||||
return typeof text === "string" && text.trim() === SYNTHETIC_TRANSCRIPT_REPAIR_RESULT;
|
||||
}
|
||||
|
||||
function isTextOnlyContent(content: unknown): boolean {
|
||||
if (typeof content === "string") {
|
||||
return true;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return false;
|
||||
}
|
||||
if (content.length === 0) {
|
||||
return true;
|
||||
}
|
||||
let sawText = false;
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
return false;
|
||||
}
|
||||
const entry = block as { type?: unknown; text?: unknown };
|
||||
if (entry.type !== "text") {
|
||||
return false;
|
||||
}
|
||||
sawText = true;
|
||||
if (typeof entry.text !== "string") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return sawText;
|
||||
}
|
||||
|
||||
function isEmptyUserTextOnlyMessage(message: unknown): boolean {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
const entry = message as Record<string, unknown>;
|
||||
if (normalizeLowercaseStringOrEmpty(entry.role) !== "user") {
|
||||
return false;
|
||||
}
|
||||
if (!isTextOnlyContent(entry.content ?? entry.text)) {
|
||||
return false;
|
||||
}
|
||||
return (extractText(message)?.trim() ?? "") === "";
|
||||
}
|
||||
|
||||
function isAssistantHeartbeatAck(message: unknown): boolean {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
const entry = message as Record<string, unknown>;
|
||||
const role = normalizeLowercaseStringOrEmpty(entry.role);
|
||||
if (role !== "assistant") {
|
||||
return false;
|
||||
}
|
||||
const content = entry.content ?? entry.text;
|
||||
return isHeartbeatOkResponse({ role, content });
|
||||
}
|
||||
|
||||
function shouldHideHistoryMessage(message: unknown): boolean {
|
||||
return isAssistantSilentReply(message) || isSyntheticTranscriptRepairToolResult(message);
|
||||
return (
|
||||
isAssistantSilentReply(message) ||
|
||||
isAssistantHeartbeatAck(message) ||
|
||||
isSyntheticTranscriptRepairToolResult(message) ||
|
||||
isEmptyUserTextOnlyMessage(message)
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryableStartupUnavailable(err: unknown, method: string): err is GatewayRequestError {
|
||||
|
||||
Reference in New Issue
Block a user