mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:50:43 +00:00
fix: keep before-agent blocks redacted
This commit is contained in:
@@ -334,30 +334,12 @@ class ChatController(
|
||||
}
|
||||
|
||||
private suspend fun requestChatHistoryJson(sessionKey: String): String {
|
||||
val params =
|
||||
return session.request(
|
||||
"chat.history",
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("includeBlockedOriginalContent", JsonPrimitive(true))
|
||||
}
|
||||
val response = session.requestDetailed("chat.history", params.toString())
|
||||
if (response.ok) return response.payloadJson ?: ""
|
||||
val error = response.error
|
||||
if (
|
||||
error?.code == "INVALID_REQUEST" &&
|
||||
error.message.contains("includeBlockedOriginalContent")
|
||||
) {
|
||||
val legacyParams =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
}
|
||||
val legacyResponse = session.requestDetailed("chat.history", legacyParams.toString())
|
||||
if (legacyResponse.ok) return legacyResponse.payloadJson ?: ""
|
||||
val legacyError = legacyResponse.error
|
||||
throw IllegalStateException(
|
||||
"${legacyError?.code ?: "UNAVAILABLE"}: ${legacyError?.message ?: "request failed"}",
|
||||
)
|
||||
}
|
||||
throw IllegalStateException("${error?.code ?: "UNAVAILABLE"}: ${error?.message ?: "request failed"}")
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun pollHealthIfNeeded(force: Boolean) {
|
||||
@@ -535,21 +517,11 @@ class ChatController(
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
|
||||
val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseMessageContent) ?: emptyList()
|
||||
val originalBlockedContent =
|
||||
obj["__openclaw"]
|
||||
.asObjectOrNull()
|
||||
?.get("originalBlockedContent")
|
||||
.asObjectOrNull()
|
||||
?.get("content")
|
||||
.asArrayOrNull()
|
||||
?.mapNotNull(::parseMessageContent)
|
||||
?: emptyList()
|
||||
val ts = obj["timestamp"].asLongOrNull()
|
||||
ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
role = role,
|
||||
content = content,
|
||||
originalBlockedContent = originalBlockedContent,
|
||||
timestampMs = ts,
|
||||
)
|
||||
}
|
||||
@@ -677,14 +649,6 @@ internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
.orEmpty(),
|
||||
).joinToString(separator = "\u001F")
|
||||
}
|
||||
val blockedFingerprint =
|
||||
message.originalBlockedContent.joinToString(separator = "\u001E") { part ->
|
||||
listOf(
|
||||
part.type.trim().lowercase(),
|
||||
part.text?.trim().orEmpty(),
|
||||
).joinToString(separator = "\u001F")
|
||||
}
|
||||
|
||||
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
|
||||
return listOf(role, timestamp, contentFingerprint, blockedFingerprint).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ data class ChatMessage(
|
||||
val id: String,
|
||||
val role: String,
|
||||
val content: List<ChatMessageContent>,
|
||||
val originalBlockedContent: List<ChatMessageContent> = emptyList(),
|
||||
val timestampMs: Long?,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,44 +57,6 @@ fun ChatMessageBubble(message: ChatMessage) {
|
||||
val role = message.role.trim().lowercase(Locale.US)
|
||||
val style = bubbleStyle(role)
|
||||
val displayContent =
|
||||
if (role == "user" && message.originalBlockedContent.isNotEmpty()) {
|
||||
message.originalBlockedContent
|
||||
} else {
|
||||
message.content
|
||||
}
|
||||
|
||||
// Filter to only displayable content parts (text with content, or base64 images).
|
||||
val displayableContent =
|
||||
displayContent.filter { part ->
|
||||
when (part.type) {
|
||||
"text" -> !part.text.isNullOrBlank()
|
||||
else -> part.base64 != null
|
||||
}
|
||||
}
|
||||
|
||||
if (displayableContent.isEmpty()) return
|
||||
|
||||
ChatBubbleContainer(style = style, roleLabel = roleLabel(role)) {
|
||||
ChatMessageBody(content = displayableContent, textColor = mobileText)
|
||||
if (role == "user" && message.originalBlockedContent.isNotEmpty()) {
|
||||
Surface(
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(0.dp, Color.Transparent),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
androidx.compose.material3.HorizontalDivider(
|
||||
color = mobileText.copy(alpha = 0.18f),
|
||||
thickness = 1.dp,
|
||||
)
|
||||
Text(
|
||||
text = "The agent cannot read this message.",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Medium),
|
||||
color = mobileText.copy(alpha = 0.68f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,35 +54,13 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
struct ParamsWithBlockedOriginals: Codable {
|
||||
var sessionKey: String
|
||||
var includeBlockedOriginalContent: Bool
|
||||
}
|
||||
struct LegacyParams: Codable {
|
||||
var sessionKey: String
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(
|
||||
ParamsWithBlockedOriginals(sessionKey: sessionKey, includeBlockedOriginalContent: true))
|
||||
struct Params: Codable { var sessionKey: String }
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let res: Data
|
||||
do {
|
||||
res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||
} catch {
|
||||
guard Self.isUnsupportedBlockedOriginalHistoryParam(error) else { throw error }
|
||||
let legacyData = try encoder.encode(LegacyParams(sessionKey: sessionKey))
|
||||
let legacyJson = String(data: legacyData, encoding: .utf8)
|
||||
res = try await self.gateway.request(method: "chat.history", paramsJSON: legacyJson, timeoutSeconds: 15)
|
||||
}
|
||||
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res)
|
||||
}
|
||||
|
||||
private static func isUnsupportedBlockedOriginalHistoryParam(_ error: Error) -> Bool {
|
||||
guard let response = error as? GatewayResponseError else { return false }
|
||||
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
|
||||
return response.message.contains("includeBlockedOriginalContent")
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
|
||||
@@ -630,30 +630,14 @@ extension GatewayConnection {
|
||||
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionKey": AnyCodable(resolvedKey),
|
||||
"includeBlockedOriginalContent": AnyCodable(true),
|
||||
]
|
||||
if let limit { params["limit"] = AnyCodable(limit) }
|
||||
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
|
||||
let timeout = timeoutMs.map { Double($0) }
|
||||
do {
|
||||
return try await self.requestDecoded(
|
||||
method: .chatHistory,
|
||||
params: params,
|
||||
timeoutMs: timeout)
|
||||
} catch {
|
||||
guard Self.isUnsupportedBlockedOriginalHistoryParam(error) else { throw error }
|
||||
params.removeValue(forKey: "includeBlockedOriginalContent")
|
||||
return try await self.requestDecoded(
|
||||
method: .chatHistory,
|
||||
params: params,
|
||||
timeoutMs: timeout)
|
||||
}
|
||||
}
|
||||
|
||||
private static func isUnsupportedBlockedOriginalHistoryParam(_ error: Error) -> Bool {
|
||||
guard let response = error as? GatewayResponseError else { return false }
|
||||
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
|
||||
return response.message.contains("includeBlockedOriginalContent")
|
||||
return try await self.requestDecoded(
|
||||
method: .chatHistory,
|
||||
params: params,
|
||||
timeoutMs: timeout)
|
||||
}
|
||||
|
||||
func chatSend(
|
||||
|
||||
@@ -5554,25 +5554,21 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let limit: Int?
|
||||
public let maxchars: Int?
|
||||
public let includeblockedoriginalcontent: Bool?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
limit: Int?,
|
||||
maxchars: Int?,
|
||||
includeblockedoriginalcontent: Bool? = nil)
|
||||
maxchars: Int?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.limit = limit
|
||||
self.maxchars = maxchars
|
||||
self.includeblockedoriginalcontent = includeblockedoriginalcontent
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case limit
|
||||
case maxchars = "maxChars"
|
||||
case includeblockedoriginalcontent = "includeBlockedOriginalContent"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -275,14 +275,7 @@ private struct ChatMessageBody: View {
|
||||
}
|
||||
|
||||
private var displayContent: [OpenClawChatMessageContent] {
|
||||
if self.isBlockedUserMessage, let original = self.message.originalBlockedContent {
|
||||
return original
|
||||
}
|
||||
return self.message.content
|
||||
}
|
||||
|
||||
private var isBlockedUserMessage: Bool {
|
||||
self.isUser && !(self.message.originalBlockedContent?.isEmpty ?? true)
|
||||
self.message.content
|
||||
}
|
||||
|
||||
private var toolCalls: [OpenClawChatMessageContent] {
|
||||
|
||||
@@ -139,25 +139,15 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
|
||||
public var id: UUID = .init()
|
||||
public let role: String
|
||||
public let content: [OpenClawChatMessageContent]
|
||||
public let originalBlockedContent: [OpenClawChatMessageContent]?
|
||||
public let timestamp: Double?
|
||||
public let toolCallId: String?
|
||||
public let toolName: String?
|
||||
public let usage: OpenClawChatUsage?
|
||||
public let stopReason: String?
|
||||
|
||||
private struct OpenClawMetadata: Codable, Sendable {
|
||||
let originalBlockedContent: OriginalBlockedContent?
|
||||
}
|
||||
|
||||
private struct OriginalBlockedContent: Codable, Sendable {
|
||||
let content: [OpenClawChatMessageContent]?
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role
|
||||
case content
|
||||
case openclawMetadata = "__openclaw"
|
||||
case timestamp
|
||||
case toolCallId
|
||||
case tool_call_id
|
||||
@@ -171,7 +161,6 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
|
||||
id: UUID = .init(),
|
||||
role: String,
|
||||
content: [OpenClawChatMessageContent],
|
||||
originalBlockedContent: [OpenClawChatMessageContent]? = nil,
|
||||
timestamp: Double?,
|
||||
toolCallId: String? = nil,
|
||||
toolName: String? = nil,
|
||||
@@ -181,7 +170,6 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.originalBlockedContent = originalBlockedContent
|
||||
self.timestamp = timestamp
|
||||
self.toolCallId = toolCallId
|
||||
self.toolName = toolName
|
||||
@@ -201,9 +189,6 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
|
||||
container.decodeIfPresent(String.self, forKey: .tool_name)
|
||||
self.usage = try container.decodeIfPresent(OpenClawChatUsage.self, forKey: .usage)
|
||||
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
|
||||
let metadata = try container.decodeIfPresent(OpenClawMetadata.self, forKey: .openclawMetadata)
|
||||
self.originalBlockedContent = metadata?.originalBlockedContent?.content
|
||||
|
||||
if let decoded = try? container.decode([OpenClawChatMessageContent].self, forKey: .content) {
|
||||
self.content = decoded
|
||||
return
|
||||
@@ -239,12 +224,7 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable {
|
||||
try container.encodeIfPresent(self.usage, forKey: .usage)
|
||||
try container.encodeIfPresent(self.stopReason, forKey: .stopReason)
|
||||
try container.encode(self.content, forKey: .content)
|
||||
if let originalBlockedContent = self.originalBlockedContent {
|
||||
try container.encode(
|
||||
OpenClawMetadata(
|
||||
originalBlockedContent: OriginalBlockedContent(content: originalBlockedContent)),
|
||||
forKey: .openclawMetadata)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -281,27 +281,12 @@ public final class OpenClawChatViewModel {
|
||||
name: content.name,
|
||||
arguments: content.arguments)
|
||||
}
|
||||
let sanitizedOriginalBlockedContent = message.originalBlockedContent?.map { content -> OpenClawChatMessageContent in
|
||||
guard let text = content.text else { return content }
|
||||
let cleaned = ChatMarkdownPreprocessor.preprocess(markdown: text).cleaned
|
||||
return OpenClawChatMessageContent(
|
||||
type: content.type,
|
||||
text: cleaned,
|
||||
thinking: content.thinking,
|
||||
thinkingSignature: content.thinkingSignature,
|
||||
mimeType: content.mimeType,
|
||||
fileName: content.fileName,
|
||||
content: content.content,
|
||||
id: content.id,
|
||||
name: content.name,
|
||||
arguments: content.arguments)
|
||||
}
|
||||
|
||||
|
||||
return OpenClawChatMessage(
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: sanitizedContent,
|
||||
originalBlockedContent: sanitizedOriginalBlockedContent,
|
||||
timestamp: message.timestamp,
|
||||
toolCallId: message.toolCallId,
|
||||
toolName: message.toolName,
|
||||
@@ -318,21 +303,11 @@ public final class OpenClawChatViewModel {
|
||||
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
|
||||
}.joined(separator: "\\u{001E}")
|
||||
let originalBlockedFingerprint = (message.originalBlockedContent ?? []).map { item in
|
||||
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return [type, text].joined(separator: "\\u{001F}")
|
||||
}.joined(separator: "\\u{001E}")
|
||||
return [contentFingerprint, originalBlockedFingerprint].joined(separator: "\\u{001D}")
|
||||
return contentFingerprint
|
||||
}
|
||||
|
||||
private static func userVisibleContentFingerprint(for message: OpenClawChatMessage) -> String {
|
||||
let content = {
|
||||
if let originalBlockedContent = message.originalBlockedContent, !originalBlockedContent.isEmpty {
|
||||
return originalBlockedContent
|
||||
}
|
||||
return message.content
|
||||
}()
|
||||
let content = message.content
|
||||
return content.map { item in
|
||||
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -404,7 +379,6 @@ public final class OpenClawChatViewModel {
|
||||
id: reusedId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
originalBlockedContent: message.originalBlockedContent,
|
||||
timestamp: message.timestamp,
|
||||
toolCallId: message.toolCallId,
|
||||
toolName: message.toolName,
|
||||
|
||||
@@ -5554,25 +5554,21 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let limit: Int?
|
||||
public let maxchars: Int?
|
||||
public let includeblockedoriginalcontent: Bool?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
limit: Int?,
|
||||
maxchars: Int?,
|
||||
includeblockedoriginalcontent: Bool? = nil)
|
||||
maxchars: Int?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.limit = limit
|
||||
self.maxchars = maxchars
|
||||
self.includeblockedoriginalcontent = includeblockedoriginalcontent
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case limit
|
||||
case maxchars = "maxChars"
|
||||
case includeblockedoriginalcontent = "includeBlockedOriginalContent"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ private func chatBlockedUserMessage(redactedText: String, originalText: String,
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": redactedText]],
|
||||
"__openclaw": [
|
||||
"originalBlockedContent": [
|
||||
"content": [["type": "text", "text": originalText]],
|
||||
],
|
||||
],
|
||||
@@ -630,7 +629,6 @@ extension TestChatTransportState {
|
||||
message.role == "user" &&
|
||||
message.content.compactMap(\.text).joined(separator: "\n") ==
|
||||
"The agent cannot read this message." &&
|
||||
message.originalBlockedContent?.compactMap(\.text).joined(separator: "\n") ==
|
||||
"hello from mac webchat"
|
||||
}
|
||||
let hasAssistant = vm.messages.contains { message in
|
||||
|
||||
@@ -707,9 +707,12 @@ describe("runCliAgent reliability", () => {
|
||||
const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n");
|
||||
const blockedLine = JSON.parse(lines[lines.length - 1]);
|
||||
expect(blockedLine.message.content[0].text).toBe("The agent cannot read this message.");
|
||||
expect(blockedLine.message.__openclaw.originalBlockedContent.content[0].text).toBe(
|
||||
"secret prompt",
|
||||
);
|
||||
expect(JSON.stringify(blockedLine)).not.toContain("secret prompt");
|
||||
expect(blockedLine.message.__openclaw.beforeAgentRunBlocked).toMatchObject({
|
||||
blockedBy: "policy-plugin",
|
||||
reason: "contains protected content",
|
||||
});
|
||||
expect(Object.hasOwn(blockedLine.message.__openclaw, "beforeAgentRunBlocked")).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -246,7 +246,6 @@ export async function runPreparedCliAgent(
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
const nowMs = Date.now();
|
||||
const originalText = params.transcriptPrompt ?? params.prompt;
|
||||
const sessionManager = SessionManager.open(params.sessionFile);
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
@@ -254,8 +253,7 @@ export async function runPreparedCliAgent(
|
||||
timestamp: nowMs,
|
||||
idempotencyKey: `hook-block:before_agent_run:user:${params.runId}`,
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: originalText ? [{ type: "text", text: originalText }] : [],
|
||||
beforeAgentRunBlocked: {
|
||||
blockedBy: block.pluginId,
|
||||
reason: block.reason,
|
||||
blockedAt: nowMs,
|
||||
|
||||
@@ -28,26 +28,6 @@ type HistoryEntry = {
|
||||
summary?: unknown;
|
||||
};
|
||||
|
||||
function stripBlockedOriginalContentMeta(message: unknown): unknown {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
return message;
|
||||
}
|
||||
const record = message as Record<string, unknown>;
|
||||
const openclaw =
|
||||
record.__openclaw && typeof record.__openclaw === "object" && !Array.isArray(record.__openclaw)
|
||||
? (record.__openclaw as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (!openclaw || !Object.hasOwn(openclaw, "originalBlockedContent")) {
|
||||
return message;
|
||||
}
|
||||
const { originalBlockedContent: _originalBlockedContent, ...remainingOpenClaw } = openclaw;
|
||||
const { __openclaw: _openclaw, ...remainingMessage } = record;
|
||||
if (Object.keys(remainingOpenClaw).length === 0) {
|
||||
return remainingMessage;
|
||||
}
|
||||
return { ...remainingMessage, __openclaw: remainingOpenClaw };
|
||||
}
|
||||
|
||||
function coerceHistoryText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
@@ -199,7 +179,7 @@ export async function loadCliSessionHistoryMessages(params: {
|
||||
}): Promise<unknown[]> {
|
||||
const history = (await loadCliSessionEntries(params)).flatMap((entry) => {
|
||||
const candidate = entry as HistoryEntry;
|
||||
return candidate.type === "message" ? [stripBlockedOriginalContentMeta(candidate.message)] : [];
|
||||
return candidate.type === "message" ? [candidate.message] : [];
|
||||
});
|
||||
return limitAgentHookHistoryMessages(history, MAX_CLI_SESSION_HISTORY_MESSAGES);
|
||||
}
|
||||
@@ -228,7 +208,7 @@ export async function loadCliSessionReseedMessages(params: {
|
||||
|
||||
const tailMessages = entries.slice(latestCompactionIndex + 1).flatMap((entry) => {
|
||||
const candidate = entry as HistoryEntry;
|
||||
return candidate.type === "message" ? [stripBlockedOriginalContentMeta(candidate.message)] : [];
|
||||
return candidate.type === "message" ? [candidate.message] : [];
|
||||
});
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -698,6 +698,28 @@ describe("runWithModelFallback", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps before_agent_run hook blocks out of empty-result fallback", () => {
|
||||
const runResult: EmbeddedPiRunResult = {
|
||||
payloads: [],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
livenessState: "blocked",
|
||||
error: {
|
||||
kind: "hook_block",
|
||||
message: "Blocked by before-run policy.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
classifyEmbeddedPiRunResultForModelFallback({
|
||||
provider: "atlassian-ai-gateway-openai",
|
||||
model: "gpt-5.5-2026-04-23",
|
||||
result: runResult,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("uses harness-owned terminal classification for GPT-5 fallback", () => {
|
||||
const runResult: EmbeddedPiRunResult = {
|
||||
payloads: [],
|
||||
|
||||
@@ -18,6 +18,9 @@ function isEmbeddedPiRunResult(value: unknown): value is EmbeddedPiRunResult {
|
||||
}
|
||||
|
||||
function hasDeliberateSilentTerminalReply(result: EmbeddedPiRunResult): boolean {
|
||||
if (result.meta.error?.kind === "hook_block") {
|
||||
return true;
|
||||
}
|
||||
return [result.meta.finalAssistantRawText, result.meta.finalAssistantVisibleText].some(
|
||||
(text) => typeof text === "string" && isSilentReplyPayloadText(text),
|
||||
);
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
|
||||
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
|
||||
});
|
||||
|
||||
it("surfaces before_agent_run hook block messages instead of generic prompt failure text", async () => {
|
||||
it("does not emit a duplicate agent payload when before_agent_run blocks", async () => {
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
|
||||
makeAttemptResult({
|
||||
assistantTexts: [],
|
||||
@@ -64,12 +64,11 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
|
||||
});
|
||||
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
|
||||
expect(result.payloads).toEqual([
|
||||
{
|
||||
text: "Blocked by before-run policy.",
|
||||
isError: true,
|
||||
},
|
||||
]);
|
||||
expect(result.payloads).toEqual([]);
|
||||
expect(result.meta).toMatchObject({
|
||||
livenessState: "blocked",
|
||||
error: { kind: "hook_block", message: "Blocked by before-run policy." },
|
||||
});
|
||||
expect(result.meta?.error).toEqual({
|
||||
kind: "hook_block",
|
||||
message: "Blocked by before-run policy.",
|
||||
|
||||
@@ -1834,12 +1834,7 @@ export async function runEmbeddedPiAgent(
|
||||
livenessState: "blocked",
|
||||
});
|
||||
return {
|
||||
payloads: [
|
||||
{
|
||||
text: errorText,
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
payloads: [],
|
||||
meta: {
|
||||
durationMs: Date.now() - started,
|
||||
agentMeta: buildErrorAgentMeta({
|
||||
|
||||
@@ -151,15 +151,14 @@ describe("normalizeMessagesForLlmBoundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("strips blocked original content metadata from the LLM boundary", () => {
|
||||
it("keeps only safe blocked metadata at the LLM boundary", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
timestamp: 1,
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret prompt" }],
|
||||
beforeAgentRunBlocked: {
|
||||
blockedBy: "policy-plugin",
|
||||
reason: "contains protected content",
|
||||
blockedAt: 1,
|
||||
@@ -175,7 +174,7 @@ describe("normalizeMessagesForLlmBoundary", () => {
|
||||
expect(output[0]?.content).toEqual([
|
||||
{ type: "text", text: "The agent cannot read this message." },
|
||||
]);
|
||||
expect(output[0]).not.toHaveProperty("__openclaw");
|
||||
expect(output[0]).toHaveProperty("__openclaw.beforeAgentRunBlocked");
|
||||
expect(JSON.stringify(output)).not.toContain("secret prompt");
|
||||
expect(input[0]).toHaveProperty("__openclaw");
|
||||
});
|
||||
|
||||
@@ -492,39 +492,13 @@ function summarizeSessionContext(messages: AgentMessage[]): {
|
||||
|
||||
export function normalizeMessagesForLlmBoundary(messages: AgentMessage[]): AgentMessage[] {
|
||||
const normalized = stripToolResultDetails(normalizeAssistantReplayContent(messages));
|
||||
return stripBlockedOriginalContentFromMessages(
|
||||
stripHistoricalRuntimeContextCustomMessages(normalized),
|
||||
);
|
||||
return stripHistoricalRuntimeContextCustomMessages(normalized);
|
||||
}
|
||||
|
||||
function cloneHookMessages(messages: AgentMessage[]): AgentMessage[] {
|
||||
return messages.map((message) => structuredClone(message));
|
||||
}
|
||||
|
||||
function stripBlockedOriginalContentFromMessages(messages: AgentMessage[]): AgentMessage[] {
|
||||
return messages.map(stripBlockedOriginalContentFromMessage);
|
||||
}
|
||||
|
||||
function stripBlockedOriginalContentFromMessage(message: AgentMessage): AgentMessage {
|
||||
const record = message as AgentMessage & { __openclaw?: unknown };
|
||||
const meta =
|
||||
record.__openclaw && typeof record.__openclaw === "object" && !Array.isArray(record.__openclaw)
|
||||
? (record.__openclaw as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (!meta || !Object.hasOwn(meta, "originalBlockedContent")) {
|
||||
return message;
|
||||
}
|
||||
const { originalBlockedContent: _originalBlockedContent, ...remainingMeta } = meta;
|
||||
const { __openclaw: _openclaw, ...remainingMessage } = record;
|
||||
if (Object.keys(remainingMeta).length === 0) {
|
||||
return remainingMessage as AgentMessage;
|
||||
}
|
||||
return {
|
||||
...remainingMessage,
|
||||
__openclaw: remainingMeta,
|
||||
} as unknown as AgentMessage;
|
||||
}
|
||||
|
||||
function sessionMessagesContainIdempotencyKey(
|
||||
messages: AgentMessage[],
|
||||
idempotencyKey: string,
|
||||
@@ -2829,18 +2803,13 @@ export async function runEmbeddedAttempt(
|
||||
return true;
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const originalBlockedContent =
|
||||
blockedTranscriptPrompt.length > 0
|
||||
? [{ type: "text" as const, text: blockedTranscriptPrompt }]
|
||||
: [];
|
||||
const redactedUserMessage = {
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: block.message }],
|
||||
timestamp: nowMs,
|
||||
idempotencyKey,
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: originalBlockedContent,
|
||||
beforeAgentRunBlocked: {
|
||||
blockedBy: block.pluginId,
|
||||
reason: block.reason,
|
||||
blockedAt: nowMs,
|
||||
@@ -3352,9 +3321,7 @@ export async function runEmbeddedAttempt(
|
||||
);
|
||||
}
|
||||
}
|
||||
messagesSnapshot = stripBlockedOriginalContentFromMessages(
|
||||
snapshotSelection.messagesSnapshot,
|
||||
);
|
||||
messagesSnapshot = snapshotSelection.messagesSnapshot;
|
||||
sessionIdUsed = snapshotSelection.sessionIdUsed;
|
||||
|
||||
lastAssistant = messagesSnapshot
|
||||
|
||||
@@ -347,14 +347,6 @@ function sanitizeChatHistoryMessage(
|
||||
}
|
||||
}
|
||||
|
||||
if ("__openclaw" in entry) {
|
||||
const sanitized = sanitizeBlockedOriginalContentMeta(entry.__openclaw, maxChars);
|
||||
if (sanitized.changed) {
|
||||
entry.__openclaw = sanitized.meta;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { message: changed ? entry : message, changed };
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ export const ChatHistoryParamsSchema = Type.Object(
|
||||
sessionKey: NonEmptyString,
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
|
||||
maxChars: Type.Optional(Type.Integer({ minimum: 1, maximum: 500_000 })),
|
||||
includeBlockedOriginalContent: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -570,137 +570,6 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
mockState.dispatchBlockedByBeforeAgentRun = false;
|
||||
});
|
||||
|
||||
it("includes blocked original content for scoped chat history callers", async () => {
|
||||
createTranscriptFixture("openclaw-chat-history-blocked-original-");
|
||||
fs.writeFileSync(
|
||||
mockState.transcriptPath,
|
||||
[
|
||||
{
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: mockState.sessionId,
|
||||
timestamp: new Date(0).toISOString(),
|
||||
cwd: "/tmp",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "blocked-1",
|
||||
parentId: null,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
timestamp: 1,
|
||||
},
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
blockedBy: "policy-plugin",
|
||||
reason: "blocked by policy",
|
||||
blockedAt: 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const scoped = await runChatHistory({
|
||||
client: createScopedCliClient(["operator.admin"]),
|
||||
requestParams: { includeBlockedOriginalContent: true },
|
||||
});
|
||||
expect(
|
||||
(
|
||||
scoped.messages?.[0] as {
|
||||
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
|
||||
}
|
||||
)?.__openclaw?.originalBlockedContent?.content?.[0]?.text,
|
||||
).toBe("secret blocked prompt");
|
||||
|
||||
const sensitiveScoped = await runChatHistory({
|
||||
client: createScopedCliClient(["operator.talk.secrets"]),
|
||||
requestParams: { includeBlockedOriginalContent: true },
|
||||
});
|
||||
expect(
|
||||
(
|
||||
sensitiveScoped.messages?.[0] as {
|
||||
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
|
||||
}
|
||||
)?.__openclaw?.originalBlockedContent?.content?.[0]?.text,
|
||||
).toBe("secret blocked prompt");
|
||||
|
||||
const writeScoped = await runChatHistory({
|
||||
client: createScopedCliClient(["operator.write"]),
|
||||
requestParams: { includeBlockedOriginalContent: true },
|
||||
});
|
||||
expect(
|
||||
(
|
||||
writeScoped.messages?.[0] as {
|
||||
__openclaw?: { originalBlockedContent?: unknown };
|
||||
}
|
||||
)?.__openclaw?.originalBlockedContent,
|
||||
).toBeUndefined();
|
||||
|
||||
const unscoped = await runChatHistory({
|
||||
client: createScopedCliClient(["operator.read"]),
|
||||
requestParams: { includeBlockedOriginalContent: true },
|
||||
});
|
||||
expect(
|
||||
(
|
||||
unscoped.messages?.[0] as {
|
||||
__openclaw?: { originalBlockedContent?: unknown };
|
||||
}
|
||||
)?.__openclaw?.originalBlockedContent,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies chat history text caps to blocked original content", async () => {
|
||||
createTranscriptFixture("openclaw-chat-history-blocked-original-maxchars-");
|
||||
fs.writeFileSync(
|
||||
mockState.transcriptPath,
|
||||
[
|
||||
{
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: mockState.sessionId,
|
||||
timestamp: new Date(0).toISOString(),
|
||||
cwd: "/tmp",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "blocked-1",
|
||||
parentId: null,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
timestamp: 1,
|
||||
},
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret ".repeat(20) }],
|
||||
blockedBy: "policy-plugin",
|
||||
reason: "blocked by policy",
|
||||
blockedAt: 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const scoped = await runChatHistory({
|
||||
client: createScopedCliClient(["operator.admin"]),
|
||||
requestParams: { includeBlockedOriginalContent: true, maxChars: 24 },
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
scoped.messages?.[0] as {
|
||||
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
|
||||
}
|
||||
)?.__openclaw?.originalBlockedContent?.content?.[0]?.text,
|
||||
).toBe("secret secret secret sec\n...(truncated)...");
|
||||
});
|
||||
|
||||
it("registers tool-event recipients for clients advertising tool-events capability", async () => {
|
||||
createTranscriptFixture("openclaw-chat-send-tool-events-");
|
||||
mockState.finalText = "ok";
|
||||
@@ -2277,51 +2146,6 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit raw user transcript content after before_agent_run blocks", async () => {
|
||||
createTranscriptFixture("openclaw-chat-send-user-transcript-blocked-gate-");
|
||||
mockState.finalText = "The agent cannot read this message.";
|
||||
mockState.triggerAgentRunStart = true;
|
||||
mockState.hasBeforeAgentRunHooks = true;
|
||||
mockState.onAfterAgentRunStart = () => {
|
||||
fs.appendFileSync(
|
||||
mockState.transcriptPath,
|
||||
`${JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
idempotencyKey: "hook-block:before_agent_run:user:idem-user-transcript-blocked-gate",
|
||||
},
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret prompt that was blocked" }],
|
||||
blockedBy: "policy-plugin",
|
||||
reason: "contains protected content",
|
||||
blockedAt: 1,
|
||||
},
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
};
|
||||
const respond = vi.fn();
|
||||
const context = createChatContext();
|
||||
|
||||
await runNonStreamingChatSend({
|
||||
context,
|
||||
respond,
|
||||
idempotencyKey: "idem-user-transcript-blocked-gate",
|
||||
message: "secret prompt that was blocked",
|
||||
expectBroadcast: false,
|
||||
});
|
||||
|
||||
const userUpdates = mockState.emittedTranscriptUpdates.filter(
|
||||
(update) =>
|
||||
typeof update.message === "object" &&
|
||||
update.message !== null &&
|
||||
(update.message as { role?: unknown }).role === "user",
|
||||
);
|
||||
expect(userUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not emit raw user transcript content when before_agent_run blocks without a persisted marker", async () => {
|
||||
createTranscriptFixture("openclaw-chat-send-user-transcript-blocked-live-signal-");
|
||||
mockState.finalText = "The agent cannot read this message.";
|
||||
|
||||
@@ -761,11 +761,6 @@ function canInjectSystemProvenance(client: GatewayRequestHandlerOptions["client"
|
||||
return scopes.includes(ADMIN_SCOPE);
|
||||
}
|
||||
|
||||
function canIncludeBlockedOriginalContent(client: GatewayRequestHandlerOptions["client"]): boolean {
|
||||
const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||
return scopes.includes(ADMIN_SCOPE) || scopes.includes(TALK_SECRETS_SCOPE);
|
||||
}
|
||||
|
||||
async function persistChatSendImages(params: {
|
||||
images: ChatImageContent[];
|
||||
imageOrder: PromptImageOrderEntry[];
|
||||
@@ -1731,11 +1726,10 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { sessionKey, limit, maxChars, includeBlockedOriginalContent } = params as {
|
||||
const { sessionKey, limit, maxChars } = params as {
|
||||
sessionKey: string;
|
||||
limit?: number;
|
||||
maxChars?: number;
|
||||
includeBlockedOriginalContent?: boolean;
|
||||
};
|
||||
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
|
||||
const sessionId = entry?.sessionId;
|
||||
@@ -1751,8 +1745,6 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
? await readRecentSessionMessagesAsync(sessionId, storePath, entry?.sessionFile, {
|
||||
maxMessages: max,
|
||||
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
|
||||
includeBlockedOriginalContent:
|
||||
includeBlockedOriginalContent === true && canIncludeBlockedOriginalContent(client),
|
||||
})
|
||||
: [];
|
||||
const rawMessages = augmentChatHistoryWithCliSessionImports({
|
||||
|
||||
@@ -455,31 +455,6 @@ describe("sanitizeChatHistoryMessages", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates blocked original sidecar content with the chat history text cap", () => {
|
||||
const result = sanitizeChatHistoryMessages(
|
||||
[
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret ".repeat(20) }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
24,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
result[0] as {
|
||||
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
|
||||
}
|
||||
).__openclaw?.originalBlockedContent?.content?.[0]?.text,
|
||||
).toBe("secret secret secret sec\n...(truncated)...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectRecentChatDisplayMessages", () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
loadGatewaySessionRow,
|
||||
loadSessionEntry,
|
||||
readSessionMessageCountAsync,
|
||||
stripBlockedOriginalContentMeta,
|
||||
type GatewaySessionRow,
|
||||
} from "./session-utils.js";
|
||||
|
||||
@@ -127,7 +126,7 @@ async function handleTranscriptUpdateBroadcast(
|
||||
sessionRow: loadGatewaySessionRow(sessionKey, { transcriptUsageMaxBytes: 64 * 1024 }),
|
||||
includeSession: true,
|
||||
});
|
||||
const rawMessage = attachOpenClawTranscriptMeta(stripBlockedOriginalContentMeta(update.message), {
|
||||
const rawMessage = attachOpenClawTranscriptMeta(update.message, {
|
||||
...(typeof update.messageId === "string" ? { id: update.messageId } : {}),
|
||||
...(typeof messageSeq === "number" ? { seq: messageSeq } : {}),
|
||||
});
|
||||
|
||||
@@ -288,64 +288,4 @@ describe("SessionHistorySseState", () => {
|
||||
).toBeNull();
|
||||
expect(state.snapshot().messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("strips blocked original content from inline SSE messages", () => {
|
||||
const state = SessionHistorySseState.fromRawSnapshot({
|
||||
target: { sessionId: "sess-main" },
|
||||
rawMessages: [],
|
||||
});
|
||||
|
||||
const appended = state.appendInlineMessage({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
messageId: "blocked-1",
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
appended?.message as {
|
||||
__openclaw?: { originalBlockedContent?: unknown };
|
||||
}
|
||||
).__openclaw?.originalBlockedContent,
|
||||
).toBeUndefined();
|
||||
expect(state.snapshot().messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "The agent cannot read this message." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps blocked original content for authorized inline SSE messages", () => {
|
||||
const state = SessionHistorySseState.fromRawSnapshot({
|
||||
target: { sessionId: "sess-main" },
|
||||
rawMessages: [],
|
||||
includeBlockedOriginalContent: true,
|
||||
});
|
||||
|
||||
const appended = state.appendInlineMessage({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
messageId: "blocked-1",
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
appended?.message as {
|
||||
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
|
||||
}
|
||||
).__openclaw?.originalBlockedContent?.content?.[0]?.text,
|
||||
).toBe("secret blocked prompt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
attachOpenClawTranscriptMeta,
|
||||
readRecentSessionMessagesWithStatsAsync,
|
||||
readSessionMessagesAsync,
|
||||
stripBlockedOriginalContentMeta,
|
||||
} from "./session-utils.js";
|
||||
|
||||
type SessionHistoryTranscriptMeta = {
|
||||
@@ -157,7 +156,6 @@ export class SessionHistorySseState {
|
||||
private readonly maxChars: number;
|
||||
private readonly limit: number | undefined;
|
||||
private readonly cursor: string | undefined;
|
||||
private readonly includeBlockedOriginalContent: boolean;
|
||||
private sentHistory: PaginatedSessionHistory;
|
||||
private rawTranscriptSeq: number;
|
||||
|
||||
@@ -169,14 +167,12 @@ export class SessionHistorySseState {
|
||||
maxChars?: number;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
includeBlockedOriginalContent?: boolean;
|
||||
}): SessionHistorySseState {
|
||||
return new SessionHistorySseState({
|
||||
target: params.target,
|
||||
maxChars: params.maxChars,
|
||||
limit: params.limit,
|
||||
cursor: params.cursor,
|
||||
includeBlockedOriginalContent: params.includeBlockedOriginalContent,
|
||||
initialRawMessages: params.rawMessages,
|
||||
rawTranscriptSeq: params.rawTranscriptSeq,
|
||||
totalRawMessages: params.totalRawMessages,
|
||||
@@ -188,7 +184,6 @@ export class SessionHistorySseState {
|
||||
maxChars?: number;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
includeBlockedOriginalContent?: boolean;
|
||||
initialRawMessages: unknown[];
|
||||
rawTranscriptSeq?: number;
|
||||
totalRawMessages?: number;
|
||||
@@ -197,7 +192,6 @@ export class SessionHistorySseState {
|
||||
this.maxChars = params.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS;
|
||||
this.limit = params.limit;
|
||||
this.cursor = params.cursor;
|
||||
this.includeBlockedOriginalContent = params.includeBlockedOriginalContent === true;
|
||||
const rawSnapshot = {
|
||||
rawMessages: params.initialRawMessages,
|
||||
...(typeof params.rawTranscriptSeq === "number"
|
||||
@@ -235,10 +229,7 @@ export class SessionHistorySseState {
|
||||
return null;
|
||||
}
|
||||
this.rawTranscriptSeq += 1;
|
||||
const projectedMessage = this.includeBlockedOriginalContent
|
||||
? update.message
|
||||
: stripBlockedOriginalContentMeta(update.message);
|
||||
const nextMessage = attachOpenClawTranscriptMeta(projectedMessage, {
|
||||
const nextMessage = attachOpenClawTranscriptMeta(update.message, {
|
||||
...(typeof update.messageId === "string" ? { id: update.messageId } : {}),
|
||||
seq: this.rawTranscriptSeq,
|
||||
});
|
||||
@@ -286,7 +277,6 @@ export class SessionHistorySseState {
|
||||
this.target.sessionFile,
|
||||
{
|
||||
...resolveSessionHistoryTailReadOptions(this.limit),
|
||||
includeBlockedOriginalContent: this.includeBlockedOriginalContent,
|
||||
},
|
||||
);
|
||||
return {
|
||||
@@ -303,7 +293,6 @@ export class SessionHistorySseState {
|
||||
{
|
||||
mode: "full",
|
||||
reason: "session history cursor pagination",
|
||||
includeBlockedOriginalContent: this.includeBlockedOriginalContent,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -314,20 +314,18 @@ describe("session.message websocket events", () => {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
},
|
||||
beforeAgentRunBlocked: { blockedBy: "policy-plugin", reason: "blocked", blockedAt: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payload = messageEvent.payload as {
|
||||
message?: { content?: unknown; __openclaw?: { originalBlockedContent?: unknown } };
|
||||
message?: { content?: unknown; __openclaw?: { beforeAgentRunBlocked?: unknown } };
|
||||
};
|
||||
expect(payload.message?.content).toEqual([
|
||||
{ type: "text", text: "The agent cannot read this message." },
|
||||
]);
|
||||
expect(payload.message?.__openclaw?.originalBlockedContent).toBeUndefined();
|
||||
expect(JSON.stringify(payload.message)).not.toContain("secret blocked prompt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -353,8 +351,7 @@ describe("session.message websocket events", () => {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
beforeAgentRunBlocked: {
|
||||
blockedBy: "policy-plugin",
|
||||
reason: "contains protected content",
|
||||
blockedAt: Date.now(),
|
||||
@@ -368,14 +365,14 @@ describe("session.message websocket events", () => {
|
||||
message?: {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
__openclaw?: { originalBlockedContent?: unknown };
|
||||
__openclaw?: { beforeAgentRunBlocked?: unknown };
|
||||
};
|
||||
};
|
||||
expect(payload.message?.role).toBe("user");
|
||||
expect(payload.message?.content).toEqual([
|
||||
{ type: "text", text: "The agent cannot read this message." },
|
||||
]);
|
||||
expect(payload.message?.__openclaw?.originalBlockedContent).toBeUndefined();
|
||||
expect(JSON.stringify(payload.message)).not.toContain("secret blocked prompt");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ function appendBlockedUserMessageWithSessionManager(params: {
|
||||
timestamp: Date.now(),
|
||||
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
beforeAgentRunBlocked: {
|
||||
content: params.originalText ? [{ type: "text", text: params.originalText }] : [],
|
||||
blockedBy: params.pluginId,
|
||||
reason: params.reason,
|
||||
@@ -1305,9 +1305,7 @@ describe("readSessionMessages", () => {
|
||||
});
|
||||
|
||||
expect(messageId).toBeTruthy();
|
||||
const out = readSessionMessages(sessionId, storePath, sessionFile, {
|
||||
includeBlockedOriginalContent: true,
|
||||
});
|
||||
const out = readSessionMessages(sessionId, storePath, sessionFile, {});
|
||||
expect(
|
||||
out.map((message) => ({
|
||||
role: (message as { role?: string }).role,
|
||||
@@ -1319,8 +1317,8 @@ describe("readSessionMessages", () => {
|
||||
{ role: "user", text: [{ type: "text", text: "Blocked by HITL test hook." }] },
|
||||
]);
|
||||
expect(
|
||||
(out[2] as { __openclaw?: { originalBlockedContent?: { content?: unknown } } }).__openclaw
|
||||
?.originalBlockedContent?.content,
|
||||
(out[2] as { __openclaw?: { beforeAgentRunBlocked?: { content?: unknown } } }).__openclaw
|
||||
?.beforeAgentRunBlocked?.content,
|
||||
).toEqual([{ type: "text", text: "[hitl:block] hello" }]);
|
||||
});
|
||||
|
||||
@@ -1359,17 +1357,15 @@ describe("readSessionMessages", () => {
|
||||
reason: "blocked by test policy",
|
||||
});
|
||||
|
||||
const out = readSessionMessages(sessionId, storePath, sessionFile, {
|
||||
includeBlockedOriginalContent: true,
|
||||
});
|
||||
const out = readSessionMessages(sessionId, storePath, sessionFile, {});
|
||||
expect(
|
||||
out.map((message) => ({
|
||||
role: (message as { role?: string }).role,
|
||||
original: (
|
||||
message as {
|
||||
__openclaw?: { originalBlockedContent?: { content?: Array<{ text?: string }> } };
|
||||
__openclaw?: { beforeAgentRunBlocked?: { content?: Array<{ text?: string }> } };
|
||||
}
|
||||
).__openclaw?.originalBlockedContent?.content?.[0]?.text,
|
||||
).__openclaw?.beforeAgentRunBlocked?.content?.[0]?.text,
|
||||
})),
|
||||
).toEqual([
|
||||
{ role: "user", original: "[hitl:block] first" },
|
||||
|
||||
@@ -139,28 +139,7 @@ export function attachOpenClawTranscriptMeta(
|
||||
};
|
||||
}
|
||||
|
||||
export function stripBlockedOriginalContentMeta(message: unknown): unknown {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
return message;
|
||||
}
|
||||
const record = message as Record<string, unknown>;
|
||||
const existing =
|
||||
record.__openclaw && typeof record.__openclaw === "object" && !Array.isArray(record.__openclaw)
|
||||
? (record.__openclaw as Record<string, unknown>)
|
||||
: null;
|
||||
if (!existing || !("originalBlockedContent" in existing)) {
|
||||
return message;
|
||||
}
|
||||
const { originalBlockedContent: _originalBlockedContent, ...remainingMeta } = existing;
|
||||
return {
|
||||
...record,
|
||||
__openclaw: remainingMeta,
|
||||
};
|
||||
}
|
||||
|
||||
type SessionMessageProjectionOptions = {
|
||||
includeBlockedOriginalContent?: boolean;
|
||||
};
|
||||
type SessionMessageProjectionOptions = Record<never, never>;
|
||||
|
||||
export function readSessionMessages(
|
||||
sessionId: string,
|
||||
@@ -605,7 +584,7 @@ export async function visitSessionMessagesAsync(
|
||||
storePath: string | undefined,
|
||||
sessionFile: string | undefined,
|
||||
visit: (message: unknown, seq: number) => void,
|
||||
opts: { mode: "full"; reason: string; includeBlockedOriginalContent?: boolean },
|
||||
opts: { mode: "full"; reason: string },
|
||||
): Promise<number> {
|
||||
const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile);
|
||||
if (!filePath) {
|
||||
@@ -759,36 +738,9 @@ function parsedSessionEntryToMessage(
|
||||
}
|
||||
const entry = parsed as Record<string, unknown>;
|
||||
if (entry.message) {
|
||||
const messageRecord =
|
||||
entry.message && typeof entry.message === "object" && !Array.isArray(entry.message)
|
||||
? (entry.message as Record<string, unknown>)
|
||||
: undefined;
|
||||
const messageOpenClaw =
|
||||
messageRecord?.__openclaw &&
|
||||
typeof messageRecord.__openclaw === "object" &&
|
||||
!Array.isArray(messageRecord.__openclaw)
|
||||
? (messageRecord.__openclaw as Record<string, unknown>)
|
||||
: undefined;
|
||||
const originalBlockedContent =
|
||||
opts?.includeBlockedOriginalContent === true &&
|
||||
!messageOpenClaw?.originalBlockedContent &&
|
||||
entry.originalBlockedContent &&
|
||||
typeof entry.originalBlockedContent === "object" &&
|
||||
!Array.isArray(entry.originalBlockedContent)
|
||||
? {
|
||||
originalBlockedContent: {
|
||||
content: (entry.originalBlockedContent as { content?: unknown }).content,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const projectedMessage =
|
||||
opts?.includeBlockedOriginalContent === true
|
||||
? entry.message
|
||||
: stripBlockedOriginalContentMeta(entry.message);
|
||||
return attachOpenClawTranscriptMeta(projectedMessage, {
|
||||
return attachOpenClawTranscriptMeta(entry.message, {
|
||||
...(typeof entry.id === "string" ? { id: entry.id } : {}),
|
||||
seq,
|
||||
...originalBlockedContent,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@ export {
|
||||
readSessionTitleFieldsFromTranscriptAsync,
|
||||
readSessionPreviewItemsFromTranscript,
|
||||
readSessionMessagesAsync,
|
||||
stripBlockedOriginalContentMeta,
|
||||
visitSessionMessagesAsync,
|
||||
resolveSessionTranscriptCandidates,
|
||||
} from "./session-utils.fs.js";
|
||||
|
||||
@@ -106,28 +106,21 @@ vi.mock("./session-history-state.js", () => ({
|
||||
history: { items: [], nextCursor: null, messages: [] },
|
||||
}),
|
||||
SessionHistorySseState: {
|
||||
fromRawSnapshot: (params: { includeBlockedOriginalContent?: boolean }) => ({
|
||||
fromRawSnapshot: (_params: unknown) => ({
|
||||
snapshot: () => ({ items: [], nextCursor: null, messages: [] }),
|
||||
appendInlineMessage: ({ message, messageId }: { message: unknown; messageId?: string }) => ({
|
||||
message:
|
||||
params.includeBlockedOriginalContent || !message || typeof message !== "object"
|
||||
? message
|
||||
: (() => {
|
||||
const clone = { ...(message as Record<string, unknown>) };
|
||||
delete clone.__openclaw;
|
||||
return clone;
|
||||
})(),
|
||||
message,
|
||||
messageSeq: 1,
|
||||
messageId,
|
||||
}),
|
||||
refreshAsync: async () => ({
|
||||
items: [
|
||||
params.includeBlockedOriginalContent
|
||||
false
|
||||
? {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
beforeAgentRunBlocked: {
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
},
|
||||
},
|
||||
@@ -139,12 +132,12 @@ vi.mock("./session-history-state.js", () => ({
|
||||
],
|
||||
nextCursor: null,
|
||||
messages: [
|
||||
params.includeBlockedOriginalContent
|
||||
false
|
||||
? {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
beforeAgentRunBlocked: {
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
},
|
||||
},
|
||||
@@ -252,112 +245,6 @@ describe("session history SSE auth revocation", () => {
|
||||
expect(res.writableEnded).toBe(true);
|
||||
});
|
||||
|
||||
it("closes original-content streams when admin scope is downgraded", async () => {
|
||||
currentScopes = ["operator.read", "operator.admin"];
|
||||
const req = new MockReq("/sessions/agent%3Amain/history?includeBlockedOriginalContent=true");
|
||||
const res = new MockRes();
|
||||
|
||||
const handled = await handleSessionHistoryHttpRequest(
|
||||
req as unknown as IncomingMessage,
|
||||
res as unknown as ServerResponse,
|
||||
{ auth: { mode: "trusted-proxy" } as never },
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(transcriptUpdateHandler).toBeTypeOf("function");
|
||||
|
||||
currentScopes = ["operator.read"];
|
||||
|
||||
transcriptUpdateHandler?.({
|
||||
sessionFile: "/tmp/session-1.jsonl",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
messageId: "blocked-1",
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const joined = res.writes.join("");
|
||||
expect(joined).not.toContain("event: message");
|
||||
expect(joined).not.toContain("secret blocked prompt");
|
||||
expect(res.writableEnded).toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes authorized SSE history for redacted blocked update originals", async () => {
|
||||
currentScopes = ["operator.read", "operator.talk.secrets"];
|
||||
const req = new MockReq("/sessions/agent%3Amain/history?includeBlockedOriginalContent=true");
|
||||
const res = new MockRes();
|
||||
|
||||
const handled = await handleSessionHistoryHttpRequest(
|
||||
req as unknown as IncomingMessage,
|
||||
res as unknown as ServerResponse,
|
||||
{ auth: { mode: "trusted-proxy" } as never },
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(transcriptUpdateHandler).toBeTypeOf("function");
|
||||
|
||||
transcriptUpdateHandler?.({
|
||||
sessionFile: "/tmp/session-1.jsonl",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
},
|
||||
messageId: "blocked-1",
|
||||
forceHistoryRefresh: true,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const joined = res.writes.join("");
|
||||
expect(joined).toContain("event: history");
|
||||
expect(joined).toContain("secret blocked prompt");
|
||||
expect(res.writableEnded).toBe(false);
|
||||
});
|
||||
|
||||
it("strips blocked originals on unscoped live inline SSE updates", async () => {
|
||||
currentScopes = ["operator.read"];
|
||||
const req = new MockReq("/sessions/agent%3Amain/history");
|
||||
const res = new MockRes();
|
||||
|
||||
const handled = await handleSessionHistoryHttpRequest(
|
||||
req as unknown as IncomingMessage,
|
||||
res as unknown as ServerResponse,
|
||||
{ auth: { mode: "trusted-proxy" } as never },
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(transcriptUpdateHandler).toBeTypeOf("function");
|
||||
|
||||
transcriptUpdateHandler?.({
|
||||
sessionFile: "/tmp/session-1.jsonl",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
messageId: "blocked-1",
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const joined = res.writes.join("");
|
||||
expect(joined).toContain("event: message");
|
||||
expect(joined).not.toContain("secret blocked prompt");
|
||||
expect(res.writableEnded).toBe(false);
|
||||
});
|
||||
|
||||
it("rechecks SSE auth against live proxy config instead of startup fallbacks", async () => {
|
||||
const req = new MockReq("/sessions/agent%3Amain/history");
|
||||
const res = new MockRes();
|
||||
|
||||
@@ -80,18 +80,6 @@ function resolveLimit(req: IncomingMessage): number | undefined {
|
||||
return Math.min(MAX_SESSION_HISTORY_LIMIT, Math.max(1, value));
|
||||
}
|
||||
|
||||
function shouldIncludeBlockedOriginalContent(
|
||||
req: IncomingMessage,
|
||||
requestAuth: Parameters<typeof resolveTrustedHttpOperatorScopes>[1],
|
||||
): boolean {
|
||||
const raw = getRequestUrl(req).searchParams.get("includeBlockedOriginalContent");
|
||||
if (raw !== "1" && raw !== "true") {
|
||||
return false;
|
||||
}
|
||||
const scopes = resolveTrustedHttpOperatorScopes(req, requestAuth);
|
||||
return scopes.includes(ADMIN_SCOPE) || scopes.includes(TALK_SECRETS_SCOPE);
|
||||
}
|
||||
|
||||
function canonicalizePath(value: string | undefined): string | undefined {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
if (!trimmed) {
|
||||
@@ -149,8 +137,7 @@ export async function handleSessionHistoryHttpRequest(
|
||||
if (!authResult) {
|
||||
return true;
|
||||
}
|
||||
const { cfg, requestAuth } = authResult;
|
||||
const includeBlockedOriginalContent = shouldIncludeBlockedOriginalContent(req, requestAuth);
|
||||
const { cfg } = authResult;
|
||||
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey });
|
||||
const store = loadSessionStore(target.storePath);
|
||||
@@ -179,7 +166,6 @@ export async function handleSessionHistoryHttpRequest(
|
||||
entry.sessionFile,
|
||||
{
|
||||
...resolveSessionHistoryTailReadOptions(limit),
|
||||
includeBlockedOriginalContent,
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
@@ -191,7 +177,6 @@ export async function handleSessionHistoryHttpRequest(
|
||||
? await readSessionMessagesAsync(entry.sessionId, target.storePath, entry.sessionFile, {
|
||||
mode: "full",
|
||||
reason: "session history cursor pagination",
|
||||
includeBlockedOriginalContent,
|
||||
})
|
||||
: []);
|
||||
const historySnapshot = buildSessionHistorySnapshot({
|
||||
@@ -238,7 +223,6 @@ export async function handleSessionHistoryHttpRequest(
|
||||
maxChars: effectiveMaxChars,
|
||||
limit,
|
||||
cursor,
|
||||
includeBlockedOriginalContent,
|
||||
});
|
||||
sentHistory = sseState.snapshot();
|
||||
setSseHeaders(res);
|
||||
@@ -308,13 +292,6 @@ export async function handleSessionHistoryHttpRequest(
|
||||
return false;
|
||||
}
|
||||
const requestedScopes = resolveTrustedHttpOperatorScopes(req, currentRequestAuth.requestAuth);
|
||||
if (
|
||||
includeBlockedOriginalContent &&
|
||||
!requestedScopes.includes(ADMIN_SCOPE) &&
|
||||
!requestedScopes.includes(TALK_SECRETS_SCOPE)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return authorizeOperatorScopesForMethod("chat.history", requestedScopes).allowed;
|
||||
};
|
||||
|
||||
|
||||
@@ -664,11 +664,7 @@ describe("grouped chat rendering", () => {
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
},
|
||||
},
|
||||
__openclaw: {},
|
||||
timestamp: 1000,
|
||||
},
|
||||
"user",
|
||||
|
||||
@@ -1433,8 +1433,7 @@ function renderGroupedMessage(
|
||||
const markdownBase = extractedText?.trim() ? extractedText : null;
|
||||
const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) : null;
|
||||
const markdown = markdownBase;
|
||||
const isBlockedUserMessage =
|
||||
normalizedRole === "user" && normalizedMessage.isBlockedOriginalContent === true;
|
||||
const isBlockedUserMessage = false;
|
||||
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
|
||||
const canExpand = role === "assistant" && Boolean(onOpenSidebar && markdown?.trim());
|
||||
|
||||
|
||||
@@ -22,30 +22,6 @@ function processMessageText(text: string, role: string): string {
|
||||
export function extractText(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "";
|
||||
// Sidecar `__openclaw.originalBlockedContent` is set on user messages
|
||||
// that were blocked by `before_agent_run` (or any pre-LLM hook). The
|
||||
// agent transcript only ever sees the redacted stub in `message.content`,
|
||||
// but the SPA renders the human's original input from this sidecar so
|
||||
// the user can see what they typed. NOTE: this is for HUMAN display only;
|
||||
// never inject this back into agent context.
|
||||
if (role === "user") {
|
||||
const meta = m.__openclaw as Record<string, unknown> | undefined;
|
||||
const originalBlocked = meta?.originalBlockedContent as { content?: unknown } | undefined;
|
||||
if (originalBlocked && Array.isArray(originalBlocked.content)) {
|
||||
const parts = originalBlocked.content
|
||||
.map((p) => {
|
||||
const item = p as Record<string, unknown>;
|
||||
if (item.type === "text" && typeof item.text === "string") {
|
||||
return item.text;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
if (parts.length > 0) {
|
||||
return processMessageText(parts.join("\n"), role);
|
||||
}
|
||||
}
|
||||
}
|
||||
const raw =
|
||||
role === "assistant" ? extractSharedAssistantVisibleText(message) : extractRawText(message);
|
||||
if (!raw) {
|
||||
|
||||
@@ -42,29 +42,6 @@ describe("message-normalizer", () => {
|
||||
expect(result.audioAsVoice).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders blocked originals without mutating the raw redacted message", () => {
|
||||
const rawMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "The agent cannot read this message." }],
|
||||
__openclaw: {
|
||||
originalBlockedContent: {
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = normalizeMessage(rawMessage);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "secret blocked prompt" }],
|
||||
isBlockedOriginalContent: true,
|
||||
});
|
||||
expect(rawMessage.content).toEqual([
|
||||
{ type: "text", text: "The agent cannot read this message." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes message with array content", () => {
|
||||
const result = normalizeMessage({
|
||||
role: "assistant",
|
||||
|
||||
@@ -240,17 +240,7 @@ function expandTextContent(text: string): {
|
||||
export function normalizeMessage(message: unknown): NormalizedMessage {
|
||||
const m = message as Record<string, unknown>;
|
||||
let role = typeof m.role === "string" ? m.role : "unknown";
|
||||
let isBlockedOriginalContent = false;
|
||||
let contentRaw = m.content;
|
||||
|
||||
if (role === "user") {
|
||||
const oc = m.__openclaw as Record<string, unknown> | undefined;
|
||||
const obc = oc?.originalBlockedContent as { content?: unknown } | undefined;
|
||||
if (obc && Array.isArray(obc.content) && obc.content.length > 0) {
|
||||
contentRaw = obc.content;
|
||||
isBlockedOriginalContent = true;
|
||||
}
|
||||
}
|
||||
const contentRaw = m.content;
|
||||
|
||||
// Detect tool messages by common gateway shapes.
|
||||
// Some tool events come through as assistant role with tool_* items in the content array.
|
||||
@@ -396,7 +386,6 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
|
||||
timestamp,
|
||||
id,
|
||||
senderLabel,
|
||||
...(isBlockedOriginalContent ? { isBlockedOriginalContent: true } : {}),
|
||||
...(audioAsVoice ? { audioAsVoice: true } : {}),
|
||||
...(replyTarget ? { replyTarget } : {}),
|
||||
};
|
||||
|
||||
@@ -58,7 +58,6 @@ export type NormalizedMessage = {
|
||||
timestamp: number;
|
||||
id?: string;
|
||||
senderLabel?: string | null;
|
||||
isBlockedOriginalContent?: boolean;
|
||||
audioAsVoice?: boolean;
|
||||
replyTarget?:
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user