From bbd0702c790ff099f1d77408c48ee7747b749d96 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Apr 2026 12:51:15 +0100 Subject: [PATCH] fix(agents): narrow phase-aware history hardening (#61829) (thanks @100yenadmin) --- CHANGELOG.md | 1 + ...pi-embedded-helpers.validate-turns.test.ts | 60 +----- src/agents/pi-embedded-helpers/turns.ts | 128 +++--------- src/gateway/server-methods/chat.ts | 26 ++- src/gateway/sessions-history-http.test.ts | 185 ++++++++++++++---- src/gateway/sessions-history-http.ts | 15 +- 6 files changed, 219 insertions(+), 196 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a00f1dd3aa..70cc84e095b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Providers/Anthropic: skip `service_tier` injection for OAuth-authenticated stream wrapper requests so Claude OAuth requests stop failing with HTTP 401. (#60356) thanks @openperf. - Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, and fail loud on invalid elevated cross-host overrides. (#61739) Thanks @obviyus. +- Agents/history: suppress commentary-only visible-text leaks in streaming and chat history views, and keep sanitized SSE history sequence numbers monotonic after transcript-only refreshes. (#61829) Thanks @100yenadmin. - Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`. - Agents/heartbeat: stop truncating live session transcripts after no-op heartbeat acks, move heartbeat cleanup to prompt assembly and compaction, and keep post-filter context-engine ingestion aligned with the real session baseline. (#60998) Thanks @nxmxbbd. diff --git a/src/agents/pi-embedded-helpers.validate-turns.test.ts b/src/agents/pi-embedded-helpers.validate-turns.test.ts index f13444387aa..342dbc8dfef 100644 --- a/src/agents/pi-embedded-helpers.validate-turns.test.ts +++ b/src/agents/pi-embedded-helpers.validate-turns.test.ts @@ -12,7 +12,7 @@ function asMessages(messages: unknown[]): AgentMessage[] { function makeDualToolUseAssistantContent() { return [ - { type: "toolUse", id: "tool-1", name: "test1", arguments: {} }, + { type: "toolUse", id: "tool-1", name: "test1", input: {} }, { type: "toolUse", id: "tool-2", name: "test2", input: {} }, { type: "text", text: "Done" }, ]; @@ -123,7 +123,7 @@ describe("validateGeminiTurns", () => { { role: "user", content: "Use tool" }, { role: "assistant", - content: [{ type: "toolUse", id: "tool-1", name: "test", arguments: {} }], + content: [{ type: "toolUse", id: "tool-1", name: "test", input: {} }], }, { role: "toolResult", @@ -368,7 +368,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { { role: "assistant", content: [ - { type: "toolUse", id: "tool-1", name: "test", arguments: {} }, + { type: "toolUse", id: "tool-1", name: "test", input: {} }, { type: "text", text: "I'll check that" }, ], }, @@ -389,7 +389,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { { role: "assistant", content: [ - { type: "toolUse", id: "tool-1", name: "test", arguments: {} }, + { type: "toolUse", id: "tool-1", name: "test", input: {} }, { type: "text", text: "Here's result" }, ], }, @@ -408,7 +408,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { // tool_use should be preserved because matching tool_result exists const assistantContent = (result[1] as { content?: unknown[] }).content; expect(assistantContent).toEqual([ - { type: "toolUse", id: "tool-1", name: "test", arguments: {} }, + { type: "toolUse", id: "tool-1", name: "test", input: {} }, { type: "text", text: "Here's result" }, ]); }); @@ -418,7 +418,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { { role: "user", content: [{ type: "text", text: "Use tool" }] }, { role: "assistant", - content: [{ type: "toolUse", id: "tool-1", name: "test", arguments: {} }], + content: [{ type: "toolUse", id: "tool-1", name: "test", input: {} }], }, { role: "user", content: [{ type: "text", text: "Hello" }] }, ]); @@ -458,7 +458,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { // tool-1 should be preserved (has matching tool_result), tool-2 stripped, text preserved const assistantContent = (result[1] as { content?: unknown[] }).content; expect(assistantContent).toEqual([ - { type: "toolUse", id: "tool-1", name: "test1", arguments: {} }, + { type: "toolUse", id: "tool-1", name: "test1", input: {} }, { type: "text", text: "Done" }, ]); }); @@ -468,7 +468,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { { role: "user", content: [{ type: "text", text: "Use tool" }] }, { role: "assistant", - content: [{ type: "toolUse", id: "tool-1", name: "test", arguments: {} }], + content: [{ type: "toolUse", id: "tool-1", name: "test", input: {} }], }, // Next is assistant, not user - should not strip { role: "assistant", content: [{ type: "text", text: "Continue" }] }, @@ -479,7 +479,7 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { expect(result).toHaveLength(3); // Original tool_use should be preserved const assistantContent = (result[1] as { content?: unknown[] }).content; - expect(assistantContent).toEqual([{ type: "toolUse", id: "tool-1", name: "test", arguments: {} }]); + expect(assistantContent).toEqual([{ type: "toolUse", id: "tool-1", name: "test", input: {} }]); }); it("is replay-safe across repeated validation passes", () => { @@ -510,47 +510,5 @@ describe("validateAnthropicTurns strips dangling tool_use blocks", () => { expect(() => validateAnthropicTurns(msgs)).not.toThrow(); const result = validateAnthropicTurns(msgs); expect(result).toHaveLength(3); - expect(result[1]).toMatchObject({ content: "legacy-content" }); }); - - - it("matches tool results from standalone toolResult/tool turns and stops at the next assistant", () => { - const msgs = asMessages([ - { role: "user", content: [{ type: "text", text: "Use tool" }] }, - { - role: "assistant", - content: [{ type: "toolCall", id: "tool-1", name: "test", arguments: {} }], - }, - { role: "user", content: [{ type: "text", text: "intermediate" }] }, - { role: "toolResult", tool_call_id: "tool-1", content: [{ type: "text", text: "Result" }] }, - { - role: "assistant", - content: [{ type: "text", text: "New assistant boundary" }], - }, - { role: "tool", toolUseId: "tool-1", content: [{ type: "text", text: "Late result" }] }, - ] as unknown as AgentMessage[]); - - const result = validateAnthropicTurns(msgs); - - expect((result[1] as { content?: unknown[] }).content).toEqual([ - { type: "toolCall", id: "tool-1", name: "test", arguments: {} }, - ]); - }); - - it("does not synthesize fallback text for aborted mid-transcript tool-only turns", () => { - const msgs = asMessages([ - { role: "user", content: [{ type: "text", text: "Use tool" }] }, - { - role: "assistant", - stopReason: "aborted", - content: [{ type: "toolCall", id: "tool-1", name: "test", arguments: {} }], - }, - { role: "user", content: [{ type: "text", text: "Retry" }] }, - ]); - - const result = validateAnthropicTurns(msgs); - - expect(result[1]).toMatchObject({ stopReason: "aborted", content: [] }); - }); - }); diff --git a/src/agents/pi-embedded-helpers/turns.ts b/src/agents/pi-embedded-helpers/turns.ts index 2095fc69174..df90ee30dfb 100644 --- a/src/agents/pi-embedded-helpers/turns.ts +++ b/src/agents/pi-embedded-helpers/turns.ts @@ -1,36 +1,17 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -// Extend this union when pi-agent-core adds new tool-call block types type AnthropicContentBlock = { - type: "text" | "toolUse" | "toolResult" | "toolCall" | "functionCall"; + type: "text" | "toolUse" | "toolResult"; text?: string; id?: string; name?: string; toolUseId?: string; - toolCallId?: string; - tool_use_id?: string; - tool_call_id?: string; }; -/** Recognizes toolUse, toolCall, and functionCall block types from different providers/core versions */ -function isToolCallBlock(type: string | undefined): boolean { - return type === "toolUse" || type === "toolCall" || type === "functionCall"; -} - -function isAbortedAssistantTurn(msg: AgentMessage): boolean { - if (!msg || typeof msg !== "object") { - return false; - } - const stopReason = (msg as { stopReason?: unknown }).stopReason; - return stopReason === "error" || stopReason === "aborted"; -} - /** - * Strips dangling assistant tool-call blocks (toolUse/toolCall/functionCall) - * when no later message in the same assistant span contains a matching - * tool_result block. This fixes the "tool_use ids found without tool_result - * blocks" error from Anthropic. Aborted/error turns are still filtered for - * dangling tool calls, but they do not receive synthesized fallback text. + * Strips dangling tool_use blocks from assistant messages when the immediately + * following user message does not contain a matching tool_result block. + * This fixes the "tool_use ids found without tool_result blocks" error from Anthropic. */ function stripDanglingAnthropicToolUses(messages: AgentMessage[]): AgentMessage[] { const result: AgentMessage[] = []; @@ -52,76 +33,47 @@ function stripDanglingAnthropicToolUses(messages: AgentMessage[]): AgentMessage[ content?: AnthropicContentBlock[]; }; - const isAbortedTurn = isAbortedAssistantTurn(msg); + // Get the next message to check for tool_result blocks + const nextMsg = messages[i + 1]; + const nextMsgRole = + nextMsg && typeof nextMsg === "object" + ? ((nextMsg as { role?: unknown }).role as string | undefined) + : undefined; - if (!Array.isArray(assistantMsg.content)) { + // If next message is not user, keep the assistant message as-is + if (nextMsgRole !== "user") { result.push(msg); continue; } - // Scan ALL subsequent messages in this assistant span for matching tool_result blocks. - // OpenAI-compatible transcripts can have assistant(toolCall) → user(text) → toolResult - // ordering, so we must look beyond the immediate next message. - // TODO: optimize to single-pass suffix set if this helper becomes hot. + // Collect tool_use_ids from the next user message's tool_result blocks + const nextUserMsg = nextMsg as { + content?: AnthropicContentBlock[]; + }; const validToolUseIds = new Set(); - for (let j = i + 1; j < messages.length; j++) { - const futureMsg = messages[j]; - if (!futureMsg || typeof futureMsg !== "object") { - continue; - } - const futureRole = (futureMsg as { role?: unknown }).role as string | undefined; - if (futureRole === "assistant") { - break; - } - if (futureRole !== "user" && futureRole !== "toolResult" && futureRole !== "tool") { - continue; - } - const futureUserMsg = futureMsg as { - content?: AnthropicContentBlock[]; - toolUseId?: string; - toolCallId?: string; - tool_use_id?: string; - tool_call_id?: string; - }; - if (futureRole === "toolResult" || futureRole === "tool") { - const directToolResultId = - futureUserMsg.toolUseId ?? - futureUserMsg.toolCallId ?? - futureUserMsg.tool_use_id ?? - futureUserMsg.tool_call_id; - if (directToolResultId) { - validToolUseIds.add(directToolResultId); - } - } - if (!Array.isArray(futureUserMsg.content)) { - continue; - } - for (const block of futureUserMsg.content) { - if (block && block.type === "toolResult") { - const toolResultId = - block.toolUseId ?? block.toolCallId ?? block.tool_use_id ?? block.tool_call_id; - if (toolResultId) { - validToolUseIds.add(toolResultId); - } + if (Array.isArray(nextUserMsg.content)) { + for (const block of nextUserMsg.content) { + if (block && block.type === "toolResult" && block.toolUseId) { + validToolUseIds.add(block.toolUseId); } } } - // Filter out tool-call blocks that don't have matching tool_result - const originalContent = assistantMsg.content; + // Filter out tool_use blocks that don't have matching tool_result + const originalContent = Array.isArray(assistantMsg.content) ? assistantMsg.content : []; const filteredContent = originalContent.filter((block) => { if (!block) { return false; } - if (!isToolCallBlock(block.type)) { + if (block.type !== "toolUse") { return true; } - // Keep tool call if its id is in the valid set + // Keep tool_use if its id is in the valid set return validToolUseIds.has(block.id || ""); }); - // If all content would be removed, insert a minimal fallback text block for non-aborted turns. - if (originalContent.length > 0 && filteredContent.length === 0 && !isAbortedTurn) { + // If all content would be removed, insert a minimal fallback text block + if (originalContent.length > 0 && filteredContent.length === 0) { result.push({ ...assistantMsg, content: [{ type: "text", text: "[tool calls omitted]" }], @@ -134,33 +86,6 @@ function stripDanglingAnthropicToolUses(messages: AgentMessage[]): AgentMessage[ } } - // See also: main loop tool_use stripping above - // Handle end-of-conversation orphans: if the last message is assistant with - // tool-call blocks and no following user message, strip those blocks. - if (result.length > 0) { - const lastMsg = result[result.length - 1]; - const lastRole = - lastMsg && typeof lastMsg === "object" - ? ((lastMsg as { role?: unknown }).role as string | undefined) - : undefined; - if (lastRole === "assistant") { - const lastAssistant = lastMsg as { content?: AnthropicContentBlock[] }; - if (Array.isArray(lastAssistant.content)) { - const hasToolUse = lastAssistant.content.some((b) => b && isToolCallBlock(b.type)); - if (hasToolUse) { - const filtered = lastAssistant.content.filter((b) => b && !isToolCallBlock(b.type)); - result[result.length - 1] = - filtered.length > 0 || isAbortedAssistantTurn(lastMsg) - ? ({ ...lastAssistant, content: filtered } as AgentMessage) - : ({ - ...lastAssistant, - content: [{ type: "text" as const, text: "[tool calls omitted]" }], - } as AgentMessage); - } - } - } - } - return result; } @@ -268,7 +193,6 @@ export function validateAnthropicTurns(messages: AgentMessage[]): AgentMessage[] // First, strip dangling tool_use blocks from assistant messages const stripped = stripDanglingAnthropicToolUses(messages); - // Then merge consecutive user messages return validateTurnsWithConsecutiveMerge({ messages: stripped, role: "user", diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index d8d3415c1ac..ba7d85e2d90 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -658,8 +658,30 @@ function sanitizeChatHistoryMessage( changed ||= stripped.changed || res.truncated; } else if (Array.isArray(entry.content)) { const updated = entry.content.map((block) => sanitizeChatHistoryContentBlock(block, maxChars)); - if (updated.some((item) => item.changed)) { - entry.content = updated.map((item) => item.block); + const sanitizedBlocks = updated.map((item) => item.block); + const hasPhaseMetadata = + entry.role === "assistant" && + entry.content.some( + (block) => + block && + typeof block === "object" && + typeof (block as { textSignature?: unknown }).textSignature === "string", + ); + if (hasPhaseMetadata) { + const stripped = stripInlineDirectiveTagsForDisplay( + extractAssistantVisibleText(entry as Parameters[0]), + ); + const res = truncateChatHistoryText(stripped.text, maxChars); + const nonTextBlocks = sanitizedBlocks.filter( + (block) => + !block || typeof block !== "object" || (block as { type?: unknown }).type !== "text", + ); + entry.content = res.text + ? [{ type: "text", text: res.text }, ...nonTextBlocks] + : nonTextBlocks; + changed = true; + } else if (updated.some((item) => item.changed)) { + entry.content = sanitizedBlocks; changed = true; } } diff --git a/src/gateway/sessions-history-http.test.ts b/src/gateway/sessions-history-http.test.ts index 0a66eb2ea7e..75653115ed3 100644 --- a/src/gateway/sessions-history-http.test.ts +++ b/src/gateway/sessions-history-http.test.ts @@ -1,8 +1,10 @@ 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, test } from "vitest"; import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js"; +import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { testState } from "./test-helpers.runtime-state.js"; import { connectReq, @@ -54,6 +56,56 @@ async function seedSession(params?: { text?: string }) { return { storePath }; } +function makeTranscriptAssistantMessage(params: { + text: string; + content?: Array>; +}) { + return { + role: "assistant" as const, + content: params.content ?? [{ type: "text", text: params.text }], + api: "openai-responses", + provider: "openclaw", + model: "delivery-mirror", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop" as const, + timestamp: Date.now(), + }; +} + +function appendTranscriptMessage(params: { + sessionFile: string; + sessionKey: string; + message: ReturnType; + emitInlineMessage?: boolean; +}): string { + const sessionManager = SessionManager.open(params.sessionFile); + const messageId = sessionManager.appendMessage(params.message); + emitSessionTranscriptUpdate( + params.emitInlineMessage === false + ? params.sessionFile + : { + sessionFile: params.sessionFile, + sessionKey: params.sessionKey, + message: params.message, + messageId, + }, + ); + return messageId; +} + async function fetchSessionHistory( port: number, sessionKey: string, @@ -329,7 +381,7 @@ describe("session history HTTP endpoints", () => { }); }); - test("sanitizes unbounded SSE push updates before emitting them", async () => { + test("sanitizes phased assistant history entries before returning them", async () => { const storePath = await createSessionStoreFile(); await writeSessionStore({ entries: { @@ -342,18 +394,6 @@ describe("session history HTTP endpoints", () => { }); await withGatewayHarness(async (harness) => { - const res = await fetchSessionHistory(harness.port, "agent:main:main", { - headers: { Accept: "text/event-stream" }, - }); - - expect(res.status).toBe(200); - const reader = res.body?.getReader(); - expect(reader).toBeTruthy(); - const streamState = { buffer: "" }; - const historyEvent = await readSseEvent(reader!, streamState); - expect(historyEvent.event).toBe("history"); - expect((historyEvent.data as { messages?: unknown[] }).messages ?? []).toHaveLength(0); - const hidden = await appendAssistantMessageToSessionTranscript({ sessionKey: "agent:main:main", text: "NO_REPLY", @@ -361,11 +401,14 @@ describe("session history HTTP endpoints", () => { }); expect(hidden.ok).toBe(true); - const visible = await appendAssistantMessageToSessionTranscript({ + if (!hidden.ok) { + throw new Error(`append failed: ${hidden.reason}`); + } + const visibleMessageId = appendTranscriptMessage({ + sessionFile: hidden.sessionFile, sessionKey: "agent:main:main", - text: "Done.", - message: { - role: "assistant", + message: makeTranscriptAssistantMessage({ + text: "Done.", content: [ { type: "text", @@ -378,30 +421,26 @@ describe("session history HTTP endpoints", () => { textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }), }, ], - }, - storePath, + }), + emitInlineMessage: false, }); - expect(visible.ok).toBe(true); - const messageEvent = await readSseEvent(reader!, streamState); - expect(messageEvent.event).toBe("message"); - expect( - (messageEvent.data as { message?: { content?: Array<{ text?: string }> } }).message?.content?.[0] - ?.text, - ).toBe("Done."); - expect((messageEvent.data as { messageSeq?: number }).messageSeq).toBe(2); - expect( - ( - messageEvent.data as { - message?: { __openclaw?: { id?: string; seq?: number } }; - } - ).message?.__openclaw, - ).toMatchObject({ - id: visible.ok ? visible.messageId : undefined, + const historyRes = await fetchSessionHistory(harness.port, "agent:main:main"); + expect(historyRes.status).toBe(200); + const body = (await historyRes.json()) as { + sessionKey?: string; + messages?: Array<{ + content?: Array<{ text?: string }>; + __openclaw?: { id?: string; seq?: number }; + }>; + }; + expect(body.sessionKey).toBe("agent:main:main"); + expect(body.messages).toHaveLength(1); + expect(body.messages?.[0]?.content?.[0]?.text).toBe("Done."); + expect(body.messages?.[0]?.__openclaw).toMatchObject({ + id: visibleMessageId, seq: 2, }); - - await reader?.cancel(); }); }); @@ -515,6 +554,78 @@ describe("session history HTTP endpoints", () => { }); }); + test("resyncs raw sequence numbering after transcript-only SSE refreshes", async () => { + const { storePath } = await seedSession({ text: "first message" }); + + await withGatewayHarness(async (harness) => { + const res = await fetchSessionHistory(harness.port, "agent:main:main", { + headers: { Accept: "text/event-stream" }, + }); + + expect(res.status).toBe(200); + const reader = res.body?.getReader(); + expect(reader).toBeTruthy(); + const streamState = { buffer: "" }; + await readSseEvent(reader!, streamState); + + const second = await appendAssistantMessageToSessionTranscript({ + sessionKey: "agent:main:main", + text: "second visible message", + storePath, + }); + expect(second.ok).toBe(true); + + const secondEvent = await readSseEvent(reader!, streamState); + expect(secondEvent.event).toBe("message"); + expect((secondEvent.data as { messageSeq?: number }).messageSeq).toBe(2); + + if (!second.ok) { + throw new Error(`append failed: ${second.reason}`); + } + appendTranscriptMessage({ + sessionFile: second.sessionFile, + sessionKey: "agent:main:main", + message: makeTranscriptAssistantMessage({ text: "NO_REPLY" }), + emitInlineMessage: false, + }); + + const refreshEvent = await readSseEvent(reader!, streamState); + expect(refreshEvent.event).toBe("history"); + expect( + ( + refreshEvent.data as { messages?: Array<{ content?: Array<{ text?: string }> }> } + ).messages?.map((message) => message.content?.[0]?.text), + ).toEqual(["first message", "second visible message"]); + + const third = await appendAssistantMessageToSessionTranscript({ + sessionKey: "agent:main:main", + text: "third visible message", + storePath, + }); + expect(third.ok).toBe(true); + + const thirdEvent = await readSseEvent(reader!, streamState); + expect(thirdEvent.event).toBe("message"); + expect( + (thirdEvent.data as { message?: { content?: Array<{ text?: string }> } }).message + ?.content?.[0]?.text, + ).toBe("third visible message"); + expect((thirdEvent.data as { messageSeq?: number }).messageSeq).toBe(4); + expect( + ( + thirdEvent.data as { + message?: { __openclaw?: { id?: string; seq?: number } }; + } + ).message?.__openclaw, + ).toMatchObject({ + id: third.ok ? third.messageId : undefined, + seq: 4, + }); + + await reader?.cancel(); + }); + }); + test("rejects session history when operator.read is not requested", async () => { await seedSession({ text: "scope-guarded history" }); diff --git a/src/gateway/sessions-history-http.ts b/src/gateway/sessions-history-http.ts index 9ec1ebc078a..b9231e41e94 100644 --- a/src/gateway/sessions-history-http.ts +++ b/src/gateway/sessions-history-http.ts @@ -302,13 +302,20 @@ export async function handleSessionHistoryHttpRequest( return; } } + // Transcript-only updates can advance the raw transcript without carrying + // an inline message payload. Resync the raw seq counter before the next + // fast-path append so later messageSeq values stay monotonic. + const refreshedRawMessages = readSessionMessages( + entry.sessionId, + target.storePath, + entry.sessionFile, + ); + rawTranscriptSeq = + resolveMessageSeq(refreshedRawMessages.at(-1)) ?? refreshedRawMessages.length; // Bounded SSE history refreshes: apply sanitizeChatHistoryMessages before // pagination, consistent with the unbounded path. sentHistory = paginateSessionMessages( - sanitizeChatHistoryMessages( - readSessionMessages(entry.sessionId, target.storePath, entry.sessionFile), - effectiveMaxChars, - ), + sanitizeChatHistoryMessages(refreshedRawMessages, effectiveMaxChars), limit, cursor, );