mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(sessions): reopen history around search hits
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+10
-2
@@ -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": {
|
||||
|
||||
+10
-2
@@ -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": {
|
||||
|
||||
+10
-2
@@ -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": {
|
||||
|
||||
Vendored
+4
-4
@@ -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,
|
||||
|
||||
test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
Vendored
+4
-4
@@ -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,
|
||||
|
||||
Vendored
+4
-4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user