mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 04:52:56 +00:00
fix(webchat): fetch full sidebar content for truncated history
Add a bounded `chat.message.get` gateway method so Control UI can fetch one display-normalized transcript message by id when an assistant history preview was truncated. Keep `chat.history` lightweight, reject oversized/hidden/missing rows with explicit unavailable reasons, and wire the WebChat side reader to request full content only for visible truncated assistant messages. Also refresh the generated Swift gateway protocol models and document the new assistant-message side-reader behavior. Closes #84651. Related #53242. Co-authored-by: NianJiuZst <3235467914@qq.com>
This commit is contained in:
@@ -198,6 +198,7 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "agent.identity.get", scope: "operator.read" },
|
||||
{ name: "agent.wait", scope: "operator.write", startup: true },
|
||||
{ name: "chat.history", scope: "operator.read", startup: true },
|
||||
{ name: "chat.message.get", scope: "operator.read", startup: true },
|
||||
{ name: "chat.abort", scope: "operator.write" },
|
||||
{ name: "chat.send", scope: "operator.write" },
|
||||
{ name: "assistant.media.get", scope: "operator.read", advertise: false },
|
||||
|
||||
@@ -271,7 +271,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
loadHandlers: loadChannelsHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: ["chat.history", "chat.abort", "chat.send", "chat.inject"],
|
||||
methods: ["chat.history", "chat.message.get", "chat.abort", "chat.send", "chat.inject"],
|
||||
loadHandlers: loadChatHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
validateChatAbortParams,
|
||||
validateChatHistoryParams,
|
||||
validateChatInjectParams,
|
||||
validateChatMessageGetParams,
|
||||
validateChatSendParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-protocol/src/schema.js";
|
||||
@@ -127,11 +128,13 @@ import {
|
||||
createManagedOutgoingImageBlocks,
|
||||
} from "../managed-image-attachments.js";
|
||||
import { ADMIN_SCOPE } from "../method-scopes.js";
|
||||
import { getMaxChatHistoryMessagesBytes } from "../server-constants.js";
|
||||
import { getMaxChatHistoryMessagesBytes, MAX_PAYLOAD_BYTES } from "../server-constants.js";
|
||||
import { readSessionTranscriptIndex } from "../session-transcript-index.fs.js";
|
||||
import {
|
||||
capArrayByJsonBytes,
|
||||
loadSessionEntry,
|
||||
readSessionMessageByIdAsync,
|
||||
readSessionMessagesAsync,
|
||||
resolveGatewayModelSupportsImages,
|
||||
resolveGatewaySessionThinkingDefault,
|
||||
resolveDeletedAgentIdFromSessionKey,
|
||||
@@ -1387,11 +1390,26 @@ export function buildOversizedHistoryPlaceholder(message?: unknown): Record<stri
|
||||
typeof (message as { timestamp?: unknown }).timestamp === "number"
|
||||
? (message as { timestamp: number }).timestamp
|
||||
: Date.now();
|
||||
const rawMetadata =
|
||||
message && typeof message === "object"
|
||||
? (message as Record<string, unknown>)["__openclaw"]
|
||||
: undefined;
|
||||
const metadata =
|
||||
rawMetadata && typeof rawMetadata === "object" && !Array.isArray(rawMetadata)
|
||||
? (rawMetadata as Record<string, unknown>)
|
||||
: {};
|
||||
const metadataId = typeof metadata.id === "string" ? metadata.id : undefined;
|
||||
const metadataSeq = typeof metadata.seq === "number" ? metadata.seq : undefined;
|
||||
return {
|
||||
role,
|
||||
timestamp,
|
||||
content: [{ type: "text", text: CHAT_HISTORY_OVERSIZED_PLACEHOLDER }],
|
||||
__openclaw: { truncated: true, reason: "oversized" },
|
||||
__openclaw: {
|
||||
...(metadataId ? { id: metadataId } : {}),
|
||||
...(metadataSeq !== undefined ? { seq: metadataSeq } : {}),
|
||||
truncated: true,
|
||||
reason: "oversized",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2330,6 +2348,35 @@ export function dropPreSessionStartAnnouncePairs(
|
||||
return changed ? kept : messages;
|
||||
}
|
||||
|
||||
function readChatHistoryMessageId(message: unknown): string | undefined {
|
||||
const metadata = asOptionalRecord(asOptionalRecord(message)?.["__openclaw"]);
|
||||
return typeof metadata?.id === "string" ? metadata.id : undefined;
|
||||
}
|
||||
|
||||
async function isChatMessageIdVisibleAfterHistoryFilters(params: {
|
||||
sessionId: string;
|
||||
storePath: string | undefined;
|
||||
sessionFile: string | undefined;
|
||||
messageId: string;
|
||||
sessionStartedAt?: number;
|
||||
}): Promise<boolean> {
|
||||
if (params.sessionStartedAt === undefined) {
|
||||
return true;
|
||||
}
|
||||
const messages = await readSessionMessagesAsync(
|
||||
params.sessionId,
|
||||
params.storePath,
|
||||
params.sessionFile,
|
||||
{
|
||||
mode: "full",
|
||||
reason: "chat.message.get visibility",
|
||||
},
|
||||
);
|
||||
return dropPreSessionStartAnnouncePairs(messages, params.sessionStartedAt).some(
|
||||
(message) => readChatHistoryMessageId(message) === params.messageId,
|
||||
);
|
||||
}
|
||||
|
||||
function dropLocalHistoryOverreadContextMessage(
|
||||
messages: unknown[],
|
||||
contextMessage: unknown,
|
||||
@@ -2474,6 +2521,94 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
verboseLevel,
|
||||
});
|
||||
},
|
||||
"chat.message.get": async ({ params, respond, context }) => {
|
||||
if (!validateChatMessageGetParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid chat.message.get params: ${formatValidationErrors(validateChatMessageGetParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { sessionKey, messageId, maxChars } = params as {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
messageId: string;
|
||||
maxChars?: number;
|
||||
};
|
||||
const agentIdOverride = normalizeOptionalText((params as { agentId?: string }).agentId);
|
||||
const requestedAgentId = resolveRequestedChatAgentId({
|
||||
cfg: (context as { getRuntimeConfig?: () => OpenClawConfig }).getRuntimeConfig?.(),
|
||||
requestedSessionKey: sessionKey,
|
||||
agentId: agentIdOverride,
|
||||
});
|
||||
const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined;
|
||||
const { cfg, storePath, entry } = loadSessionEntry(sessionKey, sessionLoadOptions);
|
||||
const selectedAgent = validateChatSelectedAgent({
|
||||
cfg,
|
||||
requestedSessionKey: sessionKey,
|
||||
agentId: requestedAgentId,
|
||||
});
|
||||
if (!selectedAgent.ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, selectedAgent.error));
|
||||
return;
|
||||
}
|
||||
const sessionId = entry?.sessionId;
|
||||
if (!sessionId) {
|
||||
respond(true, { ok: false, unavailableReason: "not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = await readSessionMessageByIdAsync(
|
||||
sessionId,
|
||||
storePath,
|
||||
entry?.sessionFile,
|
||||
messageId,
|
||||
);
|
||||
if (!resolved.found) {
|
||||
respond(true, { ok: false, unavailableReason: "not_found" });
|
||||
return;
|
||||
}
|
||||
const visible = await isChatMessageIdVisibleAfterHistoryFilters({
|
||||
sessionId,
|
||||
storePath,
|
||||
sessionFile: entry?.sessionFile,
|
||||
messageId,
|
||||
sessionStartedAt:
|
||||
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
|
||||
});
|
||||
if (!visible) {
|
||||
respond(true, { ok: false, unavailableReason: "not_found" });
|
||||
return;
|
||||
}
|
||||
if (resolved.oversized) {
|
||||
respond(true, { ok: false, unavailableReason: "oversized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveMaxChars =
|
||||
typeof maxChars === "number" ? maxChars : Math.min(MAX_PAYLOAD_BYTES, 1_000_000);
|
||||
const projectedMessage = resolved.message
|
||||
? projectChatDisplayMessage(resolved.message, {
|
||||
maxChars: effectiveMaxChars,
|
||||
})
|
||||
: undefined;
|
||||
const projected = projectedMessage
|
||||
? augmentChatHistoryWithCanvasBlocks([projectedMessage])[0]
|
||||
: undefined;
|
||||
if (!projected) {
|
||||
respond(true, { ok: false, unavailableReason: "not_visible" });
|
||||
return;
|
||||
}
|
||||
|
||||
respond(true, {
|
||||
ok: true,
|
||||
message: projected,
|
||||
});
|
||||
},
|
||||
"chat.abort": async ({ params, respond, context, client }) => {
|
||||
if (!validateChatAbortParams(params)) {
|
||||
respond(
|
||||
|
||||
@@ -80,6 +80,9 @@ async function withGatewayChatHarness(
|
||||
await run({ ws, createSessionDir });
|
||||
} finally {
|
||||
setMaxChatHistoryMessagesBytesForTest();
|
||||
if (process.env.OPENCLAW_CONFIG_PATH) {
|
||||
await fs.rm(process.env.OPENCLAW_CONFIG_PATH, { force: true });
|
||||
}
|
||||
clearConfigCache();
|
||||
testState.sessionStorePath = undefined;
|
||||
ws.close();
|
||||
@@ -129,6 +132,35 @@ async function fetchHistoryMessages(
|
||||
return historyRes.payload?.messages ?? [];
|
||||
}
|
||||
|
||||
async function fetchChatMessage(
|
||||
ws: GatewaySocket,
|
||||
params: {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
messageId: string;
|
||||
maxChars?: number;
|
||||
},
|
||||
): Promise<{
|
||||
ok?: boolean;
|
||||
message?: unknown;
|
||||
unavailableReason?: "not_found" | "oversized" | "not_visible";
|
||||
}> {
|
||||
const res = await rpcReq<{
|
||||
ok?: boolean;
|
||||
message?: unknown;
|
||||
unavailableReason?: "not_found" | "oversized" | "not_visible";
|
||||
}>(ws, "chat.message.get", {
|
||||
sessionKey: params.sessionKey,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
messageId: params.messageId,
|
||||
...(typeof params.maxChars === "number" ? { maxChars: params.maxChars } : {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`chat.message.get rpc failed: ${JSON.stringify(res.error ?? null)}`);
|
||||
}
|
||||
return res.payload ?? {};
|
||||
}
|
||||
|
||||
type ConfiguredImageModelCase = {
|
||||
id: string;
|
||||
imageModel: AgentModelConfig;
|
||||
@@ -1052,6 +1084,7 @@ describe("gateway server chat", () => {
|
||||
|
||||
const hugeNestedText = "n".repeat(120_000);
|
||||
const oversizedLine = JSON.stringify({
|
||||
id: "msg-huge",
|
||||
message: {
|
||||
role: "assistant",
|
||||
timestamp: Date.now(),
|
||||
@@ -1076,6 +1109,9 @@ describe("gateway server chat", () => {
|
||||
const bytes = Buffer.byteLength(serialized, "utf8");
|
||||
expect(bytes).toBeLessThanOrEqual(historyMaxBytes);
|
||||
expect(serialized).toContain("[chat.history omitted: message too large]");
|
||||
expect(messages[0]).toMatchObject({
|
||||
__openclaw: { id: "msg-huge", truncated: true, reason: "oversized" },
|
||||
});
|
||||
expect(serialized.includes(hugeNestedText.slice(0, 256))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1368,6 +1404,198 @@ describe("gateway server chat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.message.get returns the full projected message for a truncated history row", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
||||
await writeMainSessionTranscript(sessionDir, [
|
||||
JSON.stringify({
|
||||
id: "msg-full-assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "abcdefghij" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const historyMessages = await fetchHistoryMessages(ws, { maxChars: 5 });
|
||||
expect(JSON.stringify(historyMessages)).toContain("abcde\\n...(truncated)...");
|
||||
|
||||
const full = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-full-assistant",
|
||||
});
|
||||
expect(full.ok).toBe(true);
|
||||
expect(full.unavailableReason).toBeUndefined();
|
||||
expect(JSON.stringify(full.message)).toContain("abcdefghij");
|
||||
expect(JSON.stringify(full.message)).not.toContain("...(truncated)...");
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.message.get accepts the selected agent for global sessions", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
await writeGatewayConfig({
|
||||
session: { scope: "global" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "work" }],
|
||||
},
|
||||
});
|
||||
await connectOk(ws);
|
||||
const sessionDir = await createSessionDir();
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
global: { sessionId: "sess-global", updatedAt: Date.now() },
|
||||
},
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(sessionDir, "sess-global.jsonl"),
|
||||
`${JSON.stringify({
|
||||
id: "msg-global-agent",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "global agent content" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const full = await fetchChatMessage(ws, {
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
messageId: "msg-global-agent",
|
||||
});
|
||||
expect(full.ok).toBe(true);
|
||||
expect(JSON.stringify(full.message)).toContain("global agent content");
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.message.get reports oversized transcript entries as unavailable", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
||||
const oversizedLine = JSON.stringify({
|
||||
id: "msg-oversized",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "x".repeat(300 * 1024) }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
await writeMainSessionTranscript(sessionDir, [oversizedLine]);
|
||||
|
||||
const full = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-oversized",
|
||||
});
|
||||
expect(full.ok).toBe(false);
|
||||
expect(full.unavailableReason).toBe("oversized");
|
||||
expect(full.message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.message.get does not return inactive branch entries", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
||||
await writeMainSessionTranscript(sessionDir, [
|
||||
JSON.stringify({
|
||||
id: "msg-root",
|
||||
parentId: null,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "question" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "msg-stale",
|
||||
parentId: "msg-root",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "stale branch" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "msg-active",
|
||||
parentId: "msg-root",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "active branch" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const stale = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-stale",
|
||||
});
|
||||
expect(stale.ok).toBe(false);
|
||||
expect(stale.unavailableReason).toBe("not_found");
|
||||
|
||||
const active = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-active",
|
||||
});
|
||||
expect(active.ok).toBe(true);
|
||||
expect(JSON.stringify(active.message)).toContain("active branch");
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.message.get does not return pre-session announce pairs hidden by history", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
await connectOk(ws);
|
||||
const sessionDir = await createSessionDir();
|
||||
const sessionStartedAt = Date.now();
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: { sessionId: "sess-main", updatedAt: Date.now(), sessionStartedAt },
|
||||
},
|
||||
});
|
||||
await writeMainSessionTranscript(sessionDir, [
|
||||
JSON.stringify({
|
||||
id: "msg-announce",
|
||||
message: {
|
||||
role: "user",
|
||||
provenance: { kind: "inter_session", sourceTool: "subagent_announce" },
|
||||
content: [{ type: "text", text: "announce" }],
|
||||
timestamp: sessionStartedAt - 2_000,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "msg-hidden-assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hidden pre-session reply" }],
|
||||
timestamp: sessionStartedAt - 1_000,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "msg-visible-assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "visible reply" }],
|
||||
timestamp: sessionStartedAt + 1_000,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const hidden = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-hidden-assistant",
|
||||
});
|
||||
expect(hidden.ok).toBe(false);
|
||||
expect(hidden.unavailableReason).toBe("not_found");
|
||||
|
||||
const visible = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-visible-assistant",
|
||||
});
|
||||
expect(visible.ok).toBe(true);
|
||||
expect(JSON.stringify(visible.message)).toContain("visible reply");
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.history still drops assistant NO_REPLY entries before truncation", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
||||
|
||||
@@ -3,6 +3,9 @@ import { StringDecoder } from "node:string_decoder";
|
||||
|
||||
const TRANSCRIPT_INDEX_READ_CHUNK_BYTES = 64 * 1024;
|
||||
const MAX_TRANSCRIPT_INDEX_CACHE_ENTRIES = 256;
|
||||
const MAX_TRANSCRIPT_INDEX_PARSE_LINE_BYTES = 256 * 1024;
|
||||
const OVERSIZED_TRANSCRIPT_METADATA_PREFIX_CHARS = 64 * 1024;
|
||||
const TRANSCRIPT_OVERSIZED_MESSAGE_PLACEHOLDER = "[chat.history omitted: message too large]";
|
||||
|
||||
type ParsedTranscriptRecord = Record<string, unknown>;
|
||||
|
||||
@@ -55,6 +58,44 @@ function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function extractJsonStringFieldPrefix(prefix: string, field: string): string | undefined {
|
||||
const match = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(prefix);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const decoded = JSON.parse(`"${match[1]}"`) as unknown;
|
||||
return normalizeOptionalString(decoded);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJsonNullableStringFieldPrefix(
|
||||
prefix: string,
|
||||
field: string,
|
||||
): string | null | undefined {
|
||||
if (new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*null`).test(prefix)) {
|
||||
return null;
|
||||
}
|
||||
return extractJsonStringFieldPrefix(prefix, field);
|
||||
}
|
||||
|
||||
function extractJsonNumberFieldPrefix(prefix: string, field: string): number | undefined {
|
||||
const match = new RegExp(
|
||||
`"${escapeRegExp(field)}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)`,
|
||||
).exec(prefix);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const decoded = Number(match[1]);
|
||||
return Number.isFinite(decoded) ? decoded : undefined;
|
||||
}
|
||||
|
||||
async function yieldTranscriptIndexScan(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
@@ -93,6 +134,41 @@ function isTreeTranscriptRecord(record: ParsedTranscriptRecord): boolean {
|
||||
return record.type !== "session" && typeof record.id === "string" && "parentId" in record;
|
||||
}
|
||||
|
||||
function buildOversizedIndexedRawEntry(params: {
|
||||
line: string;
|
||||
offset: number;
|
||||
byteLength: number;
|
||||
}): IndexedRawEntry | null {
|
||||
const prefix = params.line.slice(0, OVERSIZED_TRANSCRIPT_METADATA_PREFIX_CHARS);
|
||||
const messageMatch = /"message"\s*:/.exec(prefix);
|
||||
const recordPrefix = messageMatch ? prefix.slice(0, messageMatch.index) : prefix;
|
||||
const id = extractJsonStringFieldPrefix(prefix, "id");
|
||||
const parentId = extractJsonNullableStringFieldPrefix(prefix, "parentId");
|
||||
const type = extractJsonStringFieldPrefix(prefix, "type");
|
||||
const timestamp =
|
||||
extractJsonStringFieldPrefix(recordPrefix, "timestamp") ??
|
||||
extractJsonNumberFieldPrefix(recordPrefix, "timestamp");
|
||||
const role = extractJsonStringFieldPrefix(prefix, "role") ?? "assistant";
|
||||
const record: ParsedTranscriptRecord = {
|
||||
...(type ? { type } : {}),
|
||||
...(id ? { id } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
message: {
|
||||
role,
|
||||
content: [{ type: "text", text: TRANSCRIPT_OVERSIZED_MESSAGE_PLACEHOLDER }],
|
||||
__openclaw: { truncated: true, reason: "oversized" },
|
||||
},
|
||||
};
|
||||
return {
|
||||
...(id ? { id } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
offset: params.offset,
|
||||
byteLength: params.byteLength,
|
||||
record,
|
||||
};
|
||||
}
|
||||
|
||||
async function visitTranscriptJsonLines(
|
||||
filePath: string,
|
||||
visit: (line: string, offset: number, byteLength: number) => void,
|
||||
@@ -190,6 +266,21 @@ async function buildSessionTranscriptIndex(
|
||||
if (!line.trim()) {
|
||||
return;
|
||||
}
|
||||
if (byteLength > MAX_TRANSCRIPT_INDEX_PARSE_LINE_BYTES) {
|
||||
const rawEntry = buildOversizedIndexedRawEntry({ line, offset, byteLength });
|
||||
if (!rawEntry) {
|
||||
return;
|
||||
}
|
||||
rawEntries.push(rawEntry);
|
||||
if (rawEntry.id) {
|
||||
byId.set(rawEntry.id, rawEntry);
|
||||
if (isTreeTranscriptRecord(rawEntry.record)) {
|
||||
hasTreeEntries = true;
|
||||
leafId = rawEntry.id;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
|
||||
@@ -2107,6 +2107,29 @@ describe("oversized transcript line guards", () => {
|
||||
expect(serialized).not.toContain(oversizedContent);
|
||||
});
|
||||
|
||||
test("readSessionMessagesAsync keeps id-less oversized message placeholders", async () => {
|
||||
const sessionId = "test-oversized-idless-async";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const oversizedContent = "w".repeat(300 * 1024);
|
||||
fs.writeFileSync(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
message: { role: "assistant", content: oversizedContent },
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const out = await readSessionMessagesAsync(sessionId, storePath, undefined, {
|
||||
mode: "full",
|
||||
reason: "test",
|
||||
});
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
const serialized = JSON.stringify(out);
|
||||
expect(serialized).toContain("[chat.history omitted: message too large]");
|
||||
expect(serialized).not.toContain(oversizedContent);
|
||||
});
|
||||
|
||||
test("readSessionTitleFieldsFromTranscriptAsync delegates to bounded sync reader", async () => {
|
||||
const sessionId = "test-async-title-bounded";
|
||||
writeTranscript(
|
||||
|
||||
@@ -588,6 +588,31 @@ export async function readSessionMessagesAsync(
|
||||
return index?.entries.flatMap((entry) => indexedTranscriptEntryToMessages(entry)) ?? [];
|
||||
}
|
||||
|
||||
export async function readSessionMessageByIdAsync(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
sessionFile: string | undefined,
|
||||
messageId: string,
|
||||
): Promise<{ message?: unknown; seq?: number; oversized: boolean; found: boolean }> {
|
||||
const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile);
|
||||
if (!filePath) {
|
||||
return { oversized: false, found: false };
|
||||
}
|
||||
const index = await readSessionTranscriptIndex(filePath);
|
||||
if (!index) {
|
||||
return { oversized: false, found: false };
|
||||
}
|
||||
const entry = index.entries.find((candidate) => candidate.id === messageId);
|
||||
if (!entry) {
|
||||
return { oversized: false, found: false };
|
||||
}
|
||||
if (entry.byteLength > MAX_TRANSCRIPT_PARSE_LINE_BYTES) {
|
||||
return { oversized: true, found: true, seq: entry.seq };
|
||||
}
|
||||
const message = indexedTranscriptEntryToMessage(entry);
|
||||
return { message, seq: entry.seq, oversized: false, found: true };
|
||||
}
|
||||
|
||||
export async function visitSessionMessagesAsync(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
|
||||
@@ -116,6 +116,7 @@ export {
|
||||
readRecentSessionMessagesWithStatsAsync,
|
||||
readRecentSessionTranscriptLines,
|
||||
readRecentSessionUsageFromTranscript,
|
||||
readSessionMessageByIdAsync,
|
||||
readSessionMessageCountAsync,
|
||||
readSessionTitleFieldsFromTranscript,
|
||||
readSessionTitleFieldsFromTranscriptAsync,
|
||||
|
||||
Reference in New Issue
Block a user