perf(gateway): fold session touched-files incrementally via transcript delta (#114401)

* perf(gateway): fold session touched-files incrementally via transcript delta

* refactor(gateway): keep session touched-files fold on the canonical sqlite path

* test(gateway): split session files transcript-fold coverage

* test(gateway): drop unused session-files test-support exports
This commit is contained in:
Peter Steinberger
2026-07-27 04:32:57 -04:00
committed by GitHub
parent abfe7c64a7
commit 4aa2ab2b25
5 changed files with 654 additions and 282 deletions

View File

@@ -0,0 +1,144 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { expect } from "vitest";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
type SessionFilesMethod =
| "sessions.files.list"
| "sessions.files.get"
| "sessions.files.set"
| "sessions.files.reveal";
type ResponderCall = { ok: boolean; payload?: unknown; error?: unknown };
type ReturnValueMock = { mockReturnValue: (value: unknown) => unknown };
function createResponder() {
const calls: ResponderCall[] = [];
const respond: RespondFn = (ok, payload, error) => {
calls.push({ ok, payload, error });
};
return { calls, respond };
}
export function createSessionFilesHandlerInvoker(handlers: GatewayRequestHandlers) {
return async (
method: SessionFilesMethod,
params: Record<string, unknown>,
context: Record<string, unknown> = {},
) => {
const responder = createResponder();
await handlers[method]?.({
req: { type: "req", id: method, method, params: {} },
params,
client: null,
isWebchatConnect: () => false,
respond: responder.respond,
context: context as never,
});
return responder.calls;
};
}
export function expectOkPayload(calls: ResponderCall[]): Record<string, any> {
expect(calls).toHaveLength(1);
expect(calls[0]?.ok).toBe(true);
return calls[0]?.payload as Record<string, any>;
}
export function expectError(calls: ResponderCall[]): Record<string, any> {
expect(calls).toHaveLength(1);
expect(calls[0]?.ok).toBe(false);
return calls[0]?.error as Record<string, any>;
}
export function assistantToolCall(name: string, args: Record<string, unknown>) {
return {
role: "assistant",
content: [
{
type: "toolCall",
name,
arguments: args,
},
],
};
}
export function visibleMessageEvent(message: unknown, seq: number) {
return {
event: { id: `event-${String(seq)}`, message },
eventSeq: seq,
parentId: seq > 1 ? `event-${String(seq - 1)}` : null,
seq,
};
}
export function createVisibleMessagesMock(readVisibleMessageDelta: ReturnValueMock) {
return (messages: unknown[], cursor = "visible-messages-final"): void => {
readVisibleMessageDelta.mockReturnValue({
kind: "page",
cursor,
events: messages.map(visibleMessageEvent),
hasMore: false,
serializedBytes: 100,
});
};
}
export function writeWorkspaceFile(root: string, filePath: string, content: string): void {
const resolved = path.join(root, filePath);
fs.mkdirSync(path.dirname(resolved), { recursive: true });
fs.writeFileSync(resolved, content, "utf8");
}
export function createWorkspaceFixture(prefix: string): string {
const tempRoot = fs.realpathSync(os.tmpdir());
const workspaceRoot = fs.mkdtempSync(path.join(tempRoot, prefix));
writeWorkspaceFile(workspaceRoot, "package.json", '{"name":"openclaw-test"}\n');
writeWorkspaceFile(workspaceRoot, "src/readme.md", "# Read me\n");
writeWorkspaceFile(workspaceRoot, "ui/chat.ts", "export const chat = true;\n");
writeWorkspaceFile(workspaceRoot, "ui/vite.config.ts", "export default {};\n");
return workspaceRoot;
}
export function hashContent(content: string): string {
return createHash("sha256").update(content, "utf8").digest("hex");
}
export function createSessionEntryFixture(
workspaceRoot: string,
sessionId: string,
storePath = path.join(workspaceRoot, ".sessions.json"),
) {
return {
canonicalKey: "agent:main:main",
cfg: {},
storePath,
entry: {
sessionId,
sessionFile: `${sessionId}.jsonl`,
spawnedCwd: workspaceRoot,
},
};
}
export function useSqliteSession(
loadSessionEntry: ReturnValueMock,
workspaceRoot: string,
sessionId: string,
storePath = path.join(workspaceRoot, `${sessionId}.sqlite`),
): string {
loadSessionEntry.mockReturnValue({
canonicalKey: "agent:main:main",
cfg: {},
storePath,
entry: {
sessionId,
sessionFile: `sqlite:main:${sessionId}:${storePath}`,
spawnedCwd: workspaceRoot,
},
});
return storePath;
}

View File

@@ -1,13 +1,22 @@
import { execFileSync } from "node:child_process";
// Session file method tests cover transcript-linked files plus the workspace browser.
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveOpenPathCommand } from "./open-path.js";
import { sessionsFilesHandlers } from "./sessions-files.js";
import {
assistantToolCall,
createSessionEntryFixture,
createSessionFilesHandlerInvoker,
createVisibleMessagesMock,
createWorkspaceFixture,
expectError,
expectOkPayload,
hashContent,
writeWorkspaceFile,
} from "./sessions-files.test-support.js";
import { updateWorkspaceFile } from "./workspace-fs.js";
const hoisted = vi.hoisted(() => ({
@@ -15,7 +24,7 @@ const hoisted = vi.hoisted(() => ({
loadSessionEntry: vi.fn(),
resolveAgentWorkspaceDir: vi.fn(),
resolveDefaultAgentId: vi.fn(),
visitSessionMessagesAsync: vi.fn(),
readSessionTranscriptVisibleMessageDelta: vi.fn(),
}));
vi.mock("./open-path.js", async () => {
@@ -43,152 +52,39 @@ vi.mock("../session-transcript-readers.js", async () => {
);
return {
...actual,
visitSessionMessagesAsync: hoisted.visitSessionMessagesAsync,
readSessionTranscriptVisibleMessageDelta: hoisted.readSessionTranscriptVisibleMessageDelta,
};
});
function createResponder() {
const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
return {
calls,
respond: (ok: boolean, payload?: unknown, error?: unknown) => {
calls.push({ ok, payload, error });
},
};
}
type SessionFilesMethod =
| "sessions.files.list"
| "sessions.files.get"
| "sessions.files.set"
| "sessions.files.reveal";
async function invokeSessionFilesHandler(
method: SessionFilesMethod,
params: Record<string, unknown>,
context: Record<string, unknown> = {},
) {
const responder = createResponder();
await sessionsFilesHandlers[method]?.({
req: { type: "req", id: method, method, params: {} },
params,
client: null,
isWebchatConnect: () => false,
respond: responder.respond,
context: context as never,
});
return responder.calls;
}
function expectOkPayload(calls: ReturnType<typeof createResponder>["calls"]): Record<string, any> {
expect(calls).toHaveLength(1);
expect(calls[0]?.ok).toBe(true);
return calls[0]?.payload as Record<string, any>;
}
function expectError(calls: ReturnType<typeof createResponder>["calls"]): Record<string, any> {
expect(calls).toHaveLength(1);
expect(calls[0]?.ok).toBe(false);
return calls[0]?.error as Record<string, any>;
}
function assistantToolCall(name: string, args: Record<string, unknown>) {
return {
role: "assistant",
content: [
{
type: "toolCall",
name,
arguments: args,
},
],
};
}
function writeWorkspaceFile(root: string, filePath: string, content: string) {
const resolved = path.join(root, filePath);
fs.mkdirSync(path.dirname(resolved), { recursive: true });
fs.writeFileSync(resolved, content, "utf8");
}
function hashContent(content: string): string {
return createHash("sha256").update(content, "utf8").digest("hex");
}
const invokeSessionFilesHandler = createSessionFilesHandlerInvoker(sessionsFilesHandlers);
const mockVisibleMessages = createVisibleMessagesMock(
hoisted.readSessionTranscriptVisibleMessageDelta,
);
describe("sessions.files RPC handlers", () => {
let workspaceRoot: string;
beforeEach(() => {
vi.clearAllMocks();
const tempRoot = fs.realpathSync(os.tmpdir());
workspaceRoot = fs.mkdtempSync(path.join(tempRoot, "openclaw-session-files-test-"));
hoisted.readSessionTranscriptVisibleMessageDelta.mockReset();
workspaceRoot = createWorkspaceFixture("openclaw-session-files-test-");
hoisted.resolveDefaultAgentId.mockReturnValue("main");
hoisted.resolveAgentWorkspaceDir.mockReturnValue(workspaceRoot);
hoisted.execOpenPath.mockResolvedValue(undefined);
writeWorkspaceFile(workspaceRoot, "package.json", '{"name":"openclaw-test"}\n');
writeWorkspaceFile(workspaceRoot, "src/readme.md", "# Read me\n");
writeWorkspaceFile(workspaceRoot, "ui/chat.ts", "export const chat = true;\n");
writeWorkspaceFile(workspaceRoot, "ui/vite.config.ts", "export default {};\n");
hoisted.loadSessionEntry.mockReturnValue({
canonicalKey: "agent:main:main",
cfg: {},
storePath: path.join(workspaceRoot, ".sessions.json"),
entry: {
sessionId: "sess-main",
sessionFile: "sess-main.jsonl",
spawnedCwd: workspaceRoot,
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
[
assistantToolCall("edit", { path: "ui/chat.ts" }),
assistantToolCall("read", { path: "src/readme.md" }),
assistantToolCall("apply_patch", {
input: "*** Begin Patch\n*** Update File: package.json\n*** End Patch\n",
}),
].forEach((message, index) => visit(message, index + 1));
return 3;
});
hoisted.loadSessionEntry.mockReturnValue(createSessionEntryFixture(workspaceRoot, "sess-main"));
mockVisibleMessages([
assistantToolCall("edit", { path: "ui/chat.ts" }),
assistantToolCall("read", { path: "src/readme.md" }),
assistantToolCall("apply_patch", {
input: "*** Begin Patch\n*** Update File: package.json\n*** End Patch\n",
}),
]);
});
afterEach(() => {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
});
it("lists session-touched files with a browser rooted at the session workspace", async () => {
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.root).toBe(workspaceRoot);
expect(payload.gitCheckout).toBe(false);
expect(payload.files.map((file: Record<string, unknown>) => [file.path, file.kind])).toEqual([
["package.json", "modified"],
["ui/chat.ts", "modified"],
["src/readme.md", "read"],
]);
expect(payload.browser.path).toBe("");
expect(
payload.browser.entries.map((entry: Record<string, unknown>) => [
entry.path,
entry.kind,
entry.sessionKind,
]),
).toEqual([
["src", "directory", "read"],
["ui", "directory", "modified"],
["package.json", "file", "modified"],
]);
execFileSync("git", ["-C", workspaceRoot, "init", "-q", "-b", "main"]);
const gitPayload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", { sessionKey: "agent:main:main" }),
);
expect(gitPayload.gitCheckout).toBe(true);
});
it("reveals the same workspace root returned by sessions.files.list", async () => {
const listPayload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
@@ -288,72 +184,6 @@ describe("sessions.files RPC handlers", () => {
);
});
it("collects touched files from existing transcript tool-call spellings", async () => {
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(
{
role: "assistant",
content: [
{ type: "tool_use", name: "read", input: { path: "src/readme.md" } },
{ type: "toolcall", name: "edit", arguments: { path: "ui/vite.config.ts" } },
{ type: "tool_use", name: "read", args: { path: "ui/chat.ts" } },
{
type: "tool_call",
name: "apply_patch",
input: {
input: "*** Begin Patch\n*** Update File: package.json\n*** End Patch\n",
},
},
],
},
1,
);
return 1;
});
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.files.map((file: Record<string, unknown>) => [file.path, file.kind])).toEqual([
["package.json", "modified"],
["ui/vite.config.ts", "modified"],
["src/readme.md", "read"],
["ui/chat.ts", "read"],
]);
});
it("collects changed files from structured apply_patch changes", async () => {
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(
assistantToolCall("apply_patch", {
changes: [
{ path: "ui/chat.ts", kind: "update" },
{ path: "src/readme.md", kind: "delete" },
{ path: "old-name.md", kind: { type: "update", move_path: "package.json" } },
],
}),
1,
);
return 1;
});
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.files.map((file: Record<string, unknown>) => [file.path, file.kind])).toEqual([
["old-name.md", "modified"],
["package.json", "modified"],
["src/readme.md", "modified"],
["ui/chat.ts", "modified"],
]);
});
it("prefers the spawned workspace root over a nested spawned cwd", async () => {
const nestedCwd = path.join(workspaceRoot, "packages/app");
fs.mkdirSync(nestedCwd, { recursive: true });
@@ -370,11 +200,10 @@ describe("sessions.files RPC handlers", () => {
spawnedWorkspaceDir: workspaceRoot,
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
visit(assistantToolCall("read", { path: "../shared/config.ts" }), 2);
return 2;
});
mockVisibleMessages([
assistantToolCall("read", { path: "src/readme.md" }),
assistantToolCall("read", { path: "../shared/config.ts" }),
]);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
@@ -467,10 +296,7 @@ describe("sessions.files RPC handlers", () => {
sessionFile: "sess-main.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
return 1;
});
mockVisibleMessages([assistantToolCall("read", { path: "src/readme.md" })]);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
@@ -499,10 +325,7 @@ describe("sessions.files RPC handlers", () => {
sessionFile: "sess-main.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
return 1;
});
mockVisibleMessages([assistantToolCall("read", { path: "src/readme.md" })]);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
@@ -600,10 +423,7 @@ describe("sessions.files RPC handlers", () => {
sessionFile: "missing-session.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: outsidePath }), 1);
return 1;
});
mockVisibleMessages([assistantToolCall("read", { path: outsidePath })]);
try {
for (const requestedPath of [outsidePath, "../outside.txt"]) {
@@ -624,49 +444,6 @@ describe("sessions.files RPC handlers", () => {
}
});
it("omits transcript paths outside the workspace without hiding missing workspace files", async () => {
const outsidePath = path.join(os.tmpdir(), `openclaw-outside-list-${Date.now()}.txt`);
fs.writeFileSync(outsidePath, "outside\n", "utf8");
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
[
outsidePath,
"../outside.txt",
"~/.openclaw-external.txt",
pathToFileURL(outsidePath).href,
`@${outsidePath}`,
"..cache/missing.txt",
"missing.txt",
"src/readme.md",
].forEach((filePath, index) =>
visit(assistantToolCall("read", { path: filePath }), index + 1),
);
return 8;
});
try {
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.files).toHaveLength(3);
expect(payload.files).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "..cache/missing.txt", missing: true }),
expect.objectContaining({ path: "missing.txt", missing: true }),
expect.objectContaining({
path: "src/readme.md",
missing: false,
workspacePath: "src/readme.md",
}),
]),
);
} finally {
fs.rmSync(outsidePath, { force: true });
}
});
it("does not follow workspace symlinks for file previews", async () => {
const outsidePath = path.join(os.tmpdir(), `openclaw-linked-${Date.now()}.txt`);
fs.writeFileSync(outsidePath, "linked outside\n", "utf8");
@@ -759,10 +536,7 @@ describe("sessions.files RPC handlers", () => {
sessionFile: "sess-main.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: "secret.txt" }), 1);
return 1;
});
mockVisibleMessages([assistantToolCall("read", { path: "secret.txt" })]);
try {
const listPayload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
@@ -796,10 +570,7 @@ describe("sessions.files RPC handlers", () => {
it("reports oversized existing files without marking them missing", async () => {
writeWorkspaceFile(workspaceRoot, "large.log", "x".repeat(260 * 1024));
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: "large.log" }), 1);
return 1;
});
mockVisibleMessages([assistantToolCall("read", { path: "large.log" })]);
const error = expectError(
await invokeSessionFilesHandler("sessions.files.get", {
@@ -841,7 +612,6 @@ describe("sessions.files RPC handlers", () => {
});
expect(Number.isInteger(payload.file.updatedAtMs)).toBe(true);
expect(payload.file.content).toBeUndefined();
expect(hoisted.visitSessionMessagesAsync).not.toHaveBeenCalled();
});
it("rejects a stale file hash with the current hash", async () => {

View File

@@ -0,0 +1,376 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sessionsFilesHandlers } from "./sessions-files.js";
import {
assistantToolCall,
createSessionFilesHandlerInvoker,
createVisibleMessagesMock,
createWorkspaceFixture,
expectOkPayload,
useSqliteSession,
visibleMessageEvent,
} from "./sessions-files.test-support.js";
const hoisted = vi.hoisted(() => ({
loadSessionEntry: vi.fn(),
resolveAgentWorkspaceDir: vi.fn(),
resolveDefaultAgentId: vi.fn(),
readSessionTranscriptVisibleMessageDelta: vi.fn(),
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: hoisted.resolveAgentWorkspaceDir,
resolveDefaultAgentId: hoisted.resolveDefaultAgentId,
}));
vi.mock("../session-utils.js", async () => {
const actual = await vi.importActual<typeof import("../session-utils.js")>("../session-utils.js");
return {
...actual,
loadSessionEntry: hoisted.loadSessionEntry,
loadSessionEntryReadOnly: hoisted.loadSessionEntry,
};
});
vi.mock("../session-transcript-readers.js", async () => {
const actual = await vi.importActual<typeof import("../session-transcript-readers.js")>(
"../session-transcript-readers.js",
);
return {
...actual,
readSessionTranscriptVisibleMessageDelta: hoisted.readSessionTranscriptVisibleMessageDelta,
};
});
const invokeSessionFilesHandler = createSessionFilesHandlerInvoker(sessionsFilesHandlers);
const mockVisibleMessages = createVisibleMessagesMock(
hoisted.readSessionTranscriptVisibleMessageDelta,
);
describe("sessions.files touched-file folds", () => {
let workspaceRoot: string;
beforeEach(() => {
vi.clearAllMocks();
hoisted.readSessionTranscriptVisibleMessageDelta.mockReset();
workspaceRoot = createWorkspaceFixture("openclaw-session-touched-files-test-");
hoisted.resolveDefaultAgentId.mockReturnValue("main");
hoisted.resolveAgentWorkspaceDir.mockReturnValue(workspaceRoot);
});
afterEach(() => {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
});
it("lists session-touched files with a browser rooted at the session workspace", async () => {
useSqliteSession(hoisted.loadSessionEntry, workspaceRoot, "sess-touched-list");
mockVisibleMessages([
assistantToolCall("edit", { path: "ui/chat.ts" }),
assistantToolCall("read", { path: "src/readme.md" }),
assistantToolCall("apply_patch", {
input: "*** Begin Patch\n*** Update File: package.json\n*** End Patch\n",
}),
]);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.root).toBe(workspaceRoot);
expect(payload.gitCheckout).toBe(false);
expect(payload.files.map((file: Record<string, unknown>) => [file.path, file.kind])).toEqual([
["package.json", "modified"],
["ui/chat.ts", "modified"],
["src/readme.md", "read"],
]);
expect(payload.browser.path).toBe("");
expect(
payload.browser.entries.map((entry: Record<string, unknown>) => [
entry.path,
entry.kind,
entry.sessionKind,
]),
).toEqual([
["src", "directory", "read"],
["ui", "directory", "modified"],
["package.json", "file", "modified"],
]);
execFileSync("git", ["-C", workspaceRoot, "init", "-q", "-b", "main"]);
const gitPayload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", { sessionKey: "agent:main:main" }),
);
expect(gitPayload.gitCheckout).toBe(true);
});
it("folds only appended SQLite messages after the cached cursor", async () => {
useSqliteSession(hoisted.loadSessionEntry, workspaceRoot, "sess-touched-incremental");
hoisted.readSessionTranscriptVisibleMessageDelta.mockImplementation((_scope, limits) => {
if (limits.cursor === undefined) {
return {
kind: "page",
cursor: "cursor-1",
events: [visibleMessageEvent(assistantToolCall("read", { path: "ui/chat.ts" }), 1)],
hasMore: false,
serializedBytes: 100,
};
}
if (limits.cursor === "cursor-1") {
return {
kind: "page",
cursor: "cursor-2",
events: [visibleMessageEvent(assistantToolCall("edit", { path: "ui/chat.ts" }), 2)],
hasMore: false,
serializedBytes: 100,
};
}
throw new Error(`unexpected cursor: ${String(limits.cursor)}`);
});
const first = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
const second = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(first.files).toEqual([expect.objectContaining({ path: "ui/chat.ts", kind: "read" })]);
expect(second.files).toEqual([
expect.objectContaining({ path: "ui/chat.ts", kind: "modified" }),
]);
expect(hoisted.readSessionTranscriptVisibleMessageDelta).toHaveBeenCalledTimes(2);
expect(hoisted.readSessionTranscriptVisibleMessageDelta.mock.calls[1]?.[1]).toMatchObject({
cursor: "cursor-1",
});
});
it("isolates touched-file folds for the same session across stores", async () => {
const sessionId = "sess-touched-multi-store";
const firstStorePath = path.join(workspaceRoot, "store-a.sqlite");
const secondStorePath = path.join(workspaceRoot, "store-b.sqlite");
hoisted.readSessionTranscriptVisibleMessageDelta.mockImplementation((scope, limits) => {
expect(limits.cursor).toBeUndefined();
const message =
scope.storePath === firstStorePath
? assistantToolCall("read", { path: "src/readme.md" })
: assistantToolCall("edit", { path: "ui/chat.ts" });
return {
kind: "page",
cursor: `${scope.storePath}-final`,
events: [visibleMessageEvent(message, 1)],
hasMore: false,
serializedBytes: 100,
};
});
useSqliteSession(hoisted.loadSessionEntry, workspaceRoot, sessionId, firstStorePath);
const first = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
useSqliteSession(hoisted.loadSessionEntry, workspaceRoot, sessionId, secondStorePath);
const second = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(first.files).toEqual([expect.objectContaining({ path: "src/readme.md", kind: "read" })]);
expect(second.files).toEqual([
expect.objectContaining({ path: "ui/chat.ts", kind: "modified" }),
]);
});
it("rebuilds the SQLite fold from the bootstrap cursor after a reset", async () => {
useSqliteSession(hoisted.loadSessionEntry, workspaceRoot, "sess-touched-reset");
hoisted.readSessionTranscriptVisibleMessageDelta.mockImplementation((_scope, limits) => {
if (limits.cursor === undefined) {
return {
kind: "page",
cursor: "generation-1",
events: [visibleMessageEvent(assistantToolCall("read", { path: "src/readme.md" }), 1)],
hasMore: false,
serializedBytes: 100,
};
}
if (limits.cursor === "generation-1") {
return {
kind: "reset",
cursor: "generation-2-bootstrap",
reason: "generation_mismatch",
};
}
if (limits.cursor === "generation-2-bootstrap") {
return {
kind: "page",
cursor: "generation-2-final",
events: [visibleMessageEvent(assistantToolCall("edit", { path: "ui/chat.ts" }), 1)],
hasMore: false,
serializedBytes: 100,
};
}
throw new Error(`unexpected cursor: ${String(limits.cursor)}`);
});
const beforeReset = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
const afterReset = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(beforeReset.files).toEqual([
expect.objectContaining({ path: "src/readme.md", kind: "read" }),
]);
expect(afterReset.files).toEqual([
expect.objectContaining({ path: "ui/chat.ts", kind: "modified" }),
]);
expect(hoisted.readSessionTranscriptVisibleMessageDelta).toHaveBeenCalledTimes(3);
});
it("collects the expected files and kinds from the SQLite fold", async () => {
const messages = [
assistantToolCall("read", { path: "ui/chat.ts" }),
assistantToolCall("edit", { path: "ui/chat.ts" }),
assistantToolCall("read", { path: "src/readme.md" }),
assistantToolCall("apply_patch", {
input: "*** Begin Patch\n*** Update File: package.json\n*** End Patch\n",
}),
];
useSqliteSession(hoisted.loadSessionEntry, workspaceRoot, "sess-touched-parity");
hoisted.readSessionTranscriptVisibleMessageDelta.mockReturnValue({
kind: "page",
cursor: "parity-final",
events: messages.map(visibleMessageEvent),
hasMore: false,
serializedBytes: 400,
});
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.files.map((file: Record<string, unknown>) => [file.path, file.kind])).toEqual([
["package.json", "modified"],
["ui/chat.ts", "modified"],
["src/readme.md", "read"],
]);
});
it("collects touched files from existing transcript tool-call spellings", async () => {
useSqliteSession(hoisted.loadSessionEntry, workspaceRoot, "sess-touched-spellings");
mockVisibleMessages([
{
role: "assistant",
content: [
{ type: "tool_use", name: "read", input: { path: "src/readme.md" } },
{ type: "toolcall", name: "edit", arguments: { path: "ui/vite.config.ts" } },
{ type: "tool_use", name: "read", args: { path: "ui/chat.ts" } },
{
type: "tool_call",
name: "apply_patch",
input: {
input: "*** Begin Patch\n*** Update File: package.json\n*** End Patch\n",
},
},
],
},
]);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.files.map((file: Record<string, unknown>) => [file.path, file.kind])).toEqual([
["package.json", "modified"],
["ui/vite.config.ts", "modified"],
["src/readme.md", "read"],
["ui/chat.ts", "read"],
]);
});
it("collects changed files from structured apply_patch changes", async () => {
useSqliteSession(hoisted.loadSessionEntry, workspaceRoot, "sess-touched-structured-patch");
mockVisibleMessages([
assistantToolCall("apply_patch", {
changes: [
{ path: "ui/chat.ts", kind: "update" },
{ path: "src/readme.md", kind: "delete" },
{ path: "old-name.md", kind: { type: "update", move_path: "package.json" } },
],
}),
]);
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.files.map((file: Record<string, unknown>) => [file.path, file.kind])).toEqual([
["old-name.md", "modified"],
["package.json", "modified"],
["src/readme.md", "modified"],
["ui/chat.ts", "modified"],
]);
});
it("omits transcript paths outside the workspace without hiding missing workspace files", async () => {
useSqliteSession(hoisted.loadSessionEntry, workspaceRoot, "sess-touched-outside-paths");
const outsidePath = path.join(os.tmpdir(), `${path.basename(workspaceRoot)}-outside-list.txt`);
fs.writeFileSync(outsidePath, "outside\n", "utf8");
mockVisibleMessages(
[
outsidePath,
"../outside.txt",
"~/.openclaw-external.txt",
pathToFileURL(outsidePath).href,
`@${outsidePath}`,
"..cache/missing.txt",
"missing.txt",
"src/readme.md",
].map((filePath) => assistantToolCall("read", { path: filePath })),
);
try {
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
sessionKey: "agent:main:main",
}),
);
expect(payload.files).toHaveLength(3);
expect(payload.files).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "..cache/missing.txt", missing: true }),
expect.objectContaining({ path: "missing.txt", missing: true }),
expect.objectContaining({
path: "src/readme.md",
missing: false,
workspacePath: "src/readme.md",
}),
]),
);
} finally {
fs.rmSync(outsidePath, { force: true });
}
});
});

View File

@@ -22,7 +22,13 @@ import { resolveToCwd as resolveSessionToolPathToCwd } from "../../agents/sessio
import { runGit } from "../../agents/worktrees/git.js";
import { FsSafeError } from "../../infra/fs-safe.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { visitSessionMessagesAsync } from "../session-transcript-readers.js";
import {
readSessionTranscriptVisibleMessageDelta,
resolveTranscriptReadTarget,
sqliteMessageEventWithSeq,
toTranscriptReadScope,
type SessionTranscriptReadScope,
} from "../session-transcript-readers.js";
import { loadSessionEntryReadOnly } from "../session-utils.js";
import {
execOpenPath,
@@ -64,10 +70,18 @@ type LoadedSessionFiles = {
files: TouchedFile[];
};
type TouchedFilesCacheEntry = {
cursor: string;
files: Map<string, TouchedFile>;
};
const MAX_PREVIEW_BYTES = WORKSPACE_PREVIEW_MAX_BYTES;
const MAX_BROWSER_ENTRIES = 250;
const MAX_SEARCH_ENTRIES = 500;
const MAX_SEARCH_VISITED_ENTRIES = 5_000;
const TOUCHED_FILES_CACHE_LIMIT = 16;
const TOUCHED_FILES_DELTA_MAX_MESSAGES = 1_000;
const TOUCHED_FILES_DELTA_MAX_BYTES = 1_000_000;
const SEARCH_SKIP_DIRS = new Set([
".git",
".hg",
@@ -79,6 +93,31 @@ const SEARCH_SKIP_DIRS = new Set([
"node_modules",
]);
// Request latency must not scale with transcript size: delta resets rebuild the
// fold, while this process-local LRU cap bounds retained session state.
const touchedFilesCache = new Map<string, TouchedFilesCacheEntry>();
function readTouchedFilesCache(key: string): TouchedFilesCacheEntry | undefined {
const cached = touchedFilesCache.get(key);
if (cached) {
touchedFilesCache.delete(key);
touchedFilesCache.set(key, cached);
}
return cached;
}
function writeTouchedFilesCache(key: string, entry: TouchedFilesCacheEntry): void {
touchedFilesCache.delete(key);
touchedFilesCache.set(key, entry);
while (touchedFilesCache.size > TOUCHED_FILES_CACHE_LIMIT) {
const oldestKey = touchedFilesCache.keys().next().value;
if (oldestKey === undefined) {
break;
}
touchedFilesCache.delete(oldestKey);
}
}
function sessionFilesError(type: string, message: string, details?: Record<string, unknown>) {
return errorShape(ErrorCodes.INVALID_REQUEST, message, {
details: {
@@ -191,6 +230,50 @@ function collectTouchedFilesFromMessage(message: unknown, files: Map<string, Tou
}
}
function loadSqliteTouchedFiles(
scope: SessionTranscriptReadScope,
cacheKey: string,
): Map<string, TouchedFile> {
let cached = readTouchedFilesCache(cacheKey);
let cursor = cached?.cursor;
let files = cached?.files ?? new Map<string, TouchedFile>();
let maxBytes = TOUCHED_FILES_DELTA_MAX_BYTES;
while (true) {
const delta = readSessionTranscriptVisibleMessageDelta(scope, {
...(cursor ? { cursor } : {}),
maxBytes,
maxMessages: TOUCHED_FILES_DELTA_MAX_MESSAGES,
});
if (delta.kind === "missing") {
touchedFilesCache.delete(cacheKey);
return new Map();
}
if (delta.kind === "reset") {
cached = { cursor: delta.cursor, files: new Map() };
cursor = cached.cursor;
files = cached.files;
writeTouchedFilesCache(cacheKey, cached);
continue;
}
for (const event of delta.events) {
const message = sqliteMessageEventWithSeq(event);
if (message !== undefined) {
collectTouchedFilesFromMessage(message, files);
}
}
cached = { cursor: delta.cursor, files };
cursor = cached.cursor;
writeTouchedFilesCache(cacheKey, cached);
if (!delta.hasMore) {
return files;
}
if (delta.requiredBytes !== undefined) {
maxBytes = delta.requiredBytes;
}
}
}
function toDisplayPath(root: string, resolved: string): string {
const relative = path.relative(root, resolved);
if (!relative) {
@@ -531,21 +614,19 @@ async function loadSessionFiles(params: {
if (!entry?.sessionId || !storePath || !agentId) {
return { files: [] };
}
const files = new Map<string, TouchedFile>();
await visitSessionMessagesAsync(
{
agentId,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: canonicalKey,
storePath,
},
(message) => collectTouchedFilesFromMessage(message, files),
{
mode: "full",
reason: "session files transcript scan",
cache: "reuse",
},
const scope = {
agentId,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: canonicalKey,
storePath,
} satisfies SessionTranscriptReadScope;
const target = resolveTranscriptReadTarget(scope);
// Entry-scoped reads without an explicit sessionFile always resolve to a canonical SQLite marker.
// Legacy transcript files are doctor-owned migration debt, not a runtime read path.
const files = loadSqliteTouchedFiles(
toTranscriptReadScope(target),
`${agentId}\0${entry.sessionId}\0${target.storePath ?? ""}`,
);
return {
root: loaded.root,

View File

@@ -38,6 +38,7 @@ import type { SessionPreviewItem } from "./session-utils.types.js";
export type { ReadSessionMessagesAsyncOptions };
export { attachOpenClawTranscriptMeta, capArrayByJsonBytes } from "./session-utils.fs.js";
export { readSessionTranscriptVisibleMessageDelta } from "../config/sessions/session-accessor.js";
export type { SessionTranscriptReadScope };