feat(sessions): reopen history around search hits

This commit is contained in:
Ayaan Zaidi
2026-07-13 10:27:29 +05:30
parent 432da8b3cb
commit 143fed81c1
24 changed files with 1226 additions and 356 deletions
@@ -11656,6 +11656,8 @@ public struct ChatHistoryParams: Codable, Sendable {
public let agentid: String?
public let limit: Int?
public let offset: Int?
public let messageid: String?
public let sessionid: String?
public let maxchars: Int?
public init(
@@ -11663,12 +11665,16 @@ public struct ChatHistoryParams: Codable, Sendable {
agentid: String? = nil,
limit: Int? = nil,
offset: Int? = nil,
messageid: String? = nil,
sessionid: String? = nil,
maxchars: Int? = nil)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.limit = limit
self.offset = offset
self.messageid = messageid
self.sessionid = sessionid
self.maxchars = maxchars
}
@@ -11677,6 +11683,8 @@ public struct ChatHistoryParams: Codable, Sendable {
case agentid = "agentId"
case limit
case offset
case messageid = "messageId"
case sessionid = "sessionId"
case maxchars = "maxChars"
}
}
@@ -1,2 +1,2 @@
f346d9e3af554adefaace0f2f2bd0bce4b39d9552d7dc8ed6f08c01c925cb567 plugin-sdk-api-baseline.json
582ec2b6bd24612498362a1e2a425d261d1f122a4524b98e1be015cbfe75ad32 plugin-sdk-api-baseline.jsonl
5843643ebc635ab35cf50668e0e1a14ec599f5d297bfb0bd0e019048caf425f0 plugin-sdk-api-baseline.json
2102fd06e7da61320aa6a1113a2227f6aeca64444cb7a8c4a3a7bc4886957845 plugin-sdk-api-baseline.jsonl
@@ -183,6 +183,15 @@ describe("lazy protocol validators", () => {
offset: 100,
}),
).toBe(true);
expect(
validateChatHistoryParams({
sessionKey: "global",
agentId: "work",
limit: 11,
messageId: "matching-message",
sessionId: "matching-session",
}),
).toBe(true);
expect(
validateChatSendParams({
sessionKey: "global",
@@ -33,6 +33,8 @@ export const ChatHistoryParamsSchema = Type.Object(
agentId: Type.Optional(NonEmptyString),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
offset: Type.Optional(Type.Integer({ minimum: 0 })),
messageId: Type.Optional(NonEmptyString),
sessionId: Type.Optional(NonEmptyString),
maxChars: Type.Optional(Type.Integer({ minimum: 1, maximum: 500_000 })),
},
{ additionalProperties: false },
+7 -7
View File
@@ -409,9 +409,9 @@
"extensions/zalouser/src/setup-surface.ts": 504,
"extensions/zalouser/src/text-styles.ts": 547,
"extensions/zalouser/src/zalo-js.ts": 1995,
"packages/agent-core/src/agent-loop.ts": 1133,
"packages/agent-core/src/agent.ts": 622,
"packages/agent-core/src/harness/agent-harness.ts": 1214,
"packages/agent-core/src/agent-loop.ts": 1128,
"packages/agent-core/src/agent.ts": 612,
"packages/agent-core/src/harness/agent-harness.ts": 1202,
"packages/agent-core/src/harness/compaction/compaction.ts": 925,
"packages/agent-core/src/harness/env/nodejs.ts": 651,
"packages/agent-core/src/harness/types.ts": 842,
@@ -559,7 +559,7 @@
"src/agents/embedded-agent-runner/run/attempt.session-lock.ts": 2209,
"src/agents/embedded-agent-runner/run/attempt.tool-call-argument-repair.ts": 798,
"src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts": 1211,
"src/agents/embedded-agent-runner/run/attempt.ts": 5898,
"src/agents/embedded-agent-runner/run/attempt.ts": 5896,
"src/agents/embedded-agent-runner/run/auth-controller.ts": 676,
"src/agents/embedded-agent-runner/run/images.ts": 665,
"src/agents/embedded-agent-runner/run/incomplete-turn.ts": 823,
@@ -612,7 +612,7 @@
"src/agents/session-tool-result-guard.ts": 944,
"src/agents/session-transcript-repair.ts": 827,
"src/agents/session-write-lock.ts": 1103,
"src/agents/sessions/agent-session.ts": 3338,
"src/agents/sessions/agent-session.ts": 3336,
"src/agents/sessions/auth-storage.ts": 566,
"src/agents/sessions/extensions/loader.ts": 720,
"src/agents/sessions/extensions/runner.ts": 1147,
@@ -824,7 +824,7 @@
"src/config/schema.ts": 847,
"src/config/sessions/cleanup-service.ts": 694,
"src/config/sessions/disk-budget.ts": 848,
"src/config/sessions/session-accessor.sqlite.ts": 5856,
"src/config/sessions/session-accessor.sqlite.ts": 5854,
"src/config/sessions/session-accessor.ts": 3263,
"src/config/sessions/store-load.ts": 584,
"src/config/sessions/store-maintenance.ts": 578,
@@ -911,7 +911,7 @@
"src/gateway/server-methods/approval.ts": 602,
"src/gateway/server-methods/artifacts.ts": 673,
"src/gateway/server-methods/channels.ts": 712,
"src/gateway/server-methods/chat.ts": 5552,
"src/gateway/server-methods/chat.ts": 5336,
"src/gateway/server-methods/config.ts": 1020,
"src/gateway/server-methods/cron.ts": 996,
"src/gateway/server-methods/devices.ts": 822,
@@ -301,6 +301,8 @@ describe("sessions tools", () => {
};
expect(schemaProp("sessions_history", "limit").type).toBe("integer");
expect(schemaProp("sessions_history", "messageId").type).toBe("string");
expect(schemaProp("sessions_history", "sessionId").type).toBe("string");
expect(schemaProp("sessions_search", "limit").type).toBe("integer");
expect(schemaProp("sessions_list", "limit").type).toBe("integer");
expect(schemaProp("sessions_list", "activeMinutes").type).toBe("integer");
+2 -2
View File
@@ -26,7 +26,7 @@ export function describeSessionsListTool(): string {
export function describeSessionsHistoryTool(): string {
return [
"Read sanitized visible-session history.",
"Before reply/debug/resume. Supports limit/offset/tool messages.",
"Before reply/debug/resume. Supports limit, offset, search-result sessionId/messageId anchors, and tool messages.",
].join(" ");
}
@@ -34,7 +34,7 @@ export function describeSessionsHistoryTool(): string {
export function describeSessionsSearchTool(): string {
return [
"Search your own past sessions for matching user and assistant text.",
"Follow up with sessions_history using a returned sessionKey for full context.",
"Follow up with sessions_history using a returned sessionKey, sessionId, and messageId for neighboring context.",
].join(" ");
}
@@ -63,6 +63,18 @@ function readMessageSeq(message: unknown): number | undefined {
return typeof seq === "number" ? seq : undefined;
}
function readMessageId(message: unknown): string | undefined {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return undefined;
}
const meta = (message as Record<string, unknown>)["__openclaw"];
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
return undefined;
}
const id = (meta as Record<string, unknown>).id;
return typeof id === "string" ? id : undefined;
}
describe("sessions_history redaction", () => {
beforeAll(async () => {
previousConfigPath = process.env.OPENCLAW_CONFIG_PATH;
@@ -135,6 +147,22 @@ describe("sessions_history redaction", () => {
expect(requests).toEqual([]);
});
it("rejects offset and messageId together", async () => {
const tool = createHistoryToolWithMessage("hello");
await expect(
tool.execute("call-1", { sessionKey: "main", offset: 0, messageId: "message-1" }),
).rejects.toThrow("offset and messageId cannot be used together");
});
it("rejects sessionId without messageId", async () => {
const tool = createHistoryToolWithMessage("hello");
await expect(
tool.execute("call-1", { sessionKey: "main", sessionId: "session-1" }),
).rejects.toThrow("sessionId requires messageId");
});
it("preserves the bounded default history request", async () => {
const requests: CallGatewayRequest[] = [];
const tool = createSessionsHistoryTool({
@@ -191,6 +219,71 @@ describe("sessions_history redaction", () => {
});
});
it("requests history around a search result message id", async () => {
const requests: CallGatewayRequest[] = [];
const tool = createSessionsHistoryTool({
config: {},
callGateway: async <T = Record<string, unknown>>(request: CallGatewayRequest): Promise<T> => {
requests.push(request);
return {
messages: [
{ role: "user", content: "before" },
{ role: "assistant", content: "matching message" },
{ role: "user", content: "after" },
],
} as T;
},
});
const result = await tool.execute("call-1", {
sessionKey: "main",
limit: 3,
messageId: "matching-message",
sessionId: "matching-session",
});
expect(requests[0]).toMatchObject({
method: "chat.history",
params: {
sessionKey: "main",
limit: 3,
messageId: "matching-message",
sessionId: "matching-session",
},
});
expect(result.details).toMatchObject({
messages: [{ content: "before" }, { content: "matching message" }, { content: "after" }],
});
});
it("keeps the anchored message when the history byte cap trims neighbors", async () => {
const anchorId = "message-10";
const messages = Array.from({ length: 30 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index + 1} ${"x".repeat(4_000)}`,
__openclaw: { id: `message-${index + 1}`, seq: index + 1 },
}));
const tool = createSessionsHistoryTool({
config: {},
callGateway: async <T = Record<string, unknown>>(): Promise<T> =>
({ messages, offset: 0, totalMessages: messages.length }) as T,
});
const result = await tool.execute("call-1", {
sessionKey: "main",
messageId: anchorId,
});
const details = readHistoryDetails(result);
const returnedMessages = details.messages as unknown[];
expect(returnedMessages.length).toBeLessThan(messages.length);
expect(returnedMessages.some((message) => readMessageId(message) === anchorId)).toBe(true);
expect(details).toMatchObject({ truncated: true, droppedMessages: true });
expect(details.offset).toBeUndefined();
expect(details.nextOffset).toBeUndefined();
expect(details.hasMore).toBeUndefined();
});
it("recomputes pagination after the tool byte cap drops older returned messages", async () => {
const messages: HistoryMessage[] = Array.from({ length: 30 }, (_, index) => ({
role: "assistant",
+88 -5
View File
@@ -39,6 +39,8 @@ const SessionsHistoryToolSchema = Type.Object({
sessionKey: Type.String(),
limit: optionalPositiveIntegerSchema(),
offset: Type.Optional(Type.Integer({ minimum: 0 })),
messageId: Type.Optional(Type.String({ minLength: 1 })),
sessionId: Type.Optional(Type.String({ minLength: 1 })),
includeTools: Type.Optional(Type.Boolean()),
});
@@ -211,12 +213,78 @@ function readHistoryMessageSeq(message: unknown): number | undefined {
return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined;
}
function readHistoryMessageId(message: unknown): string | undefined {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return undefined;
}
const meta = (message as Record<string, unknown>)["__openclaw"];
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
return undefined;
}
const id = (meta as Record<string, unknown>).id;
return typeof id === "string" && id.length > 0 ? id : undefined;
}
function capSessionsHistoryAroundMessage(
items: unknown[],
messageId: string,
maxBytes: number,
): { items: unknown[]; bytes: number } {
const anchorIndex = items.findIndex((item) => readHistoryMessageId(item) === messageId);
if (anchorIndex === -1) {
return capArrayByJsonBytes(items, maxBytes);
}
let start = anchorIndex;
let end = anchorIndex + 1;
let cappedItems = items.slice(start, end);
let bytes = jsonUtf8Bytes(cappedItems);
let canGrowOlder = start > 0;
let canGrowNewer = end < items.length;
while (canGrowOlder || canGrowNewer) {
if (canGrowOlder) {
const candidate = items.slice(start - 1, end);
const candidateBytes = jsonUtf8Bytes(candidate);
if (candidateBytes <= maxBytes) {
start -= 1;
cappedItems = candidate;
bytes = candidateBytes;
} else {
canGrowOlder = false;
}
}
canGrowOlder &&= start > 0;
if (canGrowNewer) {
const candidate = items.slice(start, end + 1);
const candidateBytes = jsonUtf8Bytes(candidate);
if (candidateBytes <= maxBytes) {
end += 1;
cappedItems = candidate;
bytes = candidateBytes;
} else {
canGrowNewer = false;
}
}
canGrowNewer &&= end < items.length;
}
return { items: cappedItems, bytes };
}
function buildSessionsHistoryOmittedPlaceholder(source: unknown): Record<string, unknown> {
const seq = readHistoryMessageSeq(source);
const id = readHistoryMessageId(source);
return {
role: "assistant",
content: "[sessions_history omitted: message too large]",
...(seq !== undefined ? { __openclaw: { seq } } : {}),
...(seq !== undefined || id !== undefined
? {
__openclaw: {
...(seq !== undefined ? { seq } : {}),
...(id !== undefined ? { id } : {}),
},
}
: {}),
};
}
@@ -224,8 +292,12 @@ function resolveSessionsHistoryPaginationMetadata(params: {
messages: unknown[];
result: ChatHistoryPaginationMetadata | undefined;
requestedOffset: number | undefined;
requestedMessageId: string | undefined;
}): ChatHistoryPaginationMetadata {
const result = params.result;
if (params.requestedMessageId) {
return typeof result?.totalMessages === "number" ? { totalMessages: result.totalMessages } : {};
}
const offset =
typeof result?.offset === "number"
? result.offset
@@ -343,6 +415,14 @@ export function createSessionsHistoryTool(opts?: {
const limit = readPositiveIntegerParam(params, "limit");
const offset = readOffsetParam(params);
const messageId = readStringParam(params, "messageId");
const sessionId = readStringParam(params, "sessionId");
if (offset !== undefined && messageId) {
throw new ToolInputError("offset and messageId cannot be used together");
}
if (sessionId && !messageId) {
throw new ToolInputError("sessionId requires messageId");
}
const includeTools = Boolean(params.includeTools);
const result = await gatewayCall<{
messages: Array<unknown>;
@@ -356,6 +436,8 @@ export function createSessionsHistoryTool(opts?: {
sessionKey: resolvedKey,
limit,
...(offset !== undefined ? { offset } : {}),
...(messageId ? { messageId } : {}),
...(sessionId ? { sessionId } : {}),
},
});
const rawMessages = Array.isArray(result?.messages) ? result.messages : [];
@@ -363,10 +445,10 @@ export function createSessionsHistoryTool(opts?: {
const sanitizedMessages = selectedMessages.map((message) => sanitizeHistoryMessage(message));
const contentTruncated = sanitizedMessages.some((entry) => entry.truncated);
const contentRedacted = sanitizedMessages.some((entry) => entry.redacted);
const cappedMessages = capArrayByJsonBytes(
sanitizedMessages.map((entry) => entry.message),
SESSIONS_HISTORY_MAX_BYTES,
);
const sanitizedItems = sanitizedMessages.map((entry) => entry.message);
const cappedMessages = messageId
? capSessionsHistoryAroundMessage(sanitizedItems, messageId, SESSIONS_HISTORY_MAX_BYTES)
: capArrayByJsonBytes(sanitizedItems, SESSIONS_HISTORY_MAX_BYTES);
const droppedMessages = cappedMessages.items.length < selectedMessages.length;
const hardened = enforceSessionsHistoryHardCap({
items: cappedMessages.items,
@@ -377,6 +459,7 @@ export function createSessionsHistoryTool(opts?: {
messages: hardened.items,
result,
requestedOffset: offset,
requestedMessageId: messageId,
});
return jsonResult({
sessionKey: displayKey,
@@ -377,11 +377,9 @@ export function resolveSqliteSessionKeyBySessionId(
const row = executeSqliteQueryTakeFirstSync(
database.db,
db
.selectFrom("session_entries")
.selectFrom("sessions")
.select("session_key")
.where("session_id", "=", resolved.sessionId)
.orderBy("updated_at", "desc")
.orderBy("session_key", "asc")
.limit(1),
);
return row?.session_key;
@@ -0,0 +1,381 @@
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import {
dropPreSessionStartAnnouncePairs,
projectChatDisplayMessages,
projectRecentChatDisplayMessages,
} from "../chat-display-projection.js";
import { augmentChatHistoryWithCanvasBlocks } from "../chat-display-projection.js";
import {
resolveChatHistoryWithCliSessionImports,
resolveClaudeCliBindingSessionId,
} from "../cli-session-history.js";
import { resolveSessionHistoryTailReadOptions } from "../session-history-state.js";
import { readSessionMessagesAroundIdWithStatsAsync } from "../session-transcript-anchor-reader.js";
import {
readRecentSessionMessagesWithStatsAsync,
readSessionMessagesAsync,
readSessionMessagesPageWithStatsAsync,
} from "../session-transcript-readers.js";
import type { loadSessionEntry } from "../session-utils.js";
export function readChatHistoryMessageId(message: unknown): string | undefined {
const metadata = asOptionalRecord(asOptionalRecord(message)?.["__openclaw"]);
return typeof metadata?.id === "string" ? metadata.id : undefined;
}
export function readChatHistoryMessageSeq(message: unknown): number | undefined {
const metadata = asOptionalRecord(asOptionalRecord(message)?.["__openclaw"]);
const seq = metadata?.seq;
return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined;
}
type ChatHistoryPage = {
messages: unknown[];
responseOffset?: number;
completeCliImport?: true;
// Absent only for anchored (messageId) reads: the anchor may resolve a
// reset-archive transcript that numeric offset cursors cannot address, so
// anchored responses expose no paging metadata.
pagination?: {
offset: number;
totalMessages: number;
rawPageMessages: number;
exhausted?: true;
};
};
function capOffsetChatHistoryProjectedMessages(messages: unknown[], max: number): unknown[] {
if (messages.length <= max) {
return messages;
}
const start = Math.max(0, messages.length - max);
const boundarySeq = readChatHistoryMessageSeq(messages[start]);
if (boundarySeq === undefined) {
return messages.slice(start);
}
// Offset cursors can only resume at transcript-record boundaries.
// Keep boundary rows with the same seq together so projection mirrors are not stranded.
let safeStart = start;
while (safeStart > 0 && readChatHistoryMessageSeq(messages[safeStart - 1]) === boundarySeq) {
safeStart--;
}
return messages.slice(safeStart);
}
function resolveChatHistoryMessageGroup(
messages: unknown[],
index: number,
): { start: number; end: number } {
const seq = readChatHistoryMessageSeq(messages[index]);
if (seq === undefined) {
return { start: index, end: index + 1 };
}
let start = index;
let end = index + 1;
while (start > 0 && readChatHistoryMessageSeq(messages[start - 1]) === seq) {
start -= 1;
}
while (end < messages.length && readChatHistoryMessageSeq(messages[end]) === seq) {
end += 1;
}
return { start, end };
}
export function capChatHistoryAroundMessage(params: {
messages: unknown[];
messageId: string;
fits: (messages: unknown[]) => boolean;
}): unknown[] | undefined {
const anchorIndex = params.messages.findIndex(
(message) => readChatHistoryMessageId(message) === params.messageId,
);
if (anchorIndex === -1) {
return undefined;
}
const anchorGroup = resolveChatHistoryMessageGroup(params.messages, anchorIndex);
if (!params.fits(params.messages.slice(anchorGroup.start, anchorGroup.end))) {
return [params.messages[anchorIndex]];
}
let { start, end } = anchorGroup;
let canGrowOlder = start > 0;
let canGrowNewer = end < params.messages.length;
while (canGrowOlder || canGrowNewer) {
if (canGrowOlder) {
const olderGroup = resolveChatHistoryMessageGroup(params.messages, start - 1);
if (params.fits(params.messages.slice(olderGroup.start, end))) {
start = olderGroup.start;
} else {
canGrowOlder = false;
}
}
canGrowOlder &&= start > 0;
if (canGrowNewer) {
const newerGroup = resolveChatHistoryMessageGroup(params.messages, end);
if (params.fits(params.messages.slice(start, newerGroup.end))) {
end = newerGroup.end;
} else {
canGrowNewer = false;
}
}
canGrowNewer &&= end < params.messages.length;
}
return params.messages.slice(start, end);
}
function dropLocalHistoryOverreadContextMessage(
messages: unknown[],
contextMessage: unknown,
): unknown[] {
if (contextMessage === undefined) {
return messages;
}
const index = messages.indexOf(contextMessage);
if (index < 0) {
return messages;
}
return [...messages.slice(0, index), ...messages.slice(index + 1)];
}
export async function readChatHistoryPage(params: {
entry: ReturnType<typeof loadSessionEntry>["entry"];
provider: string | undefined;
sessionId: string | undefined;
storePath: string | undefined;
sessionAgentId: string;
canonicalKey: string;
max: number;
maxHistoryBytes: number;
effectiveMaxChars: number;
offset: number | undefined;
messageId: string | undefined;
ignoreCliSessionImports?: boolean;
}): Promise<ChatHistoryPage> {
const {
entry,
provider,
sessionId,
storePath,
sessionAgentId,
canonicalKey,
max,
maxHistoryBytes,
effectiveMaxChars,
offset,
messageId,
} = params;
if (!sessionId || !storePath) {
if (messageId) {
return { messages: [] };
}
return {
messages: [],
...(offset !== undefined ? { responseOffset: offset } : {}),
pagination: { offset: offset ?? 0, totalMessages: 0, rawPageMessages: 0 },
};
}
const readScope = {
agentId: sessionAgentId,
sessionEntry: entry,
sessionId,
sessionKey: canonicalKey,
storePath,
};
const cliSessionId = params.ignoreCliSessionImports
? undefined
: resolveClaudeCliBindingSessionId(entry);
// Bound snapshots are terminal by contract, so offset requests return the same
// full snapshot. Paging oversized imports needs an opaque snapshot cursor and
// is deferred to a follow-up issue. Anchored reads fall through with them: the
// full-snapshot merge below still centers on messageId at the handler cap.
if ((offset !== undefined || messageId) && !cliSessionId) {
const rawHistoryWindow = resolveSessionHistoryTailReadOptions(max);
let pageOffset = offset ?? 0;
let hasOverreadContext = false;
let readPage: { messages: unknown[]; totalMessages: number };
if (messageId) {
const anchoredPage = await readSessionMessagesAroundIdWithStatsAsync(readScope, {
messageId,
maxMessages: max,
allowResetArchiveFallback: true,
});
if (!anchoredPage.found) {
return { messages: [] };
}
pageOffset = anchoredPage.offset;
hasOverreadContext = anchoredPage.hasOverreadContext;
readPage = anchoredPage;
} else if (pageOffset === 0) {
readPage = await readRecentSessionMessagesWithStatsAsync(readScope, {
maxMessages: rawHistoryWindow.maxMessages + 1,
maxLines: rawHistoryWindow.maxLines + 1,
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
allowResetArchiveFallback: true,
});
} else {
readPage = await readSessionMessagesPageWithStatsAsync(readScope, {
offset: pageOffset,
maxMessages: max + 1,
allowResetArchiveFallback: true,
});
}
const isTailPage = !messageId && pageOffset === 0;
const overreadContextMessage = isTailPage
? readPage.messages.length > rawHistoryWindow.maxMessages
? readPage.messages[0]
: undefined
: hasOverreadContext || readPage.messages.length > max
? readPage.messages[0]
: undefined;
const localMessages = dropLocalHistoryOverreadContextMessage(
dropPreSessionStartAnnouncePairs(
readPage.messages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
),
overreadContextMessage,
);
const rawPageMessages = isTailPage
? Math.min(
rawHistoryWindow.maxMessages,
Math.max(readPage.messages.length, readPage.totalMessages > 0 ? 1 : 0),
)
: Math.min(
max,
Math.max(readPage.messages.length, readPage.totalMessages > pageOffset ? 1 : 0),
);
const rawMessages = localMessages;
const recencyFilteredMessages = dropPreSessionStartAnnouncePairs(
rawMessages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
);
const projected = isTailPage
? projectRecentChatDisplayMessages(recencyFilteredMessages, {
maxChars: effectiveMaxChars,
maxMessages: max,
})
: projectChatDisplayMessages(recencyFilteredMessages, {
maxChars: effectiveMaxChars,
});
const windowed = messageId
? (capChatHistoryAroundMessage({
messages: projected,
messageId,
fits: (messages) => messages.length <= max,
}) ?? capOffsetChatHistoryProjectedMessages(projected, max))
: isTailPage
? projected
: capOffsetChatHistoryProjectedMessages(projected, max);
const normalized = augmentChatHistoryWithCanvasBlocks(windowed);
if (messageId) {
// Numeric offsets do not encode the selected historical transcript source.
return { messages: normalized };
}
return {
messages: normalized,
responseOffset: pageOffset,
pagination: {
offset: pageOffset,
totalMessages: readPage.totalMessages,
rawPageMessages,
},
};
}
const rawHistoryWindow = resolveSessionHistoryTailReadOptions(max);
const localHistoryReadOptions = {
maxMessages: rawHistoryWindow.maxMessages + 1,
maxLines: rawHistoryWindow.maxLines + 1,
};
const readPage = await readRecentSessionMessagesWithStatsAsync(readScope, {
...localHistoryReadOptions,
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
allowResetArchiveFallback: true,
});
const overreadContextMessage =
readPage.messages.length > rawHistoryWindow.maxMessages ? readPage.messages[0] : undefined;
const localMessagesWithBoundaryFilter = dropLocalHistoryOverreadContextMessage(
dropPreSessionStartAnnouncePairs(
readPage.messages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
),
overreadContextMessage,
);
// The ignore flag must gate this resolver too: the tail-window merge can report
// imported=true while the full merge below dedupes everything to imported=false,
// and an ungated re-resolve here would recurse through this branch forever.
const cliHistory = params.ignoreCliSessionImports
? { messages: localMessagesWithBoundaryFilter, imported: false as const }
: resolveChatHistoryWithCliSessionImports({
entry,
provider,
localMessages: localMessagesWithBoundaryFilter,
});
if ((offset !== undefined || messageId) && !cliHistory.imported) {
return readChatHistoryPage({ ...params, ignoreCliSessionImports: true });
}
if (cliHistory.imported) {
// The import reader already scans the complete external JSONL. Only after it
// succeeds do the matching full local read needed to build a pageable merge.
const completeLocalMessages = dropPreSessionStartAnnouncePairs(
await readSessionMessagesAsync(readScope, {
mode: "full",
reason: "chat.history CLI import merge",
allowResetArchiveFallback: true,
}),
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
);
const completeCliHistory = resolveChatHistoryWithCliSessionImports({
entry,
provider,
localMessages: completeLocalMessages,
});
if (!completeCliHistory.imported) {
return readChatHistoryPage({ ...params, ignoreCliSessionImports: true });
}
const mergedMessages = dropPreSessionStartAnnouncePairs(
completeCliHistory.messages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
);
const displayMessages = projectChatDisplayMessages(mergedMessages, {
maxChars: effectiveMaxChars,
});
return {
messages: augmentChatHistoryWithCanvasBlocks(displayMessages),
completeCliImport: true,
pagination: {
offset: 0,
totalMessages: mergedMessages.length,
rawPageMessages: mergedMessages.length,
exhausted: true,
},
};
}
const rawMessages = cliHistory.messages;
// Drop subagent_announce pairs (user inter-session announce + adjacent
// assistant) whose record timestamp predates the current session's
// sessionStartedAt. Run after CLI history imports too, because those
// timestamped messages share the same chat.history response surface.
const recencyFilteredMessages = dropPreSessionStartAnnouncePairs(
rawMessages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
);
const displayMessages = projectRecentChatDisplayMessages(recencyFilteredMessages, {
maxChars: effectiveMaxChars,
maxMessages: max,
});
return {
messages: augmentChatHistoryWithCanvasBlocks(displayMessages),
pagination: {
offset: 0,
totalMessages: readPage.totalMessages,
// The extra record supplies pair-filter context; it was not returned and
// must remain reachable by the next strictly-older page.
rawPageMessages: Math.min(
rawHistoryWindow.maxMessages,
Math.max(readPage.messages.length, readPage.totalMessages > 0 ? 1 : 0),
),
},
};
}
+93 -309
View File
@@ -7,7 +7,6 @@ import { fileURLToPath } from "node:url";
import { isAudioFileName } from "@openclaw/media-core/mime";
import { expectDefined } from "@openclaw/normalization-core";
import { isFutureDateTimestampMs } from "@openclaw/normalization-core/number-coercion";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
@@ -63,6 +62,7 @@ import {
findTranscriptEvent,
patchSessionEntry,
publishTranscriptUpdate,
resolveTranscriptSessionKeyBySessionId,
withTranscriptWriteLock,
type SessionTranscriptWriteScope,
type TranscriptEvent,
@@ -93,7 +93,7 @@ import {
retainGatewayRootWorkAdmissionContinuation,
runWithGatewayIndependentRootWorkContinuation,
} from "../../process/gateway-work-admission.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { normalizeAgentId, scopeLegacySessionKeyToAgent } from "../../routing/session-key.js";
import { resolveMissingAgentHarnessSessionError } from "../../sessions/agent-harness-session-key.js";
import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
@@ -139,9 +139,7 @@ import {
import {
augmentChatHistoryWithCanvasBlocks,
dropPreSessionStartAnnouncePairs,
projectChatDisplayMessages,
projectChatDisplayMessage,
projectRecentChatDisplayMessages,
resolveEffectiveChatHistoryMaxChars,
} from "../chat-display-projection.js";
import { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js";
@@ -155,10 +153,6 @@ import {
type QueuedChatTurnEntry,
type QueuedChatTurnMap,
} from "../chat-queued-turns.js";
import {
resolveClaudeCliBindingSessionId,
resolveChatHistoryWithCliSessionImports,
} from "../cli-session-history.js";
import { isSuppressedControlReplyText } from "../control-reply-text.js";
import {
isDashboardSessionTitleCandidate,
@@ -177,13 +171,10 @@ import {
pendingChatSendDedupeKey,
type DedupeEntry,
} from "../server-shared.js";
import { resolveSessionHistoryTailReadOptions } from "../session-history-state.js";
import { persistGatewaySessionLifecycleEvent } from "../session-lifecycle-state.js";
import {
capArrayByJsonBytes,
readRecentSessionMessagesWithStatsAsync,
readSessionMessageByIdAsync,
readSessionMessagesPageWithStatsAsync,
readSessionMessagesAsync,
} from "../session-transcript-readers.js";
import {
@@ -221,6 +212,12 @@ import {
replaceOversizedChatHistoryMessages,
reportOmittedChatHistory,
} from "./chat-history-budget.js";
import {
capChatHistoryAroundMessage,
readChatHistoryMessageId,
readChatHistoryMessageSeq,
readChatHistoryPage,
} from "./chat-history-pages.js";
import {
explicitOriginTargetsAcpSession,
explicitOriginTargetsPluginBinding,
@@ -306,25 +303,14 @@ type PreRegisteredAgentDedupePayload = {
turnKind?: unknown;
};
type ChatHistoryMethod = "chat.history" | "chat.startup";
type PreRegisteredAgentRun = {
runId: string;
sessionKey: string;
payload: PreRegisteredAgentDedupePayload;
};
type ChatHistoryMethod = "chat.history" | "chat.startup";
type ChatHistoryPage = {
messages: unknown[];
responseOffset?: number;
completeCliImport?: true;
pagination: {
offset: number;
totalMessages: number;
rawPageMessages: number;
exhausted?: true;
};
};
type ChatMetadataResult = {
commands?: unknown[];
models?: unknown[];
@@ -2111,17 +2097,6 @@ function isSourceReplyTranscriptMirrorPayload(payload: ReplyPayload | undefined)
return Boolean(payload && getReplyPayloadMetadata(payload)?.sourceReplyTranscriptMirror);
}
function readChatHistoryMessageId(message: unknown): string | undefined {
const metadata = asOptionalRecord(asOptionalRecord(message)?.["__openclaw"]);
return typeof metadata?.id === "string" ? metadata.id : undefined;
}
function readChatHistoryMessageSeq(message: unknown): number | undefined {
const metadata = asOptionalRecord(asOptionalRecord(message)?.["__openclaw"]);
const seq = metadata?.seq;
return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined;
}
function resolveChatHistoryNextOffset(params: {
messages: unknown[];
totalMessages: number;
@@ -2164,24 +2139,6 @@ function shouldReplayOldestChatHistoryRecord(params: {
return boundedCount < projectedCount;
}
function capOffsetChatHistoryProjectedMessages(messages: unknown[], max: number): unknown[] {
if (messages.length <= max) {
return messages;
}
const start = Math.max(0, messages.length - max);
const boundarySeq = readChatHistoryMessageSeq(messages[start]);
if (boundarySeq === undefined) {
return messages.slice(start);
}
// Offset cursors can only resume at transcript-record boundaries.
// Keep boundary rows with the same seq together so projection mirrors are not stranded.
let safeStart = start;
while (safeStart > 0 && readChatHistoryMessageSeq(messages[safeStart - 1]) === boundarySeq) {
safeStart--;
}
return messages.slice(safeStart);
}
async function isChatMessageIdVisibleAfterHistoryFilters(params: {
sessionId: string;
storePath: string | undefined;
@@ -2214,240 +2171,6 @@ async function isChatMessageIdVisibleAfterHistoryFilters(params: {
);
}
function dropLocalHistoryOverreadContextMessage(
messages: unknown[],
contextMessage: unknown,
): unknown[] {
if (contextMessage === undefined) {
return messages;
}
const index = messages.indexOf(contextMessage);
if (index < 0) {
return messages;
}
return [...messages.slice(0, index), ...messages.slice(index + 1)];
}
async function readChatHistoryPage(params: {
entry: ReturnType<typeof loadSessionEntry>["entry"];
provider: string | undefined;
sessionId: string | undefined;
storePath: string | undefined;
sessionAgentId: string;
canonicalKey: string;
max: number;
maxHistoryBytes: number;
effectiveMaxChars: number;
offset: number | undefined;
ignoreCliSessionImports?: boolean;
}): Promise<ChatHistoryPage> {
const {
entry,
provider,
sessionId,
storePath,
sessionAgentId,
canonicalKey,
max,
maxHistoryBytes,
effectiveMaxChars,
offset,
} = params;
if (!sessionId || !storePath) {
return {
messages: [],
...(offset !== undefined ? { responseOffset: offset } : {}),
pagination: { offset: offset ?? 0, totalMessages: 0, rawPageMessages: 0 },
};
}
const readScope = {
agentId: sessionAgentId,
sessionEntry: entry,
sessionId,
sessionKey: canonicalKey,
storePath,
};
const cliSessionId = params.ignoreCliSessionImports
? undefined
: resolveClaudeCliBindingSessionId(entry);
// Bound snapshots are terminal by contract, so offset requests return the same
// full snapshot. Paging oversized imports needs an opaque snapshot cursor and
// is deferred to a follow-up issue.
if (offset !== undefined && !cliSessionId) {
const rawHistoryWindow = resolveSessionHistoryTailReadOptions(max);
const readPage =
offset === 0
? await readRecentSessionMessagesWithStatsAsync(readScope, {
maxMessages: rawHistoryWindow.maxMessages + 1,
maxLines: rawHistoryWindow.maxLines + 1,
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
allowResetArchiveFallback: true,
})
: await readSessionMessagesPageWithStatsAsync(readScope, {
offset,
maxMessages: max + 1,
allowResetArchiveFallback: true,
});
const overreadContextMessage =
offset === 0
? readPage.messages.length > rawHistoryWindow.maxMessages
? readPage.messages[0]
: undefined
: readPage.messages.length > max
? readPage.messages[0]
: undefined;
const localMessages =
offset === 0
? dropLocalHistoryOverreadContextMessage(
dropPreSessionStartAnnouncePairs(
readPage.messages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
),
overreadContextMessage,
)
: dropLocalHistoryOverreadContextMessage(
dropPreSessionStartAnnouncePairs(
readPage.messages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
),
overreadContextMessage,
);
const rawPageMessages =
offset === 0
? Math.min(
rawHistoryWindow.maxMessages,
Math.max(readPage.messages.length, readPage.totalMessages > 0 ? 1 : 0),
)
: Math.min(
max,
Math.max(readPage.messages.length, readPage.totalMessages > offset ? 1 : 0),
);
const rawMessages = localMessages;
const recencyFilteredMessages = dropPreSessionStartAnnouncePairs(
rawMessages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
);
const projected =
offset === 0
? projectRecentChatDisplayMessages(recencyFilteredMessages, {
maxChars: effectiveMaxChars,
maxMessages: max,
})
: projectChatDisplayMessages(recencyFilteredMessages, {
maxChars: effectiveMaxChars,
});
const windowed =
offset === 0 ? projected : capOffsetChatHistoryProjectedMessages(projected, max);
const normalized = augmentChatHistoryWithCanvasBlocks(windowed);
return {
messages: normalized,
responseOffset: offset,
pagination: {
offset,
totalMessages: readPage.totalMessages,
rawPageMessages,
},
};
}
const rawHistoryWindow = resolveSessionHistoryTailReadOptions(max);
const localHistoryReadOptions = {
maxMessages: rawHistoryWindow.maxMessages + 1,
maxLines: rawHistoryWindow.maxLines + 1,
};
const readPage = await readRecentSessionMessagesWithStatsAsync(readScope, {
...localHistoryReadOptions,
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
allowResetArchiveFallback: true,
});
const overreadContextMessage =
readPage.messages.length > rawHistoryWindow.maxMessages ? readPage.messages[0] : undefined;
const localMessagesWithBoundaryFilter = dropLocalHistoryOverreadContextMessage(
dropPreSessionStartAnnouncePairs(
readPage.messages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
),
overreadContextMessage,
);
// The ignore flag must gate this resolver too: the tail-window merge can report
// imported=true while the full merge below dedupes everything to imported=false,
// and an ungated re-resolve here would recurse through this branch forever.
const cliHistory = params.ignoreCliSessionImports
? { messages: localMessagesWithBoundaryFilter, imported: false as const }
: resolveChatHistoryWithCliSessionImports({
entry,
provider,
localMessages: localMessagesWithBoundaryFilter,
});
if (offset !== undefined && !cliHistory.imported) {
return readChatHistoryPage({ ...params, ignoreCliSessionImports: true });
}
if (cliHistory.imported) {
// The import reader already scans the complete external JSONL. Only after it
// succeeds do the matching full local read needed to build a pageable merge.
const completeLocalMessages = dropPreSessionStartAnnouncePairs(
await readSessionMessagesAsync(readScope, {
mode: "full",
reason: "chat.history CLI import merge",
allowResetArchiveFallback: true,
}),
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
);
const completeCliHistory = resolveChatHistoryWithCliSessionImports({
entry,
provider,
localMessages: completeLocalMessages,
});
if (!completeCliHistory.imported) {
return readChatHistoryPage({ ...params, ignoreCliSessionImports: true });
}
const mergedMessages = dropPreSessionStartAnnouncePairs(
completeCliHistory.messages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
);
const displayMessages = projectChatDisplayMessages(mergedMessages, {
maxChars: effectiveMaxChars,
});
return {
messages: augmentChatHistoryWithCanvasBlocks(displayMessages),
completeCliImport: true,
pagination: {
offset: 0,
totalMessages: mergedMessages.length,
rawPageMessages: mergedMessages.length,
exhausted: true,
},
};
}
const rawMessages = cliHistory.messages;
// Drop subagent_announce pairs (user inter-session announce + adjacent
// assistant) whose record timestamp predates the current session's
// sessionStartedAt. Run after CLI history imports too, because those
// timestamped messages share the same chat.history response surface.
const recencyFilteredMessages = dropPreSessionStartAnnouncePairs(
rawMessages,
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
);
const displayMessages = projectRecentChatDisplayMessages(recencyFilteredMessages, {
maxChars: effectiveMaxChars,
maxMessages: max,
});
return {
messages: augmentChatHistoryWithCanvasBlocks(displayMessages),
pagination: {
offset: 0,
totalMessages: readPage.totalMessages,
// The extra record supplies pair-filter context; it was not returned and
// must remain reachable by the next strictly-older page.
rawPageMessages: Math.min(
rawHistoryWindow.maxMessages,
Math.max(readPage.messages.length, readPage.totalMessages > 0 ? 1 : 0),
),
},
};
}
async function handleChatHistoryRequest({
params,
respond,
@@ -2471,13 +2194,38 @@ async function handleChatHistoryRequest({
);
return;
}
const { sessionKey, limit, offset, maxChars } = params as {
const {
sessionKey,
limit,
offset,
messageId,
sessionId: requestedSessionId,
maxChars,
} = params as {
sessionKey: string;
agentId?: string;
limit?: number;
offset?: number;
messageId?: string;
sessionId?: string;
maxChars?: number;
};
if (offset !== undefined && messageId !== undefined) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "offset and messageId cannot be used together"),
);
return;
}
if (requestedSessionId !== undefined && messageId === undefined) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "sessionId requires messageId"),
);
return;
}
const agentIdOverride = normalizeOptionalText((params as { agentId?: string }).agentId);
const requestedAgentId = resolveRequestedChatAgentId({
cfg: (context as { getRuntimeConfig?: () => OpenClawConfig }).getRuntimeConfig?.(),
@@ -2498,6 +2246,32 @@ async function handleChatHistoryRequest({
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, selectedAgent.error));
return;
}
const sessionAgentId = resolveSessionAgentId({
sessionKey,
config: cfg,
agentId: selectedAgent.agentId,
});
if (requestedSessionId) {
const transcriptSessionKey = resolveTranscriptSessionKeyBySessionId({
agentId: sessionAgentId,
sessionId: requestedSessionId,
storePath,
});
if (
!transcriptSessionKey ||
scopeLegacySessionKeyToAgent({
sessionKey: transcriptSessionKey,
agentId: sessionAgentId,
}) !== scopeLegacySessionKeyToAgent({ sessionKey: canonicalKey, agentId: sessionAgentId })
) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "sessionId does not belong to sessionKey"),
);
return;
}
}
const startupModelCatalogLoad =
method === "chat.startup"
? startOptionalServerMethodModelCatalogSnapshotLoad(context)
@@ -2522,12 +2296,9 @@ async function handleChatHistoryRequest({
if (startupModelCatalogLoad) {
void modelCatalogPromise.catch(() => undefined);
}
const sessionId = entry?.sessionId;
const sessionAgentId = resolveSessionAgentId({
sessionKey,
config: cfg,
agentId: selectedAgent.agentId,
});
const sessionId = requestedSessionId ?? entry?.sessionId;
const historyEntry =
requestedSessionId && requestedSessionId !== entry?.sessionId ? undefined : entry;
const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId);
const hardMax = 1000;
const defaultLimit = 200;
@@ -2536,7 +2307,7 @@ async function handleChatHistoryRequest({
const maxHistoryBytes = getMaxChatHistoryMessagesBytes();
const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg, maxChars);
const historyPage = await readChatHistoryPage({
entry,
entry: historyEntry,
provider: resolvedSessionModel.provider,
sessionId,
storePath,
@@ -2546,6 +2317,7 @@ async function handleChatHistoryRequest({
maxHistoryBytes,
effectiveMaxChars,
offset,
messageId,
});
const normalized = historyPage.messages;
const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes);
@@ -2558,7 +2330,13 @@ async function handleChatHistoryRequest({
...(selectedAgent.agentId ? { agentId: selectedAgent.agentId } : {}),
context,
});
const capped = capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items;
const capped = messageId
? (capChatHistoryAroundMessage({
messages: replaced.messages,
messageId,
fits: (messages) => jsonUtf8Bytes(messages) <= maxHistoryBytes,
}) ?? capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items)
: capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items;
const bounded = enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes });
const historyBudgetPreserved =
replaced.replacedCount === 0 &&
@@ -2566,17 +2344,23 @@ async function handleChatHistoryRequest({
bounded.messages.length === capped.length &&
bounded.messages.every((message, index) => message === capped[index]);
const pagination = historyPage.pagination;
const candidateNextOffset = resolveChatHistoryNextOffset({
messages: bounded.messages,
totalMessages: pagination.totalMessages,
offset: pagination.offset,
rawPageMessages: pagination.rawPageMessages,
replayOldestRecord: shouldReplayOldestChatHistoryRecord({
projected: normalized,
bounded: bounded.messages,
}),
});
const hasMore = pagination.exhausted !== true && candidateNextOffset < pagination.totalMessages;
const candidateNextOffset =
pagination === undefined
? undefined
: resolveChatHistoryNextOffset({
messages: bounded.messages,
totalMessages: pagination.totalMessages,
offset: pagination.offset,
rawPageMessages: pagination.rawPageMessages,
replayOldestRecord: shouldReplayOldestChatHistoryRecord({
projected: normalized,
bounded: bounded.messages,
}),
});
const hasMore =
pagination !== undefined && candidateNextOffset !== undefined
? pagination.exhausted !== true && candidateNextOffset < pagination.totalMessages
: undefined;
const nextOffset = hasMore ? candidateNextOffset : undefined;
reportOmittedChatHistory({
originalMessages: normalized,
@@ -2663,8 +2447,8 @@ async function handleChatHistoryRequest({
messages: bounded.messages,
...(historyPage.responseOffset !== undefined ? { offset: historyPage.responseOffset } : {}),
...(hasMore ? { nextOffset } : {}),
hasMore,
totalMessages: pagination.totalMessages,
...(hasMore !== undefined ? { hasMore } : {}),
...(pagination !== undefined ? { totalMessages: pagination.totalMessages } : {}),
...(historyPage.completeCliImport && !hasMore && historyBudgetPreserved
? { completeSnapshot: true }
: {}),
@@ -9,7 +9,12 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { GetReplyOptions } from "../auto-reply/get-reply-options.types.js";
import type { InternalGetReplyOptions } from "../auto-reply/reply/get-reply.types.js";
import { clearConfigCache, getRuntimeConfig } from "../config/config.js";
import { appendTranscriptEvent, loadSessionEntry } from "../config/sessions/session-accessor.js";
import {
appendTranscriptEvent,
appendTranscriptMessage,
loadSessionEntry,
} from "../config/sessions/session-accessor.js";
import { appendSqliteTranscriptEvents } from "../config/sessions/session-accessor.sqlite.js";
import { invalidateSessionStoreCache } from "../config/sessions/store-cache.js";
import type { AgentModelConfig } from "../config/types.agents-shared.js";
import { rotateAgentEventLifecycleGeneration } from "../infra/agent-events.js";
@@ -5269,6 +5274,179 @@ describe("gateway server chat", () => {
});
});
test("chat.history centers a bounded page around a message id", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
await writeMainSessionTranscript(sessionDir, [
JSON.stringify({ type: "model_change", provider: "mock", modelId: "mock" }),
JSON.stringify({ type: "thinking_level_change", thinkingLevel: "off" }),
]);
const storePath = testState.sessionStorePath;
if (!storePath) {
throw new Error("session store path was not initialized");
}
for (let index = 0; index < 7; index += 1) {
await appendTranscriptMessage(
{
agentId: "main",
sessionId: "sess-main",
sessionKey: "agent:main:main",
storePath,
},
{
eventId: `message-${index + 1}`,
message: {
role: index % 2 === 0 ? "user" : "assistant",
content: [{ type: "text", text: `message ${index + 1} ${"x".repeat(700)}` }],
timestamp: Date.now() + index,
},
},
);
}
const history = await rpcReq<{
messages?: Array<{ __openclaw?: { seq?: number } }>;
hasMore?: boolean;
nextOffset?: number;
offset?: number;
totalMessages?: number;
}>(ws, "chat.history", {
sessionKey: "main",
limit: 3,
messageId: "message-3",
sessionId: "sess-main",
maxChars: 100,
});
expect(history.ok).toBe(true);
expect(history.payload?.messages?.map(readOpenClawSeq)).toEqual([4, 5, 6]);
expect(history.payload?.offset).toBeUndefined();
expect(history.payload?.nextOffset).toBeUndefined();
expect(history.payload?.hasMore).toBeUndefined();
expect(history.payload?.totalMessages).toBeUndefined();
setMaxChatHistoryMessagesBytesForTest(1_000);
const capped = await rpcReq<{
messages?: Array<{ __openclaw?: { id?: string } }>;
}>(ws, "chat.history", {
sessionKey: "main",
limit: 3,
messageId: "message-3",
sessionId: "sess-main",
});
expect(capped.ok).toBe(true);
expect(
capped.payload?.messages?.some((message) => message["__openclaw"]?.id === "message-3"),
).toBe(true);
});
});
test("chat.history reopens a search anchor from a prior session id", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await prepareMainHistoryHarness({ ws, createSessionDir });
const currentSessionStartedAt = Date.now();
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: futureFixtureUpdatedAt(),
sessionStartedAt: currentSessionStartedAt,
},
},
});
const storePath = testState.sessionStorePath;
if (!storePath) {
throw new Error("session store path was not initialized");
}
await appendSqliteTranscriptEvents(
{
agentId: "main",
sessionId: "sess-before-reset",
sessionKey: "agent:main:main",
storePath,
},
[
{
type: "message",
id: "archived-1",
parentId: null,
message: {
role: "user",
provenance: { kind: "inter_session", sourceTool: "subagent_announce" },
content: "before anchor",
timestamp: currentSessionStartedAt - 2_000,
},
},
{
type: "message",
id: "archived-2",
parentId: "archived-1",
message: {
role: "assistant",
content: "matching anchor",
timestamp: currentSessionStartedAt - 1_000,
},
},
{
type: "message",
id: "archived-3",
parentId: "archived-2",
message: { role: "user", content: "after anchor" },
},
],
);
const history = await rpcReq<{
messages?: Array<{ content?: string }>;
}>(ws, "chat.history", {
sessionKey: "main",
limit: 3,
messageId: "archived-2",
sessionId: "sess-before-reset",
});
expect(history.ok).toBe(true);
expect(history.payload?.messages?.map((message) => message.content)).toEqual([
"matching anchor",
"after anchor",
]);
});
});
test("chat.history rejects offset and message id together", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await prepareMainHistoryHarness({ ws, createSessionDir });
const history = await rpcReq(ws, "chat.history", {
sessionKey: "main",
offset: 0,
messageId: "message-1",
});
expect(history.ok).toBe(false);
expect((history.error as { message?: string } | undefined)?.message).toContain(
"offset and messageId cannot be used together",
);
});
});
test("chat.history rejects an anchored session id from another session key", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await prepareMainHistoryHarness({ ws, createSessionDir });
const history = await rpcReq(ws, "chat.history", {
sessionKey: "main",
messageId: "message-1",
sessionId: "unknown-session",
});
expect(history.ok).toBe(false);
expect((history.error as { message?: string } | undefined)?.message).toContain(
"sessionId does not belong to sessionKey",
);
});
});
test("chat.history offset pagination advances from the final budgeted page", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
const sessionDir = await prepareMainHistoryHarness({
@@ -0,0 +1,71 @@
import type { SessionTranscriptReadScope } from "../config/sessions/session-accessor.js";
import {
isSqliteReadTarget,
readSqliteMessageRecords,
resolveTranscriptReadTarget,
sqliteRecordMessageWithSeq,
type ReadRecentSessionMessagesResult,
} from "./session-transcript-readers.js";
import {
readSessionMessagesAroundIdWithStatsAsync as readSessionMessagesAroundIdWithStatsAsyncFile,
resolveSessionMessageAnchorBounds,
} from "./session-utils.fs-anchor.js";
type ReadSessionMessagesAroundIdResult = ReadRecentSessionMessagesResult & {
found: boolean;
hasOverreadContext: boolean;
offset: number;
};
/** Reads one message-id-anchored page from a single transcript snapshot. */
export async function readSessionMessagesAroundIdWithStatsAsync(
scope: SessionTranscriptReadScope,
opts: { messageId: string; maxMessages: number; allowResetArchiveFallback?: boolean },
): Promise<ReadSessionMessagesAroundIdResult> {
const target = resolveTranscriptReadTarget(scope);
const sessionFile =
!scope.sessionFile &&
scope.sessionEntry?.sessionId &&
scope.sessionEntry.sessionId !== scope.sessionId
? undefined
: target.sessionFile;
if (isSqliteReadTarget(target)) {
const records = await readSqliteMessageRecords(target);
const bounds = resolveSessionMessageAnchorBounds(records, opts.messageId, opts.maxMessages);
if (!bounds) {
if (opts.allowResetArchiveFallback === true) {
return await readSessionMessagesAroundIdWithStatsAsyncFile(
target.sessionId,
target.storePath,
sessionFile,
opts,
target.agentId,
);
}
return {
found: false,
hasOverreadContext: false,
messages: [],
offset: 0,
totalMessages: records.length,
transcriptPath: target.sessionFile,
};
}
const readStart = Math.max(0, bounds.start - 1);
return {
found: true,
hasOverreadContext: readStart < bounds.start,
messages: records.slice(readStart, bounds.endExclusive).map(sqliteRecordMessageWithSeq),
offset: bounds.offset,
totalMessages: records.length,
transcriptPath: target.sessionFile,
};
}
return await readSessionMessagesAroundIdWithStatsAsyncFile(
target.sessionId,
target.storePath,
sessionFile,
opts,
target.agentId,
);
}
@@ -9,6 +9,7 @@ import {
import { appendSqliteTranscriptEvents } from "../config/sessions/session-accessor.sqlite.js";
import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import { readSessionMessagesAroundIdWithStatsAsync } from "./session-transcript-anchor-reader.js";
import {
readLatestRecentSessionUsageFromTranscriptAsync,
readLatestSessionUsageFromTranscriptAsync,
@@ -84,6 +85,18 @@ describe("session transcript reader facade", () => {
oversized: false,
seq: 2,
});
await expect(
readSessionMessagesAroundIdWithStatsAsync(scope, {
messageId: "active",
maxMessages: 1,
}),
).resolves.toMatchObject({
found: true,
hasOverreadContext: true,
messages: [{ content: "root prompt" }, { content: "active answer" }],
offset: 0,
totalMessages: 2,
});
});
test("reads recent tails with total counts through a scope", () => {
@@ -111,6 +124,126 @@ describe("session transcript reader facade", () => {
]);
});
test("finds an anchored reset-archive message by historical session id", async () => {
const sessionId = "reader-file-archive-anchor";
const scope = writeTranscript(sessionId, [
{ type: "session", version: 3, id: sessionId },
{
type: "message",
id: "active-message",
parentId: null,
message: { role: "user", content: "active prompt" },
},
]);
fs.writeFileSync(
path.join(tempDir, `${sessionId}.jsonl.reset.2026-07-12T17-00-00.000Z`),
`${JSON.stringify({ type: "session", version: 3, id: sessionId })}\n${JSON.stringify({
type: "message",
id: "archived-message",
parentId: null,
message: { role: "user", content: "archived prompt" },
})}\n`,
"utf-8",
);
await expect(
readSessionMessagesAroundIdWithStatsAsync(scope, {
messageId: "archived-message",
maxMessages: 1,
allowResetArchiveFallback: true,
}),
).resolves.toMatchObject({
found: true,
messages: [{ content: "archived prompt" }],
});
});
test("does not reuse the current session file for a historical anchor", async () => {
const currentSessionId = "reader-current-collision";
const historicalSessionId = "reader-historical-collision";
const currentSessionFile = path.join(tempDir, `${currentSessionId}.jsonl`);
fs.writeFileSync(
currentSessionFile,
`${JSON.stringify({ type: "session", version: 3, id: currentSessionId })}\n${JSON.stringify({
type: "message",
id: "shared-message",
parentId: null,
message: { role: "user", content: "current collision" },
})}\n`,
"utf-8",
);
fs.writeFileSync(
path.join(tempDir, `${historicalSessionId}.jsonl.reset.2026-07-12T17-00-00.000Z`),
`${JSON.stringify({ type: "session", version: 3, id: historicalSessionId })}\n${JSON.stringify(
{
type: "message",
id: "shared-message",
parentId: null,
message: { role: "user", content: "historical collision" },
},
)}\n`,
"utf-8",
);
await expect(
readSessionMessagesAroundIdWithStatsAsync(
{
agentId: "main",
sessionId: historicalSessionId,
sessionKey: "agent:main:main",
storePath,
sessionEntry: { sessionId: currentSessionId, sessionFile: currentSessionFile },
},
{
messageId: "shared-message",
maxMessages: 1,
allowResetArchiveFallback: true,
},
),
).resolves.toMatchObject({
found: true,
messages: [{ content: "historical collision" }],
});
});
test("keeps an explicit historical session file over a mismatched current entry", async () => {
const historicalSessionId = "reader-explicit-historical";
const historicalSessionFile = path.join(tempDir, "explicit-historical.jsonl");
fs.writeFileSync(
historicalSessionFile,
`${JSON.stringify({ type: "session", version: 3, id: historicalSessionId })}\n${JSON.stringify(
{
type: "message",
id: "historical-message",
parentId: null,
message: { role: "user", content: "explicit historical" },
},
)}\n`,
"utf-8",
);
await expect(
readSessionMessagesAroundIdWithStatsAsync(
{
sessionFile: historicalSessionFile,
sessionId: historicalSessionId,
sessionEntry: {
sessionId: "reader-current-entry",
sessionFile: path.join(tempDir, "reader-current-entry.jsonl"),
},
},
{
messageId: "historical-message",
maxMessages: 1,
allowResetArchiveFallback: true,
},
),
).resolves.toMatchObject({
found: true,
messages: [{ content: "explicit historical" }],
});
});
test("reads title fields and recent usage through a scope", async () => {
const scope = writeTranscript("reader-title-usage", [
{ type: "session", version: 1, id: "reader-title-usage" },
+6 -6
View File
@@ -60,7 +60,7 @@ type SessionTitleFields = {
lastMessagePreview: string | null;
};
type ReadRecentSessionMessagesResult = {
export type ReadRecentSessionMessagesResult = {
messages: unknown[];
transcriptPath?: string;
totalMessages: number;
@@ -78,7 +78,7 @@ type ReadSessionMessageByIdResult = {
found: boolean;
};
type ResolvedTranscriptReadTarget = {
export type ResolvedTranscriptReadTarget = {
agentId?: string;
sessionFile: string;
sessionId: string;
@@ -86,7 +86,7 @@ type ResolvedTranscriptReadTarget = {
storePath?: string;
};
function resolveTranscriptReadTarget(
export function resolveTranscriptReadTarget(
scope: SessionTranscriptReadScope,
): ResolvedTranscriptReadTarget {
const target = resolveSessionTranscriptReadTarget(scope);
@@ -109,7 +109,7 @@ function resolveConcreteReadStorePath(storePath: string | undefined): string | u
return trimmed;
}
function isSqliteReadTarget(target: ResolvedTranscriptReadTarget): boolean {
export function isSqliteReadTarget(target: ResolvedTranscriptReadTarget): boolean {
return parseSqliteSessionFileMarker(target.sessionFile) !== undefined;
}
@@ -170,7 +170,7 @@ function readSqliteMessageRecordsSync(target: ResolvedTranscriptReadTarget): {
);
}
async function readSqliteMessageRecords(target: ResolvedTranscriptReadTarget): Promise<
export async function readSqliteMessageRecords(target: ResolvedTranscriptReadTarget): Promise<
{
id?: string;
message: unknown;
@@ -265,7 +265,7 @@ function readRecentSqliteUsageMessages(
).map((record) => record.message);
}
function sqliteRecordMessageWithSeq(record: {
export function sqliteRecordMessageWithSeq(record: {
id?: string;
message: unknown;
recordTimestampMs?: number;
+104
View File
@@ -0,0 +1,104 @@
import { materializeSessionArchiveForRead } from "../config/sessions/archive-compression.js";
import { readSessionTranscriptIndex } from "./session-transcript-index.fs.js";
import {
findExistingTranscriptPath,
indexedTranscriptEntryToMessages,
resolveSessionTranscriptResetArchiveCandidatesAsync,
type ReadRecentSessionMessagesResult,
} from "./session-utils.fs.js";
type ReadSessionMessagesAroundIdOptions = {
messageId: string;
maxMessages: number;
allowResetArchiveFallback?: boolean;
};
type ReadSessionMessagesAroundIdResult = ReadRecentSessionMessagesResult & {
found: boolean;
hasOverreadContext: boolean;
offset: number;
};
export function resolveSessionMessageAnchorBounds(
records: readonly { id?: string }[],
messageId: string,
maxMessages: number,
): { endExclusive: number; offset: number; start: number } | undefined {
const anchorIndex = records.findIndex((record) => record.id === messageId);
if (anchorIndex === -1) {
return undefined;
}
const pageSize = Math.max(1, Math.floor(maxMessages));
const newerMessages = Math.floor(pageSize / 2);
const olderMessages = pageSize - newerMessages - 1;
const latestStart = Math.max(0, records.length - pageSize);
const start = Math.min(Math.max(0, anchorIndex - olderMessages), latestStart);
const endExclusive = Math.min(records.length, start + pageSize);
return { endExclusive, offset: records.length - endExclusive, start };
}
export async function readSessionMessagesAroundIdWithStatsAsync(
sessionId: string,
storePath: string | undefined,
sessionFile: string | undefined,
opts: ReadSessionMessagesAroundIdOptions,
agentId?: string,
): Promise<ReadSessionMessagesAroundIdResult> {
const activePath = findExistingTranscriptPath(sessionId, storePath, sessionFile, agentId);
const archivePaths =
opts.allowResetArchiveFallback === true
? await resolveSessionTranscriptResetArchiveCandidatesAsync(
sessionId,
storePath,
sessionFile,
agentId,
)
: [];
const paths = [activePath, ...archivePaths].filter(
(candidate, index, candidates): candidate is string =>
candidate !== null && candidates.indexOf(candidate) === index,
);
let activeTotalMessages = 0;
for (const candidatePath of paths) {
let filePath: string;
try {
filePath = materializeSessionArchiveForRead(candidatePath);
} catch {
continue;
}
const index = await readSessionTranscriptIndex(filePath);
if (!index) {
continue;
}
if (candidatePath === activePath) {
activeTotalMessages = index.entries.length;
}
const bounds = resolveSessionMessageAnchorBounds(
index.entries,
opts.messageId,
opts.maxMessages,
);
if (!bounds) {
continue;
}
const readStart = Math.max(0, bounds.start - 1);
return {
found: true,
hasOverreadContext: readStart < bounds.start,
messages: index.entries
.slice(readStart, bounds.endExclusive)
.flatMap((entry) => indexedTranscriptEntryToMessages(entry)),
offset: bounds.offset,
totalMessages: index.entries.length,
transcriptPath: filePath,
};
}
return {
found: false,
hasOverreadContext: false,
messages: [],
offset: 0,
totalMessages: activeTotalMessages,
...(activePath ? { transcriptPath: activePath } : {}),
};
}
+3 -3
View File
@@ -216,7 +216,7 @@ export type ReadSessionMessagesAsyncOptions =
mode: "recent";
} & ReadRecentSessionMessagesOptions);
type ReadRecentSessionMessagesResult = {
export type ReadRecentSessionMessagesResult = {
messages: unknown[];
totalMessages: number;
transcriptPath?: string;
@@ -949,7 +949,7 @@ function indexedTranscriptEntryToMessage(entry: IndexedTranscriptEntry): unknown
return parsedSessionEntryToMessage(entry.record, entry.seq);
}
function indexedTranscriptEntryToMessages(entry: IndexedTranscriptEntry): unknown[] {
export function indexedTranscriptEntryToMessages(entry: IndexedTranscriptEntry): unknown[] {
const message = indexedTranscriptEntryToMessage(entry);
return message ? [message] : [];
}
@@ -1196,7 +1196,7 @@ function extractFirstUserMessageFromTranscriptChunk(
return null;
}
function findExistingTranscriptPath(
export function findExistingTranscriptPath(
sessionId: string,
storePath: string | undefined,
sessionFile?: string,
@@ -1248,7 +1248,7 @@
},
{
"deferLoading": true,
"description": "Read sanitized visible-session history. Before reply/debug/resume. Supports limit/offset/tool messages.",
"description": "Read sanitized visible-session history. Before reply/debug/resume. Supports limit, offset, search-result sessionId/messageId anchors, and tool messages.",
"inputSchema": {
"properties": {
"includeTools": {
@@ -1258,10 +1258,18 @@
"minimum": 1,
"type": "integer"
},
"messageId": {
"minLength": 1,
"type": "string"
},
"offset": {
"minimum": 0,
"type": "integer"
},
"sessionId": {
"minLength": 1,
"type": "string"
},
"sessionKey": {
"type": "string"
}
@@ -1274,7 +1282,7 @@
},
{
"deferLoading": true,
"description": "Search your own past sessions for matching user and assistant text. Follow up with sessions_history using a returned sessionKey for full context.",
"description": "Search your own past sessions for matching user and assistant text. Follow up with sessions_history using a returned sessionKey, sessionId, and messageId for neighboring context.",
"inputSchema": {
"properties": {
"limit": {
@@ -1280,7 +1280,7 @@
},
{
"deferLoading": true,
"description": "Read sanitized visible-session history. Before reply/debug/resume. Supports limit/offset/tool messages.",
"description": "Read sanitized visible-session history. Before reply/debug/resume. Supports limit, offset, search-result sessionId/messageId anchors, and tool messages.",
"inputSchema": {
"properties": {
"includeTools": {
@@ -1290,10 +1290,18 @@
"minimum": 1,
"type": "integer"
},
"messageId": {
"minLength": 1,
"type": "string"
},
"offset": {
"minimum": 0,
"type": "integer"
},
"sessionId": {
"minLength": 1,
"type": "string"
},
"sessionKey": {
"type": "string"
}
@@ -1306,7 +1314,7 @@
},
{
"deferLoading": true,
"description": "Search your own past sessions for matching user and assistant text. Follow up with sessions_history using a returned sessionKey for full context.",
"description": "Search your own past sessions for matching user and assistant text. Follow up with sessions_history using a returned sessionKey, sessionId, and messageId for neighboring context.",
"inputSchema": {
"properties": {
"limit": {
@@ -1244,7 +1244,7 @@
},
{
"deferLoading": true,
"description": "Read sanitized visible-session history. Before reply/debug/resume. Supports limit/offset/tool messages.",
"description": "Read sanitized visible-session history. Before reply/debug/resume. Supports limit, offset, search-result sessionId/messageId anchors, and tool messages.",
"inputSchema": {
"properties": {
"includeTools": {
@@ -1254,10 +1254,18 @@
"minimum": 1,
"type": "integer"
},
"messageId": {
"minLength": 1,
"type": "string"
},
"offset": {
"minimum": 0,
"type": "integer"
},
"sessionId": {
"minLength": 1,
"type": "string"
},
"sessionKey": {
"type": "string"
}
@@ -1270,7 +1278,7 @@
},
{
"deferLoading": true,
"description": "Search your own past sessions for matching user and assistant text. Follow up with sessions_history using a returned sessionKey for full context.",
"description": "Search your own past sessions for matching user and assistant text. Follow up with sessions_history using a returned sessionKey, sessionId, and messageId for neighboring context.",
"inputSchema": {
"properties": {
"limit": {
@@ -208,8 +208,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 52644,
"roughTokens": 13161
"chars": 52932,
"roughTokens": 13233
},
"openClawDeveloperInstructions": {
"chars": 3431,
@@ -220,8 +220,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6989
},
"totalWithDynamicToolsJson": {
"chars": 80602,
"roughTokens": 20151
"chars": 80890,
"roughTokens": 20223
},
"userInputText": {
"chars": 1442,
@@ -208,8 +208,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 52371,
"roughTokens": 13093
"chars": 52659,
"roughTokens": 13165
},
"openClawDeveloperInstructions": {
"chars": 2322,
@@ -220,8 +220,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6610
},
"totalWithDynamicToolsJson": {
"chars": 78811,
"roughTokens": 19703
"chars": 79099,
"roughTokens": 19775
},
"userInputText": {
"chars": 1033,
@@ -209,8 +209,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 53661,
"roughTokens": 13416
"chars": 53949,
"roughTokens": 13488
},
"openClawDeveloperInstructions": {
"chars": 2341,
@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6745
},
"totalWithDynamicToolsJson": {
"chars": 80640,
"roughTokens": 20160
"chars": 80928,
"roughTokens": 20232
},
"userInputText": {
"chars": 1271,