fix: keep internal completion wakes out of chat memory

This commit is contained in:
Peter Steinberger
2026-04-26 04:01:05 +01:00
parent d0d93d0fde
commit 6df120fb39
7 changed files with 92 additions and 4 deletions

View File

@@ -73,6 +73,9 @@ Docs: https://docs.openclaw.ai
and honor configured `params.chat_template_kwargs` for OpenAI-compatible
completions, so vLLM/Nemotron replies stay visible instead of becoming
thinking-only. Fixes #71891. Thanks @jmystaki-create and @dennis-lynch.
- Subagents/memory: keep inter-session completion wakes out of memory and
dreaming session exports, and strip internal runtime-context blocks from
realtime Control UI chat events.
- Agents/Claude: treat zero-token empty `stop` turns as failed provider output,
retry once, repair replay, and allow configured model fallback instead of
preserving them as successful silent replies. Fixes #71880. Thanks @MagnaAI.

View File

@@ -185,4 +185,32 @@ describe("buildSessionEntry", () => {
expect(entry).not.toBeNull();
expect(entry!.content).toBe("User: Actual user text");
});
it("skips inter-session user messages", async () => {
const jsonlLines = [
JSON.stringify({
type: "message",
message: {
role: "user",
content: "A background task completed. Internal relay text.",
provenance: { kind: "inter_session", sourceTool: "subagent_announce" },
},
}),
JSON.stringify({
type: "message",
message: { role: "assistant", content: "User-facing summary." },
}),
JSON.stringify({
type: "message",
message: { role: "user", content: "Actual user follow-up." },
}),
];
const filePath = path.join(tmpDir, "inter-session-session.jsonl");
fsSync.writeFileSync(filePath, jsonlLines.join("\n"));
const entry = await buildSessionEntry(filePath);
expect(entry).not.toBeNull();
expect(entry!.content).toBe("Assistant: User-facing summary.\nUser: Actual user follow-up.");
expect(entry!.lineMap).toEqual([2, 3]);
});
});

View File

@@ -8,6 +8,7 @@ import {
} from "../../../../src/config/sessions/artifacts.js";
import { resolveSessionTranscriptsDirForAgent } from "../../../../src/config/sessions/paths.js";
import { redactSensitiveText } from "../../../../src/logging/redact.js";
import { hasInterSessionUserProvenance } from "../../../../src/sessions/input-provenance.js";
import { hashText } from "./hash.js";
export type SessionFileEntry = {
@@ -170,7 +171,7 @@ export async function buildSessionEntry(absPath: string): Promise<SessionFileEnt
continue;
}
const message = (record as { message?: unknown }).message as
| { role?: unknown; content?: unknown }
| { role?: unknown; content?: unknown; provenance?: unknown }
| undefined;
if (!message || typeof message.role !== "string") {
continue;
@@ -178,6 +179,9 @@ export async function buildSessionEntry(absPath: string): Promise<SessionFileEnt
if (message.role !== "user" && message.role !== "assistant") {
continue;
}
if (message.role === "user" && hasInterSessionUserProvenance(message)) {
continue;
}
const text = extractSessionText(message.content, message.role);
if (!text) {
continue;

View File

@@ -215,6 +215,34 @@ describe("agent event handler", () => {
nowSpy?.mockRestore();
});
it("strips internal runtime context from assistant chat events", () => {
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
createHarness({ now: 1_000 }),
[
"Visible before.",
"",
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
"OpenClaw runtime context (internal):",
"[Internal task completion event]",
"secret child result",
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
"",
"Visible after.",
].join("\n"),
);
const chatCalls = chatBroadcastCalls(broadcast);
expect(chatCalls).toHaveLength(1);
const payload = chatCalls[0]?.[1] as {
message?: { content?: Array<{ text?: string }> };
};
expect(payload.message?.content?.[0]?.text).toBe("Visible before.\n\nVisible after.");
expect(payload.message?.content?.[0]?.text).not.toContain("BEGIN_OPENCLAW_INTERNAL_CONTEXT");
expect(payload.message?.content?.[0]?.text).not.toContain("secret child result");
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
nowSpy?.mockRestore();
});
it.each([" NO_REPLY ", " ANNOUNCE_SKIP ", " REPLY_SKIP "])(
"does not emit chat delta for suppressed control text %s",
(replyText) => {

View File

@@ -1,3 +1,4 @@
import { stripInternalRuntimeContext } from "../agents/internal-runtime-context.js";
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-reply/heartbeat.js";
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
import {
@@ -688,9 +689,11 @@ export function createAgentEventHandler({
text: string,
delta?: unknown,
) => {
const cleanedText = stripInlineDirectiveTagsForDisplay(text).text;
const cleanedText = stripInternalRuntimeContext(stripInlineDirectiveTagsForDisplay(text).text);
const cleanedDelta =
typeof delta === "string" ? stripInlineDirectiveTagsForDisplay(delta).text : "";
typeof delta === "string"
? stripInternalRuntimeContext(stripInlineDirectiveTagsForDisplay(delta).text)
: "";
const previousRawText = chatRunState.rawBuffers.get(clientRunId) ?? "";
const mergedRawText = resolveMergedAssistantText({
previousText: previousRawText,

View File

@@ -598,6 +598,24 @@ describe("buildSessionEntry", () => {
content: "User: Actual user text",
lineMap: [3],
},
{
name: "inter-session user provenance",
fileName: "inter-session-session.jsonl",
records: [
{
type: "message",
message: {
role: "user",
content: "A background task completed. Internal relay text.",
provenance: { kind: "inter_session", sourceTool: "subagent_announce" },
},
},
{ type: "message", message: { role: "assistant", content: "User-facing summary." } },
{ type: "message", message: { role: "user", content: "Actual user follow-up." } },
],
content: "Assistant: User-facing summary.\nUser: Actual user follow-up.",
lineMap: [2, 3],
},
] as const;
for (const testCase of cases) {

View File

@@ -14,6 +14,7 @@ import {
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
import { isExecCompletionEvent } from "../../infra/heartbeat-events-filter.js";
import { redactSensitiveText } from "../../logging/redact.js";
import { hasInterSessionUserProvenance } from "../../sessions/input-provenance.js";
import { isCronRunSessionKey } from "../../sessions/session-key-utils.js";
import { hashText } from "./hash.js";
@@ -504,7 +505,7 @@ export async function buildSessionEntry(
continue;
}
const message = (record as { message?: unknown }).message as
| { role?: unknown; content?: unknown }
| { role?: unknown; content?: unknown; provenance?: unknown }
| undefined;
if (!message || typeof message.role !== "string") {
continue;
@@ -512,6 +513,9 @@ export async function buildSessionEntry(
if (message.role !== "user" && message.role !== "assistant") {
continue;
}
if (message.role === "user" && hasInterSessionUserProvenance(message)) {
continue;
}
const rawText = collectRawSessionText(message.content);
if (rawText === null) {
continue;