From 6e985a421da7f9624adbf3b5bb71e1e5825d7c4d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 22:17:03 +0100 Subject: [PATCH] fix(webchat): keep runtime context out of visible transcripts Keep WebChat runtime context available to the model while persisting only the transcript-facing user prompt across gateway, CLI, queued follow-up, and embedded Pi paths. Adds regression coverage for history sanitization, CLI transcript persistence, media-only auto-reply prompts, and embedded Pi prompt rewrite against a real SessionManager file. Co-authored-by: 91wan <91wan@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/reference/transcript-hygiene.md | 15 +++ docs/web/webchat.md | 3 +- src/agents/agent-command.ts | 5 + src/agents/cli-runner/types.ts | 1 + .../command/attempt-execution.cli.test.ts | 35 ++++++ src/agents/command/attempt-execution.ts | 6 +- src/agents/command/types.ts | 2 + src/agents/pi-embedded-runner/run.ts | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 8 ++ src/agents/pi-embedded-runner/run/params.ts | 2 + .../run/transcript-prompt-rewrite.test.ts | 100 ++++++++++++++++++ .../run/transcript-prompt-rewrite.ts | 94 ++++++++++++++++ .../reply/agent-runner-execution.ts | 3 + src/auto-reply/reply/agent-runner.ts | 3 + src/auto-reply/reply/followup-runner.ts | 1 + .../reply/get-reply-run.media-only.test.ts | 2 + src/auto-reply/reply/get-reply-run.ts | 9 +- src/auto-reply/reply/prompt-prelude.ts | 7 ++ src/auto-reply/reply/queue/types.ts | 2 + src/gateway/chat-sanitize.ts | 10 +- src/gateway/session-history-state.test.ts | 32 ++++++ src/gateway/session-history-state.ts | 8 +- 23 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 src/agents/pi-embedded-runner/run/transcript-prompt-rewrite.test.ts create mode 100644 src/agents/pi-embedded-runner/run/transcript-prompt-rewrite.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f2b707badc..7ef3db16e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Changes +- WebChat/sessions: keep runtime-only prompt context out of visible transcript history and scrub legacy wrappers from session history surfaces. Thanks @91wan. - Gradium: add a bundled text-to-speech provider with voice-note and telephony output support. (#64958) Thanks @LaurentMazare. - Plugins/setup: honor explicit `setup.requiresRuntime: false` as a descriptor-only setup contract while keeping omitted values on the legacy setup-api fallback path. Thanks @vincentkoc. - Plugins/setup: report descriptor/runtime drift when setup-api registrations disagree with `setup.providers` or `setup.cliBackends`, without rejecting legacy setup plugins. Thanks @vincentkoc. diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md index 52980104bf8..66a66f519e2 100644 --- a/docs/reference/transcript-hygiene.md +++ b/docs/reference/transcript-hygiene.md @@ -16,6 +16,7 @@ file is backed up alongside the session file. Scope includes: +- Runtime-only prompt context staying out of user-visible transcript turns - Tool call id sanitization - Tool call input validation - Tool result pairing repair @@ -30,6 +31,20 @@ If you need transcript storage details, see: --- +## Global rule: runtime context is not user transcript + +Runtime/system context can be added to the model prompt for a turn, but it is +not end-user-authored content. OpenClaw keeps a separate transcript-facing +prompt body for Gateway replies, queued followups, ACP, CLI, and embedded Pi +runs. Stored visible user turns use that transcript body instead of the +runtime-enriched prompt. + +For legacy sessions that already persisted runtime wrappers, Gateway history +surfaces apply a display projection before returning messages to WebChat, +TUI, REST, or SSE clients. + +--- + ## Where this runs All transcript hygiene is centralized in the embedded runner: diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 93f0456f556..a423fc61191 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -24,7 +24,8 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. - The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`. - `chat.history` is bounded for stability: Gateway may truncate long text fields, omit heavy metadata, and replace oversized entries with `[chat.history omitted: message too large]`. -- `chat.history` is also display-normalized: inline delivery directive tags +- `chat.history` is also display-normalized: runtime-only OpenClaw context, + inbound envelope wrappers, inline delivery directive tags such as `[[reply_to_*]]` and `[[audio_as_voice]]`, plain-text tool-call XML payloads (including `...`, `...`, `...`, diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 7ddf6000c79..88b7d393987 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -248,6 +248,7 @@ async function prepareAgentCommandExecution( throw new Error("Message (--message) is required"); } const body = prependInternalEventContext(message, opts.internalEvents); + const transcriptBody = opts.transcriptMessage ?? message; if (!opts.to && !opts.sessionId && !opts.sessionKey && !opts.agentId) { throw new Error("Pass --to , --session-id, or --agent to choose a session"); } @@ -368,6 +369,7 @@ async function prepareAgentCommandExecution( return { body, + transcriptBody, cfg, normalizedSpawned, agentCfg, @@ -402,6 +404,7 @@ async function agentCommandInternal( const prepared = await prepareAgentCommandExecution(opts, runtime); const { body, + transcriptBody, cfg, normalizedSpawned, agentCfg, @@ -523,6 +526,7 @@ async function agentCommandInternal( const { resolveAcpSessionCwd } = await loadAcpSessionIdentifiersRuntime(); sessionEntry = await attemptExecutionRuntime.persistAcpTurnTranscript({ body, + transcriptBody, finalText: finalTextRaw, sessionId, sessionKey, @@ -1068,6 +1072,7 @@ async function agentCommandInternal( try { sessionEntry = await attemptExecutionRuntime.persistCliTurnTranscript({ body, + transcriptBody, result, sessionId, sessionKey: sessionKey ?? sessionId, diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index a164157539b..51a31dfbfc5 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -19,6 +19,7 @@ export type RunCliAgentParams = { workspaceDir: string; config?: OpenClawConfig; prompt: string; + transcriptPrompt?: string; provider: string; model?: string; thinkLevel?: ThinkLevel; diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 04c6fc09809..b7b5b90044b 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -386,6 +386,41 @@ describe("CLI attempt execution", () => { }); }); + it("persists the transcript body instead of runtime-only CLI prompt context", async () => { + const sessionKey = "agent:main:subagent:cli-transcript-clean"; + const sessionEntry: SessionEntry = { + sessionId: "session-cli-transcript-clean", + updatedAt: Date.now(), + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const updatedEntry = await persistCliTurnTranscript({ + body: [ + "<<>>", + "secret runtime context", + "<<>>", + "", + "visible ask", + ].join("\n"), + transcriptBody: "visible ask", + result: makeCliResult("hello from cli"), + sessionId: sessionEntry.sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId: "main", + sessionCwd: tmpDir, + }); + + const messages = await readSessionMessages(updatedEntry?.sessionFile ?? ""); + expect(messages[0]).toMatchObject({ + role: "user", + content: "visible ask", + }); + }); + it("forwards user trigger and channel context to CLI runs", async () => { const sessionKey = "agent:main:direct:claude-channel-context"; const sessionEntry: SessionEntry = { diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index f5580c1d61e..a5d7d6d1d17 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -64,6 +64,7 @@ type TranscriptUsage = { type PersistTextTurnTranscriptParams = { body: string; + transcriptBody?: string; finalText: string; sessionId: string; sessionKey: string; @@ -97,7 +98,7 @@ function resolveTranscriptUsage(usage: PersistTextTurnTranscriptParams["assistan async function persistTextTurnTranscript( params: PersistTextTurnTranscriptParams, ): Promise { - const promptText = params.body; + const promptText = params.transcriptBody ?? params.body; const replyText = params.finalText; if (!promptText && !replyText) { return params.sessionEntry; @@ -169,6 +170,7 @@ function isClaudeCliProvider(provider: string): boolean { export async function persistAcpTurnTranscript(params: { body: string; + transcriptBody?: string; finalText: string; sessionId: string; sessionKey: string; @@ -191,6 +193,7 @@ export async function persistAcpTurnTranscript(params: { export async function persistCliTurnTranscript(params: { body: string; + transcriptBody?: string; result: EmbeddedPiRunResult; sessionId: string; sessionKey: string; @@ -207,6 +210,7 @@ export async function persistCliTurnTranscript(params: { return await persistTextTurnTranscript({ body: params.body, + transcriptBody: params.transcriptBody, finalText: replyText, sessionId: params.sessionId, sessionKey: params.sessionKey, diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index bbe2f19c7da..66d3474e80c 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -27,6 +27,8 @@ export type AgentRunContext = { export type AgentCommandOpts = { message: string; + /** User-visible transcript body; defaults to message and excludes runtime-only context. */ + transcriptMessage?: string; /** Optional image attachments for multimodal messages. */ images?: ImageContent[]; /** Original inline/offloaded attachment order for inbound images. */ diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 8809d08093c..497856dcf57 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -866,6 +866,7 @@ export async function runEmbeddedPiAgent( contextTokenBudget: ctxInfo.tokens, skillsSnapshot: params.skillsSnapshot, prompt, + transcriptPrompt: params.transcriptPrompt, images: params.images, imageOrder: params.imageOrder, clientTools: params.clientTools, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 25fff63fadf..f3ed025fa00 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -288,6 +288,7 @@ import { PREEMPTIVE_OVERFLOW_ERROR_TEXT, shouldPreemptivelyCompactBeforePrompt, } from "./preemptive-compaction.js"; +import { rewriteSubmittedPromptTranscript } from "./transcript-prompt-rewrite.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; export { @@ -2424,6 +2425,13 @@ export async function runEmbeddedAttempt( } else { await abortable(activeSession.prompt(effectivePrompt)); } + rewriteSubmittedPromptTranscript({ + sessionManager, + sessionFile: params.sessionFile, + previousLeafId: transcriptLeafId, + submittedPrompt: effectivePrompt, + transcriptPrompt: params.transcriptPrompt, + }); } } catch (err) { yieldAborted = diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 1873f2cfbb3..49e4fda940e 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -78,6 +78,8 @@ export type RunEmbeddedPiAgentParams = { config?: OpenClawConfig; skillsSnapshot?: SkillSnapshot; prompt: string; + /** User-visible prompt body to persist instead of runtime-enriched prompt text. */ + transcriptPrompt?: string; images?: ImageContent[]; imageOrder?: PromptImageOrderEntry[]; /** Optional client-provided tools (OpenResponses hosted tools). */ diff --git a/src/agents/pi-embedded-runner/run/transcript-prompt-rewrite.test.ts b/src/agents/pi-embedded-runner/run/transcript-prompt-rewrite.test.ts new file mode 100644 index 00000000000..0d04bd98706 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/transcript-prompt-rewrite.test.ts @@ -0,0 +1,100 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { onSessionTranscriptUpdate } from "../../../sessions/transcript-events.js"; +import { rewriteSubmittedPromptTranscript } from "./transcript-prompt-rewrite.js"; + +type AppendMessage = Parameters[0]; + +let tmpDir: string | undefined; + +async function createTmpDir(): Promise { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "transcript-prompt-rewrite-")); + return tmpDir; +} + +afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + tmpDir = undefined; + } +}); + +function getUserTextMessages(sessionManager: SessionManager): string[] { + const messages: string[] = []; + for (const entry of sessionManager.getBranch()) { + if (entry.type !== "message" || entry.message.role !== "user") { + continue; + } + const content = (entry.message as { content?: unknown }).content; + if (typeof content === "string") { + messages.push(content); + continue; + } + if (!Array.isArray(content)) { + messages.push(""); + continue; + } + messages.push( + content + .map((block) => + block && + typeof block === "object" && + typeof (block as { text?: unknown }).text === "string" + ? (block as { text: string }).text + : "", + ) + .join(""), + ); + } + return messages; +} + +describe("rewriteSubmittedPromptTranscript", () => { + it("rewrites only the submitted embedded Pi prompt in a real session file", async () => { + const sessionDir = await createTmpDir(); + const sessionManager = SessionManager.create(sessionDir, sessionDir); + const submittedPrompt = + "visible ask\n\n<<>>\nsecret runtime context\n<<>>"; + const transcriptPrompt = "visible ask"; + + sessionManager.appendMessage({ + role: "user", + content: submittedPrompt, + timestamp: 1, + }); + const previousLeafId = sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "old answer" }], + timestamp: 2, + } as AppendMessage); + sessionManager.appendMessage({ + role: "user", + content: submittedPrompt, + timestamp: 3, + }); + const sessionFile = sessionManager.getSessionFile(); + expect(sessionFile).toBeTruthy(); + + const listener = vi.fn(); + const cleanup = onSessionTranscriptUpdate(listener); + try { + rewriteSubmittedPromptTranscript({ + sessionManager, + sessionFile: sessionFile!, + previousLeafId, + submittedPrompt, + transcriptPrompt, + }); + } finally { + cleanup(); + } + + expect(listener).toHaveBeenCalledWith({ sessionFile }); + + const reopenedSession = SessionManager.open(sessionFile!); + expect(getUserTextMessages(reopenedSession)).toEqual([submittedPrompt, transcriptPrompt]); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/transcript-prompt-rewrite.ts b/src/agents/pi-embedded-runner/run/transcript-prompt-rewrite.ts new file mode 100644 index 00000000000..5dafc4ebb58 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/transcript-prompt-rewrite.ts @@ -0,0 +1,94 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { emitSessionTranscriptUpdate } from "../../../sessions/transcript-events.js"; +import { rewriteTranscriptEntriesInSessionManager } from "../transcript-rewrite.js"; + +type SessionManagerLike = ReturnType; + +function extractPromptTextFromMessage(message: AgentMessage): string | undefined { + const content = (message as { content?: unknown }).content; + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return undefined; + } + const textBlocks = content + .map((block) => + block && typeof block === "object" && typeof (block as { text?: unknown }).text === "string" + ? (block as { text: string }).text + : undefined, + ) + .filter((text): text is string => typeof text === "string"); + return textBlocks.length > 0 ? textBlocks.join("") : undefined; +} + +function replacePromptTextInMessage(message: AgentMessage, text: string): AgentMessage { + const content = (message as { content?: unknown }).content; + const entry = message as unknown as Record; + if (typeof content === "string") { + return { ...entry, content: text } as AgentMessage; + } + if (!Array.isArray(content)) { + return { ...entry, content: text } as AgentMessage; + } + let replaced = false; + const nextContent: unknown[] = []; + for (const block of content) { + if ( + replaced || + !block || + typeof block !== "object" || + typeof (block as { text?: unknown }).text !== "string" + ) { + nextContent.push(block); + continue; + } + replaced = true; + nextContent.push({ ...(block as Record), text }); + } + return { + ...entry, + content: replaced ? nextContent : text, + } as AgentMessage; +} + +export function rewriteSubmittedPromptTranscript(params: { + sessionManager: SessionManagerLike; + sessionFile: string; + previousLeafId: string | null; + submittedPrompt: string; + transcriptPrompt?: string; +}): void { + const transcriptPrompt = params.transcriptPrompt; + if (transcriptPrompt === undefined || transcriptPrompt === params.submittedPrompt) { + return; + } + const replacementText = transcriptPrompt.trim() || "[OpenClaw runtime event]"; + const branch = params.sessionManager.getBranch(); + const startIndex = params.previousLeafId + ? Math.max(0, branch.findIndex((entry) => entry.id === params.previousLeafId) + 1) + : 0; + const target = branch.slice(startIndex).find((entry) => { + if (entry.type !== "message" || entry.message.role !== "user") { + return false; + } + const text = extractPromptTextFromMessage(entry.message as AgentMessage); + return text === params.submittedPrompt; + }); + if (!target || target.type !== "message") { + return; + } + const result = rewriteTranscriptEntriesInSessionManager({ + sessionManager: params.sessionManager, + replacements: [ + { + entryId: target.id, + message: replacePromptTextInMessage(target.message, replacementText), + }, + ], + }); + if (result.changed) { + emitSessionTranscriptUpdate(params.sessionFile); + } +} diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 705648226ff..e15651da217 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -569,6 +569,7 @@ function isReplyOperationRestartAbort(replyOperation?: ReplyOperation): boolean export async function runAgentTurnWithFallback(params: { commandBody: string; + transcriptCommandBody?: string; followupRun: FollowupRun; sessionCtx: TemplateContext; replyThreading?: TemplateContext["ReplyThreading"]; @@ -965,6 +966,7 @@ export async function runAgentTurnWithFallback(params: { workspaceDir: params.followupRun.run.workspaceDir, config: runtimeConfig, prompt: params.commandBody, + transcriptPrompt: params.transcriptCommandBody, provider, model, thinkLevel: params.followupRun.run.thinkLevel, @@ -1087,6 +1089,7 @@ export async function runAgentTurnWithFallback(params: { ...runBaseParams, sandboxSessionKey: params.runtimePolicySessionKey, prompt: params.commandBody, + transcriptPrompt: params.transcriptCommandBody, extraSystemPrompt: params.followupRun.run.extraSystemPrompt, toolResultFormat: (() => { const channel = resolveMessageChannel( diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 9e390022d8c..bff832293bd 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -861,6 +861,7 @@ function refreshSessionEntryFromStore(params: { export async function runReplyAgent(params: { commandBody: string; + transcriptCommandBody?: string; followupRun: FollowupRun; queueKey: string; resolvedQueue: QueueSettings; @@ -897,6 +898,7 @@ export async function runReplyAgent(params: { }): Promise { const { commandBody, + transcriptCommandBody, followupRun, queueKey, resolvedQueue, @@ -1198,6 +1200,7 @@ export async function runReplyAgent(params: { const runStartedAt = Date.now(); const runOutcome = await runAgentTurnWithFallback({ commandBody, + transcriptCommandBody, followupRun, sessionCtx, replyThreading: replyThreadingOverride ?? sessionCtx.ReplyThreading, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 0a527810c97..a8efcf9a4d6 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -310,6 +310,7 @@ export function createFollowupRunner(params: { config: runtimeConfig, skillsSnapshot: run.skillsSnapshot, prompt: queued.prompt, + transcriptPrompt: queued.transcriptPrompt, extraSystemPrompt: run.extraSystemPrompt, ownerNumbers: run.ownerNumbers, enforceFinalTag: run.enforceFinalTag, diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 358c3e7fa5e..1a96d567000 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -835,8 +835,10 @@ describe("runPreparedReply media-only handling", () => { const call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0]; expect(call?.commandBody).toContain("System: [t] Initial event."); expect(call?.commandBody).not.toContain("System: [t] Post-compaction context."); + expect(call?.transcriptCommandBody).not.toContain("System: [t] Initial event."); expect(call?.followupRun.prompt).toContain("System: [t] Initial event."); expect(call?.followupRun.prompt).not.toContain("System: [t] Post-compaction context."); + expect(call?.followupRun.transcriptPrompt).not.toContain("System: [t] Initial event."); }); it("uses inbound origin channel for run messageProvider", async () => { await runPreparedReply( diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 16ee2ec09d7..01dab509e0b 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -475,6 +475,7 @@ export async function runPreparedReply( const effectiveBaseBody = hasUserBody ? baseBodyForPrompt : [inboundUserContext, "[User sent media without caption]"].filter(Boolean).join("\n\n"); + const transcriptBodyBase = hasUserBody ? baseBodyFinal : "[User sent media without caption]"; let prefixedBodyBase = await applySessionHints({ baseBody: effectiveBaseBody, abortedLastRun, @@ -510,6 +511,7 @@ export async function runPreparedReply( const rebuildPromptBodies = async (): Promise<{ prefixedCommandBody: string; queuedBody: string; + transcriptCommandBody: string; }> => { if (!useFastReplyRuntime) { const eventsBlock = await drainFormattedSystemEvents({ @@ -530,6 +532,7 @@ export async function runPreparedReply( sessionCtx, effectiveBaseBody, prefixedBody: prefixedBodyCore, + transcriptBody: transcriptBodyBase, threadContextNote, systemEventBlocks: drainedSystemEventBlocks, }); @@ -558,7 +561,7 @@ export async function runPreparedReply( sessionEntry = skillResult.sessionEntry ?? sessionEntry; currentSystemSent = skillResult.systemSent; const skillsSnapshot = skillResult.skillsSnapshot; - let { prefixedCommandBody, queuedBody } = await rebuildPromptBodies(); + let { prefixedCommandBody, queuedBody, transcriptCommandBody } = await rebuildPromptBodies(); if (!resolvedThinkLevel) { resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel(); } @@ -715,7 +718,7 @@ export async function runPreparedReply( isNewSession, }); preparedSessionState = resolvePreparedSessionState(); - ({ prefixedCommandBody, queuedBody } = await rebuildPromptBodies()); + ({ prefixedCommandBody, queuedBody, transcriptCommandBody } = await rebuildPromptBodies()); }, resolveBusyState: resolveQueueBusyState, }); @@ -728,6 +731,7 @@ export async function runPreparedReply( const authProfileIdSource = preparedSessionState.sessionEntry?.authProfileOverrideSource; const followupRun = { prompt: queuedBody, + transcriptPrompt: transcriptCommandBody, messageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid, summaryLine: baseBodyTrimmedRaw, enqueuedAt: Date.now(), @@ -825,6 +829,7 @@ export async function runPreparedReply( return runReplyAgent({ commandBody: prefixedCommandBody, + transcriptCommandBody, followupRun, queueKey, resolvedQueue, diff --git a/src/auto-reply/reply/prompt-prelude.ts b/src/auto-reply/reply/prompt-prelude.ts index 804dc208c7c..0133f4728c3 100644 --- a/src/auto-reply/reply/prompt-prelude.ts +++ b/src/auto-reply/reply/prompt-prelude.ts @@ -10,6 +10,7 @@ export function buildReplyPromptBodies(params: { sessionCtx: TemplateContext; effectiveBaseBody: string; prefixedBody: string; + transcriptBody?: string; threadContextNote?: string; systemEventBlocks?: string[]; }): { @@ -17,6 +18,7 @@ export function buildReplyPromptBodies(params: { mediaReplyHint?: string; prefixedCommandBody: string; queuedBody: string; + transcriptCommandBody: string; } { const combinedEventsBlock = (params.systemEventBlocks ?? []).filter(Boolean).join("\n"); const prependEvents = (body: string) => @@ -38,10 +40,15 @@ export function buildReplyPromptBodies(params: { const prefixedCommandBody = mediaNote ? [mediaNote, mediaReplyHint, prefixedBody].filter(Boolean).join("\n").trim() : prefixedBody; + const transcriptBody = params.transcriptBody ?? params.effectiveBaseBody; + const transcriptCommandBody = mediaNote + ? [mediaNote, transcriptBody].filter(Boolean).join("\n").trim() + : transcriptBody; return { mediaNote, mediaReplyHint, prefixedCommandBody, queuedBody, + transcriptCommandBody, }; } diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index efb59b2d49a..cac6bf9cc68 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -22,6 +22,8 @@ export type QueueDedupeMode = "message-id" | "prompt" | "none"; export type FollowupRun = { prompt: string; + /** User-visible prompt body persisted to transcript; excludes runtime-only prompt context. */ + transcriptPrompt?: string; /** Provider message ID, when available (for deduplication). */ messageId?: string; summaryLine?: string; diff --git a/src/gateway/chat-sanitize.ts b/src/gateway/chat-sanitize.ts index 7d69f8589e4..9aceceb6164 100644 --- a/src/gateway/chat-sanitize.ts +++ b/src/gateway/chat-sanitize.ts @@ -1,3 +1,4 @@ +import { stripInternalRuntimeContext } from "../agents/internal-runtime-context.js"; import { extractInboundSenderLabel, stripInboundMetadata, @@ -48,7 +49,8 @@ function stripEnvelopeFromContentWithRole( if (entry.type !== "text" || typeof entry.text !== "string") { return item; } - const inboundStripped = stripInboundMetadata(entry.text); + const runtimeStripped = stripInternalRuntimeContext(entry.text); + const inboundStripped = stripInboundMetadata(runtimeStripped); const stripped = stripUserEnvelope ? stripMessageIdHints(stripEnvelope(inboundStripped)) : inboundStripped; @@ -81,7 +83,8 @@ export function stripEnvelopeFromMessage(message: unknown): unknown { } if (typeof entry.content === "string") { - const inboundStripped = stripInboundMetadata(entry.content); + const runtimeStripped = stripInternalRuntimeContext(entry.content); + const inboundStripped = stripInboundMetadata(runtimeStripped); const stripped = stripUserEnvelope ? stripMessageIdHints(stripEnvelope(inboundStripped)) : inboundStripped; @@ -96,7 +99,8 @@ export function stripEnvelopeFromMessage(message: unknown): unknown { changed = true; } } else if (typeof entry.text === "string") { - const inboundStripped = stripInboundMetadata(entry.text); + const runtimeStripped = stripInternalRuntimeContext(entry.text); + const inboundStripped = stripInboundMetadata(runtimeStripped); const stripped = stripUserEnvelope ? stripMessageIdHints(stripEnvelope(inboundStripped)) : inboundStripped; diff --git a/src/gateway/session-history-state.test.ts b/src/gateway/session-history-state.test.ts index 844dbb6546f..a7a847a148f 100644 --- a/src/gateway/session-history-state.test.ts +++ b/src/gateway/session-history-state.test.ts @@ -75,4 +75,36 @@ describe("SessionHistorySseState", () => { expect(snapshot.history.messages[0]?.__openclaw?.seq).toBe(2); expect(snapshot.rawTranscriptSeq).toBe(2); }); + + test("strips legacy internal envelopes before exposing history", () => { + const snapshot = buildSessionHistorySnapshot({ + rawMessages: [ + { + role: "user", + content: [ + { + type: "text", + text: [ + "<<>>", + "secret runtime context", + "<<>>", + "", + "visible ask", + ].join("\n"), + }, + ], + __openclaw: { seq: 1 }, + }, + ], + }); + + expect(snapshot.history.messages).toHaveLength(1); + expect( + ( + snapshot.history.messages[0] as { + content?: Array<{ text?: string }>; + } + ).content?.[0]?.text, + ).toBe("visible ask"); + }); }); diff --git a/src/gateway/session-history-state.ts b/src/gateway/session-history-state.ts index 3c85f3ae1f1..8c076afb5ff 100644 --- a/src/gateway/session-history-state.ts +++ b/src/gateway/session-history-state.ts @@ -1,3 +1,4 @@ +import { stripEnvelopeFromMessages } from "./chat-sanitize.js"; import { DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, sanitizeChatHistoryMessages, @@ -102,7 +103,7 @@ export function buildSessionHistorySnapshot(params: { const history = paginateSessionMessages( toSessionHistoryMessages( sanitizeChatHistoryMessages( - params.rawMessages, + stripEnvelopeFromMessages(params.rawMessages), params.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, ), ), @@ -178,7 +179,10 @@ export class SessionHistorySseState { ...(typeof update.messageId === "string" ? { id: update.messageId } : {}), seq: this.rawTranscriptSeq, }); - const sanitized = sanitizeChatHistoryMessages([nextMessage], this.maxChars); + const sanitized = sanitizeChatHistoryMessages( + stripEnvelopeFromMessages([nextMessage]), + this.maxChars, + ); if (sanitized.length === 0) { return null; }