refactor: route transcript readers through facade

This commit is contained in:
Josh Lehman
2026-05-31 18:56:09 -07:00
parent 1609dec52a
commit 7ea7ea47ef
38 changed files with 831 additions and 251 deletions
+5
View File
@@ -1365,6 +1365,8 @@ jobs:
boundary_shard: 2/4,3/4,4/4
- check_name: check-session-accessor-boundary
group: session-accessor-boundary
- check_name: check-session-transcript-reader-boundary
group: session-transcript-reader-boundary
- check_name: check-additional-extension-channels
group: extension-channels
- check_name: check-additional-extension-bundled
@@ -1520,6 +1522,9 @@ jobs:
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
fi
;;
session-transcript-reader-boundary)
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
;;
+1
View File
@@ -1598,6 +1598,7 @@
"lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs",
"lint:tmp:no-raw-http2-imports": "node scripts/check-no-raw-http2-imports.mjs",
"lint:tmp:session-accessor-boundary": "node scripts/check-session-accessor-boundary.mjs",
"lint:tmp:session-transcript-reader-boundary": "node scripts/check-session-transcript-reader-boundary.mjs",
"lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs",
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
"lint:web-fetch-provider-boundaries": "node scripts/check-web-fetch-provider-boundaries.mjs",
@@ -0,0 +1,259 @@
#!/usr/bin/env node
import path from "node:path";
import ts from "typescript";
import {
collectFileViolations,
resolveRepoRoot,
resolveSourceRoots,
runAsScript,
toLine,
unwrapExpression,
} from "./lib/ts-guard-utils.mjs";
const legacyTranscriptReaderModules = new Set([
"../gateway/session-utils.js",
"../gateway/session-utils.fs.js",
"../../gateway/session-utils.js",
"../../gateway/session-utils.fs.js",
"./session-utils.js",
"./session-utils.fs.js",
"../session-utils.js",
"../session-utils.fs.js",
]);
const transcriptReaderNames = new Set([
"attachOpenClawTranscriptMeta",
"capArrayByJsonBytes",
"readFirstUserMessageFromTranscript",
"readLatestRecentSessionUsageFromTranscriptAsync",
"readLatestSessionUsageFromTranscript",
"readLatestSessionUsageFromTranscriptAsync",
"readRecentSessionMessages",
"readRecentSessionMessagesAsync",
"readRecentSessionMessagesWithStats",
"readRecentSessionMessagesWithStatsAsync",
"readRecentSessionTranscriptLines",
"readRecentSessionUsageFromTranscript",
"readRecentSessionUsageFromTranscriptAsync",
"readSessionMessageByIdAsync",
"readSessionMessageCount",
"readSessionMessageCountAsync",
"readSessionMessages",
"readSessionMessagesAsync",
"readSessionMessagesWithSourceAsync",
"readSessionPreviewItemsFromTranscript",
"readSessionTitleFieldsFromTranscript",
"readSessionTitleFieldsFromTranscriptAsync",
"visitSessionMessages",
"visitSessionMessagesAsync",
]);
export const migratedSessionTranscriptReaderFiles = new Set([
"src/agents/main-session-restart-recovery.ts",
"src/agents/subagent-announce-output.test.ts",
"src/agents/subagent-announce-output.ts",
"src/agents/subagent-announce.runtime.ts",
"src/agents/subagent-orphan-recovery.test.ts",
"src/agents/subagent-orphan-recovery.ts",
"src/agents/tools/embedded-gateway-stub.runtime.ts",
"src/agents/tools/embedded-gateway-stub.test.ts",
"src/agents/tools/embedded-gateway-stub.ts",
"src/agents/tools/sessions-history-tool.ts",
"src/agents/tools/sessions-list-tool.ts",
"src/gateway/cli-session-history.claude.ts",
"src/gateway/gateway-models.profiles.live.test.ts",
"src/gateway/managed-image-attachments.test.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/server-methods/artifacts.test.ts",
"src/gateway/server-methods/artifacts.ts",
"src/gateway/server-methods/chat.ts",
"src/gateway/server-methods/sessions-files.test.ts",
"src/gateway/server-methods/sessions-files.ts",
"src/gateway/server-methods/sessions.ts",
"src/gateway/server-session-events.ts",
"src/gateway/session-history-state.test.ts",
"src/gateway/session-history-state.ts",
"src/gateway/session-reset-service.ts",
"src/gateway/session-utils.ts",
"src/gateway/sessions-history-http.revocation.test.ts",
"src/gateway/sessions-history-http.ts",
"src/status/status-message.ts",
"src/tui/embedded-backend.test.ts",
"src/tui/embedded-backend.ts",
]);
function normalizeRelativePath(filePath) {
return filePath.replaceAll(path.sep, "/");
}
function importedModuleName(node) {
return node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)
? node.moduleSpecifier.text
: null;
}
function bindingName(node) {
if (node.propertyName && ts.isIdentifier(node.propertyName)) {
return node.propertyName.text;
}
if (ts.isIdentifier(node.name)) {
return node.name.text;
}
return null;
}
function destructuresLegacyNamespace(node, legacyNamespaces) {
const pattern = node.parent;
const declaration = pattern?.parent;
if (
!pattern ||
!ts.isObjectBindingPattern(pattern) ||
!declaration ||
!ts.isVariableDeclaration(declaration) ||
!declaration.initializer
) {
return false;
}
const initializer = unwrapExpression(declaration.initializer);
return ts.isIdentifier(initializer) && legacyNamespaces.has(initializer.text);
}
export function findSessionTranscriptReaderBoundaryViolations(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = [];
const legacyNamespaces = new Set();
const visit = (node) => {
if (ts.isImportDeclaration(node)) {
const moduleName = importedModuleName(node);
const namedBindings = node.importClause?.namedBindings;
if (moduleName && legacyTranscriptReaderModules.has(moduleName) && namedBindings) {
if (ts.isNamedImports(namedBindings)) {
for (const specifier of namedBindings.elements) {
const importedName = specifier.propertyName?.text ?? specifier.name.text;
if (transcriptReaderNames.has(importedName)) {
violations.push({
line: toLine(sourceFile, specifier),
reason: `imports transcript reader "${importedName}" from legacy module "${moduleName}"`,
});
}
}
} else if (ts.isNamespaceImport(namedBindings)) {
legacyNamespaces.add(namedBindings.name.text);
}
}
}
if (ts.isExportDeclaration(node)) {
const moduleName = importedModuleName(node);
if (moduleName && legacyTranscriptReaderModules.has(moduleName)) {
const exportClause = node.exportClause;
if (!exportClause) {
violations.push({
line: toLine(sourceFile, node),
reason: `re-exports transcript readers from legacy module "${moduleName}"`,
});
} else if (ts.isNamedExports(exportClause)) {
for (const specifier of exportClause.elements) {
const exportedName = specifier.propertyName?.text ?? specifier.name.text;
if (transcriptReaderNames.has(exportedName)) {
violations.push({
line: toLine(sourceFile, specifier),
reason: `re-exports transcript reader "${exportedName}" from legacy module "${moduleName}"`,
});
}
}
} else if (ts.isNamespaceExport(exportClause)) {
violations.push({
line: toLine(sourceFile, exportClause),
reason: `re-exports transcript reader namespace from legacy module "${moduleName}"`,
});
}
}
}
if (ts.isBindingElement(node)) {
const name = bindingName(node);
if (
name &&
transcriptReaderNames.has(name) &&
destructuresLegacyNamespace(node, legacyNamespaces)
) {
violations.push({
line: toLine(sourceFile, node),
reason: `aliases legacy transcript reader "${name}"`,
});
}
}
if (ts.isPropertyAccessExpression(node)) {
const receiver = unwrapExpression(node.expression);
if (
ts.isIdentifier(receiver) &&
legacyNamespaces.has(receiver.text) &&
transcriptReaderNames.has(node.name.text)
) {
violations.push({
line: toLine(sourceFile, node.name),
reason: `references legacy transcript reader "${node.name.text}"`,
});
}
}
if (
ts.isElementAccessExpression(node) &&
ts.isIdentifier(unwrapExpression(node.expression)) &&
legacyNamespaces.has(unwrapExpression(node.expression).text) &&
ts.isStringLiteral(node.argumentExpression) &&
transcriptReaderNames.has(node.argumentExpression.text)
) {
violations.push({
line: toLine(sourceFile, node.argumentExpression),
reason: `references legacy transcript reader "${node.argumentExpression.text}"`,
});
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
export async function main() {
const repoRoot = resolveRepoRoot(import.meta.url);
const sourceRoots = resolveSourceRoots(repoRoot, [
"src/agents",
"src/gateway",
"src/status",
"src/tui",
]);
const violations = await collectFileViolations({
repoRoot,
sourceRoots,
includeTests: true,
skipFile: (filePath) =>
!migratedSessionTranscriptReaderFiles.has(
normalizeRelativePath(path.relative(repoRoot, filePath)),
),
findViolations: findSessionTranscriptReaderBoundaryViolations,
});
if (violations.length === 0) {
console.log("session transcript reader boundary guard passed.");
return;
}
console.error("Found legacy transcript reader usage in migrated files:");
for (const violation of violations) {
console.error(`- ${violation.path}:${violation.line}: ${violation.reason}`);
}
console.error(
"Use src/gateway/session-transcript-readers.ts for migrated transcript reader paths. Expand this ratchet only after a slice migrates more files.",
);
process.exit(1);
}
runAsScript(import.meta.url, main);
+1
View File
@@ -124,6 +124,7 @@ export async function collectTypeScriptFilesFromRoots(sourceRoots, options = {})
*/
export async function collectFileViolations(params) {
const files = await collectTypeScriptFilesFromRoots(params.sourceRoots, {
includeTests: params.includeTests,
extraTestSuffixes: params.extraTestSuffixes,
});
+13 -5
View File
@@ -19,7 +19,7 @@ import {
} from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway } from "../gateway/call.js";
import { readSessionMessagesAsync } from "../gateway/session-utils.fs.js";
import { readSessionMessagesAsync } from "../gateway/session-transcript-readers.js";
import { resolveGatewaySessionStoreTarget } from "../gateway/session-utils.js";
import {
getAgentEventLifecycleGeneration,
@@ -27,7 +27,12 @@ import {
} from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { CommandLane } from "../process/lanes.js";
import { isAcpSessionKey, isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js";
import {
isAcpSessionKey,
isCronSessionKey,
isSubagentSessionKey,
resolveAgentIdFromSessionKey,
} from "../routing/session-key.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
import {
deliveryContextFromSession,
@@ -785,9 +790,12 @@ async function recoverStore(params: {
let messages: unknown[];
try {
messages = await readSessionMessagesAsync(
entry.sessionId,
params.storePath,
entry.sessionFile,
{
agentId: resolveAgentIdFromSessionKey(sessionKey),
sessionFile: entry.sessionFile,
sessionId: entry.sessionId,
storePath: params.storePath,
},
{
mode: "recent",
maxMessages: 20,
+4 -3
View File
@@ -216,9 +216,10 @@ describe("readSubagentOutput", () => {
}),
).resolves.toBe("fresh recovered output");
expect(deps.readSessionMessagesAsync).toHaveBeenCalledWith(
"agent:main:subagent:child",
undefined,
"/tmp/openclaw-internal-run.jsonl",
{
sessionFile: "/tmp/openclaw-internal-run.jsonl",
sessionId: "agent:main:subagent:child",
},
{ mode: "recent", maxMessages: 100, maxBytes: 1024 * 1024 },
);
expect(deps.callGateway).not.toHaveBeenCalled();
+4 -3
View File
@@ -207,9 +207,10 @@ export async function readSubagentOutput(
let messages: unknown[] | undefined;
if (options?.sessionFile) {
const transcriptMessages = await subagentAnnounceOutputDeps.readSessionMessagesAsync(
sessionKey,
undefined,
options.sessionFile,
{
sessionFile: options.sessionFile,
sessionId: sessionKey,
},
{
mode: "recent",
maxMessages: 100,
+1 -1
View File
@@ -12,7 +12,7 @@ export {
resolveStorePath,
} from "../config/sessions.js";
export { callGateway } from "../gateway/call.js";
export { readSessionMessagesAsync } from "../gateway/session-utils.fs.js";
export { readSessionMessagesAsync } from "../gateway/session-transcript-readers.js";
export { dispatchGatewayMethodInProcess } from "../gateway/server-plugins.js";
export {
isEmbeddedAgentRunActive,
+2 -2
View File
@@ -3,7 +3,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as sessions from "../config/sessions.js";
import * as gateway from "../gateway/call.js";
import * as sessionUtils from "../gateway/session-utils.fs.js";
import * as sessionUtils from "../gateway/session-transcript-readers.js";
import { resolveInternalSessionEffectsTranscriptPath } from "./internal-session-effects.js";
import * as announceDelivery from "./subagent-announce-delivery.js";
import {
@@ -32,7 +32,7 @@ vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async () => ({ runId: "test-run-id" })),
}));
vi.mock("../gateway/session-utils.fs.js", () => ({
vi.mock("../gateway/session-transcript-readers.js", () => ({
readSessionMessagesAsync: vi.fn(async () => []),
}));
+7 -4
View File
@@ -20,7 +20,7 @@ import {
type SessionEntry,
} from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import { readSessionMessagesAsync } from "../gateway/session-utils.fs.js";
import { readSessionMessagesAsync } from "../gateway/session-transcript-readers.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveInternalSessionEffectsTranscriptPath } from "./internal-session-effects.js";
@@ -294,9 +294,12 @@ export async function recoverOrphanedSubagentSessions(params: {
log.info(`found orphaned subagent session: ${childSessionKey} (run=${runId})`);
const messages = await readSessionMessagesAsync(
entry.sessionId,
storePath,
entry.sessionFile,
{
agentId: resolveAgentIdFromSessionKey(childSessionKey),
sessionFile: entry.sessionFile,
sessionId: entry.sessionId,
storePath,
},
{
mode: "recent",
maxMessages: 200,
@@ -18,12 +18,14 @@ export {
enforceChatHistoryFinalBudget,
replaceOversizedChatHistoryMessages,
} from "../../gateway/server-methods/chat.js";
export { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js";
export {
capArrayByJsonBytes,
readSessionMessagesAsync,
} from "../../gateway/session-transcript-readers.js";
export {
listSessionsFromStoreAsync,
loadCombinedSessionStoreForGateway,
loadSessionEntry,
readSessionMessagesAsync,
resolveSessionModelRef,
} from "../../gateway/session-utils.js";
export { resolveSessionKeyFromResolveParams } from "../../gateway/sessions-resolve.js";
+18 -9
View File
@@ -124,9 +124,12 @@ describe("embedded gateway stub", () => {
maxMessages: 200,
});
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
"sess-main",
"/tmp/openclaw-sessions.json",
undefined,
{
agentId: "main",
sessionFile: undefined,
sessionId: "sess-main",
storePath: "/tmp/openclaw-sessions.json",
},
{
mode: "recent",
maxMessages: 200,
@@ -187,9 +190,12 @@ describe("embedded gateway stub", () => {
maxMessages: 1,
});
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
"sess-main",
"/tmp/openclaw-sessions.json",
undefined,
{
agentId: "main",
sessionFile: undefined,
sessionId: "sess-main",
storePath: "/tmp/openclaw-sessions.json",
},
{
mode: "recent",
maxMessages: 1,
@@ -217,9 +223,12 @@ describe("embedded gateway stub", () => {
maxMessages: 2,
});
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
"sess-main",
"/tmp/openclaw-sessions.json",
undefined,
{
agentId: "main",
sessionFile: undefined,
sessionId: "sess-main",
storePath: "/tmp/openclaw-sessions.json",
},
{
mode: "recent",
maxMessages: 2,
+11 -7
View File
@@ -9,7 +9,10 @@ import type {
} from "../../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { CallGatewayOptions } from "../../gateway/call.js";
import type { ReadSessionMessagesAsyncOptions } from "../../gateway/session-utils.fs.js";
import type {
ReadSessionMessagesAsyncOptions,
SessionTranscriptReadScope,
} from "../../gateway/session-transcript-readers.js";
import type { SessionsListResult } from "../../gateway/session-utils.types.js";
import type { SessionsResolveResult } from "../../gateway/sessions-resolve.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
@@ -71,9 +74,7 @@ interface EmbeddedGatewayRuntime {
entry: Record<string, unknown> | undefined;
};
readSessionMessagesAsync: (
sessionId: string,
storePath: string,
sessionFile: string | undefined,
scope: SessionTranscriptReadScope,
opts: ReadSessionMessagesAsyncOptions,
) => Promise<unknown[]>;
resolveSessionModelRef: (
@@ -158,9 +159,12 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
const localMessages =
sessionId && storePath
? await rt.readSessionMessagesAsync(
sessionId,
storePath,
entry?.sessionFile as string | undefined,
{
agentId: sessionAgentId,
sessionFile: entry?.sessionFile as string | undefined,
sessionId,
storePath,
},
{
mode: "recent",
maxMessages: max,
+1 -1
View File
@@ -8,7 +8,7 @@ import { Type } from "typebox";
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { callGateway } from "../../gateway/call.js";
import { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js";
import { capArrayByJsonBytes } from "../../gateway/session-transcript-readers.js";
import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
import { redactToolPayloadText } from "../../logging/redact.js";
import { truncateUtf16Safe } from "../../utils.js";
+7 -9
View File
@@ -18,10 +18,8 @@ import {
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { callGateway } from "../../gateway/call.js";
import {
deriveSessionTitle,
readSessionTitleFieldsFromTranscriptAsync,
} from "../../gateway/session-utils.js";
import { readSessionTitleFieldsFromTranscriptAsync } from "../../gateway/session-transcript-readers.js";
import { deriveSessionTitle } from "../../gateway/session-utils.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js";
import {
@@ -385,12 +383,12 @@ export function createSessionsListTool(opts?: {
return;
}
const target = titleTargets[next];
const fields = await readSessionTitleFieldsFromTranscriptAsync(
target.sessionId,
const fields = await readSessionTitleFieldsFromTranscriptAsync({
agentId: target.agentId,
sessionFile: target.sessionFile,
sessionId: target.sessionId,
storePath,
target.sessionFile,
target.agentId,
);
});
if (includeDerivedTitles && !target.row.derivedTitle) {
target.row.derivedTitle = deriveSessionTitle(
target.titleEntry,
+1 -1
View File
@@ -12,7 +12,7 @@ import {
type ToolContentBlock,
} from "../chat/tool-content.js";
import type { SessionEntry } from "../config/sessions.js";
import { attachOpenClawTranscriptMeta } from "./session-utils.fs.js";
import { attachOpenClawTranscriptMeta } from "./session-transcript-readers.js";
export const CLAUDE_CLI_PROVIDER = "claude-cli";
const CLAUDE_PROJECTS_RELATIVE_DIR = path.join(".claude", "projects");
@@ -69,7 +69,8 @@ import {
shouldRetryToolReadProbe,
} from "./live-tool-probe-utils.js";
import { startGatewayServer } from "./server.impl.js";
import { loadSessionEntry, readSessionMessagesAsync } from "./session-utils.js";
import { readSessionMessagesAsync } from "./session-transcript-readers.js";
import { loadSessionEntry } from "./session-utils.js";
const ZAI_FALLBACK = isTruthyEnvValue(process.env.OPENCLAW_LIVE_GATEWAY_ZAI_FALLBACK);
const REQUIRE_PROFILE_KEYS = isLiveProfileKeyModeEnabled();
@@ -2124,10 +2125,17 @@ async function readSessionAssistantTexts(sessionKey: string, modelKey?: string):
if (!entry?.sessionId) {
return [];
}
const messages = await readSessionMessagesAsync(entry.sessionId, storePath, entry.sessionFile, {
mode: "full",
reason: "live model assistant text verification",
});
const messages = await readSessionMessagesAsync(
{
sessionFile: entry.sessionFile,
sessionId: entry.sessionId,
storePath,
},
{
mode: "full",
reason: "live model assistant text verification",
},
);
const assistantTexts: string[] = [];
for (const message of messages) {
if (!message || typeof message !== "object") {
+16 -7
View File
@@ -34,12 +34,15 @@ vi.mock("./http-utils.js", () => ({
vi.mock("./session-utils.js", () => ({
loadSessionEntry: loadSessionEntryMock,
resolveSessionHistoryTranscriptPathAsync: resolveSessionHistoryTranscriptPathMock,
}));
vi.mock("./session-transcript-readers.js", () => ({
readSessionMessagesAsync: readSessionMessagesMock,
readSessionMessagesWithSourceAsync: async (...args: unknown[]) => ({
messages: await readSessionMessagesMock(...args),
transcriptPath: await resolveSessionHistoryTranscriptPathMock(...args),
}),
resolveSessionHistoryTranscriptPathAsync: resolveSessionHistoryTranscriptPathMock,
}));
const {
@@ -287,9 +290,12 @@ describe("handleManagedOutgoingImageHttpRequest", () => {
expect(result.headers["content-disposition"]).toContain("inline");
expect(result.body.toString("utf-8")).toBe("original-image");
expect(readSessionMessagesMock).toHaveBeenCalledWith(
"sess-1",
path.join(stateDir, "gateway-sessions.json"),
"session.jsonl",
{
agentId: undefined,
sessionFile: "session.jsonl",
sessionId: "sess-1",
storePath: path.join(stateDir, "gateway-sessions.json"),
},
expect.objectContaining({ allowResetArchiveFallback: true }),
);
});
@@ -1056,9 +1062,12 @@ describe("cleanupManagedOutgoingImageRecords", () => {
expect(result.retainedCount).toBe(1);
await expect(fs.access(fixture.originalPath)).resolves.toBeUndefined();
expect(readSessionMessagesMock).toHaveBeenCalledWith(
"sess-main",
path.join(stateDir, "gateway-sessions.json"),
"/tmp/sess-main.jsonl",
{
agentId: undefined,
sessionFile: "/tmp/sess-main.jsonl",
sessionId: "sess-main",
storePath: path.join(stateDir, "gateway-sessions.json"),
},
expect.objectContaining({ allowResetArchiveFallback: true }),
);
});
+8 -8
View File
@@ -28,11 +28,8 @@ import {
resolveOpenAiCompatibleHttpSenderIsOwner,
} from "./http-utils.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
import {
loadSessionEntry,
readSessionMessagesWithSourceAsync,
resolveSessionHistoryTranscriptPathAsync,
} from "./session-utils.js";
import { readSessionMessagesWithSourceAsync } from "./session-transcript-readers.js";
import { loadSessionEntry, resolveSessionHistoryTranscriptPathAsync } from "./session-utils.js";
const OUTGOING_IMAGE_ROUTE_PREFIX = "/api/chat/media/outgoing";
const DEFAULT_TRANSIENT_OUTGOING_IMAGE_TTL_MS = 15 * 60 * 1000;
@@ -732,9 +729,12 @@ async function getSessionManagedOutgoingAttachmentIndex(
}
const readResult = await readSessionMessagesWithSourceAsync(
sessionId,
storePath,
entry.sessionFile,
{
agentId,
sessionFile: entry.sessionFile,
sessionId,
storePath,
},
{
mode: "full",
reason: "managed outgoing attachment index",
+19 -9
View File
@@ -20,6 +20,15 @@ vi.mock("../session-utils.js", async () => {
return {
...actual,
loadSessionEntry: 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,
visitSessionMessagesAsync: hoisted.visitSessionMessagesAsync,
};
});
@@ -207,12 +216,10 @@ describe("artifacts RPC handlers", () => {
});
function mockedMessages(messages: unknown[]) {
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
messages.forEach((message, index) => visit(message, index + 1));
return messages.length;
},
);
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
messages.forEach((message, index) => visit(message, index + 1));
return messages.length;
});
}
it("lists stable transcript artifact summaries by sessionKey", async () => {
@@ -235,9 +242,12 @@ describe("artifacts RPC handlers", () => {
expect(artifact?.id).toMatch(/^artifact_/);
expect(artifact).not.toHaveProperty("data");
expect(hoisted.visitSessionMessagesAsync).toHaveBeenCalledWith(
"sess-main",
"/tmp/sessions.json",
"/tmp/sess-main.jsonl",
{
agentId: "main",
sessionFile: "/tmp/sess-main.jsonl",
sessionId: "sess-main",
storePath: "/tmp/sessions.json",
},
expect.any(Function),
expect.objectContaining({ cache: "skip" }),
);
+8 -4
View File
@@ -27,7 +27,8 @@ import {
resolveSessionStoreKey,
resolveStoredSessionKeyForAgentStore,
} from "../session-store-key.js";
import { loadSessionEntry, visitSessionMessagesAsync } from "../session-utils.js";
import { visitSessionMessagesAsync } from "../session-transcript-readers.js";
import { loadSessionEntry } from "../session-utils.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
@@ -481,9 +482,12 @@ async function loadArtifacts(
}
const artifacts: ArtifactRecord[] = [];
await visitSessionMessagesAsync(
sessionId,
storePath,
entry?.sessionFile,
{
agentId: resolved.agentId ?? resolveAgentIdFromSessionKey(sessionKey),
sessionFile: entry?.sessionFile,
sessionId,
storePath,
},
(message, seq) => {
collectArtifactsFromMessage({
message,
+37 -14
View File
@@ -153,15 +153,17 @@ import { resolveSessionHistoryTailReadOptions } from "../session-history-state.j
import { readSessionTranscriptIndex } from "../session-transcript-index.fs.js";
import {
capArrayByJsonBytes,
readSessionMessageByIdAsync,
readRecentSessionMessagesAsync,
readSessionMessagesAsync,
} from "../session-transcript-readers.js";
import {
buildGatewaySessionInfo,
getSessionDefaults,
loadSessionEntry,
listAgentsForGateway,
readSessionMessageByIdAsync,
readSessionMessagesAsync,
resolveGatewayModelSupportsImages,
resolveDeletedAgentIdFromSessionKey,
readRecentSessionMessagesAsync,
resolveSessionModelRef,
resolveSessionStoreKey,
} from "../session-utils.js";
@@ -2489,6 +2491,7 @@ async function isChatMessageIdVisibleAfterHistoryFilters(params: {
sessionId: string;
storePath: string | undefined;
sessionFile: string | undefined;
agentId?: string;
messageId: string;
sessionStartedAt?: number;
allowResetArchiveFallback?: boolean;
@@ -2497,9 +2500,12 @@ async function isChatMessageIdVisibleAfterHistoryFilters(params: {
return true;
}
const messages = await readSessionMessagesAsync(
params.sessionId,
params.storePath,
params.sessionFile,
{
agentId: params.agentId,
sessionFile: params.sessionFile,
sessionId: params.sessionId,
storePath: params.storePath,
},
{
mode: "full",
reason: "chat.message.get visibility",
@@ -2613,11 +2619,19 @@ async function handleChatHistoryRequest({
};
const localMessages =
sessionId && storePath
? await readRecentSessionMessagesAsync(sessionId, storePath, entry?.sessionFile, {
...localHistoryReadOptions,
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
allowResetArchiveFallback: true,
})
? await readRecentSessionMessagesAsync(
{
agentId: sessionAgentId,
sessionFile: entry?.sessionFile,
sessionId,
storePath,
},
{
...localHistoryReadOptions,
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
allowResetArchiveFallback: true,
},
)
: [];
const overreadContextMessage =
localMessages.length > rawHistoryWindow.maxMessages ? localMessages[0] : undefined;
@@ -2793,10 +2807,18 @@ export const chatHandlers: GatewayRequestHandlers = {
return;
}
const sessionAgentId = resolveSessionAgentId({
sessionKey,
config: cfg,
agentId: selectedAgent.agentId,
});
const resolved = await readSessionMessageByIdAsync(
sessionId,
storePath,
entry?.sessionFile,
{
agentId: sessionAgentId,
sessionFile: entry?.sessionFile,
sessionId,
storePath,
},
messageId,
{ allowResetArchiveFallback: true },
);
@@ -2808,6 +2830,7 @@ export const chatHandlers: GatewayRequestHandlers = {
sessionId,
storePath,
sessionFile: entry?.sessionFile,
agentId: sessionAgentId,
messageId,
sessionStartedAt:
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
@@ -22,6 +22,15 @@ vi.mock("../session-utils.js", async () => {
return {
...actual,
loadSessionEntry: 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,
visitSessionMessagesAsync: hoisted.visitSessionMessagesAsync,
};
});
@@ -108,18 +117,16 @@ describe("sessions.files RPC handlers", () => {
spawnedCwd: workspaceRoot,
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, 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.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;
});
});
afterEach(() => {
@@ -154,29 +161,27 @@ describe("sessions.files RPC handlers", () => {
});
it("collects touched files from existing transcript tool-call spellings", async () => {
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, 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",
},
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;
},
);
},
],
},
1,
);
return 1;
});
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
@@ -193,21 +198,19 @@ describe("sessions.files RPC handlers", () => {
});
it("collects changed files from structured apply_patch changes", async () => {
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, 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;
},
);
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", {
@@ -239,13 +242,11 @@ describe("sessions.files RPC handlers", () => {
spawnedWorkspaceDir: workspaceRoot,
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
visit(assistantToolCall("read", { path: "../shared/config.ts" }), 2);
return 2;
},
);
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
visit(assistantToolCall("read", { path: "../shared/config.ts" }), 2);
return 2;
});
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
@@ -320,12 +321,10 @@ describe("sessions.files RPC handlers", () => {
sessionFile: "sess-main.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
return 1;
},
);
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
return 1;
});
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
@@ -354,12 +353,10 @@ describe("sessions.files RPC handlers", () => {
sessionFile: "sess-main.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
return 1;
},
);
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: "src/readme.md" }), 1);
return 1;
});
const payload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
@@ -454,12 +451,10 @@ describe("sessions.files RPC handlers", () => {
sessionFile: "missing-session.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: outsidePath }), 1);
return 1;
},
);
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: outsidePath }), 1);
return 1;
});
try {
const error = expectError(
@@ -570,12 +565,10 @@ describe("sessions.files RPC handlers", () => {
sessionFile: "sess-main.jsonl",
},
});
hoisted.visitSessionMessagesAsync.mockImplementation(
async (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: "secret.txt" }), 1);
return 1;
},
);
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: "secret.txt" }), 1);
return 1;
});
try {
const listPayload = expectOkPayload(
await invokeSessionFilesHandler("sessions.files.list", {
@@ -609,12 +602,10 @@ 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 (_sessionId, _storePath, _sessionFile, visit) => {
visit(assistantToolCall("read", { path: "large.log" }), 1);
return 1;
},
);
hoisted.visitSessionMessagesAsync.mockImplementation(async (_scope, visit) => {
visit(assistantToolCall("read", { path: "large.log" }), 1);
return 1;
});
const error = expectError(
await invokeSessionFilesHandler("sessions.files.get", {
+8 -4
View File
@@ -15,7 +15,8 @@ import {
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { root as fsSafeRoot, FsSafeError, type ReadResult } from "../../infra/fs-safe.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { loadSessionEntry, visitSessionMessagesAsync } from "../session-utils.js";
import { visitSessionMessagesAsync } from "../session-transcript-readers.js";
import { loadSessionEntry } from "../session-utils.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
@@ -601,9 +602,12 @@ async function loadSessionFiles(params: {
const fileRoot = resolveFileRoot({ root, spawnedCwd });
const files = new Map<string, TouchedFile>();
await visitSessionMessagesAsync(
entry.sessionId,
storePath,
entry.sessionFile,
{
agentId,
sessionFile: entry.sessionFile,
sessionId: entry.sessionId,
storePath,
},
(message) => collectTouchedFilesFromMessage(message, files),
{
mode: "full",
+30 -17
View File
@@ -94,6 +94,12 @@ import {
resolveStoredSessionOwnerAgentId,
} from "../session-store-key.js";
import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js";
import {
readRecentSessionMessagesWithStatsAsync,
readRecentSessionTranscriptLines,
readSessionMessageCountAsync,
readSessionPreviewItemsFromTranscript,
} from "../session-transcript-readers.js";
import {
archiveFileOnDisk,
buildGatewaySessionRow,
@@ -101,10 +107,6 @@ import {
loadCombinedSessionStoreForGateway,
loadSessionEntry,
migrateAndPruneGatewaySessionStoreKey,
readRecentSessionMessagesWithStatsAsync,
readRecentSessionTranscriptLines,
readSessionMessageCountAsync,
readSessionPreviewItemsFromTranscript,
resolveDeletedAgentIdFromSessionKey,
resolveFreshestSessionEntryFromStoreKeys,
resolveGatewaySessionStoreTarget,
@@ -863,7 +865,12 @@ async function handleSessionSend(params: {
}
const messageSeq =
(await readSessionMessageCountAsync(entry.sessionId, storePath, entry.sessionFile)) + 1;
(await readSessionMessageCountAsync({
agentId: requestedAgentId,
sessionFile: entry.sessionFile,
sessionId: entry.sessionId,
storePath,
})) + 1;
let sendAcked = false;
let sendPayload: unknown;
let sendCached = false;
@@ -1201,10 +1208,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
continue;
}
const items = readSessionPreviewItemsFromTranscript(
entry.sessionId,
target.storePath,
entry.sessionFile,
target.agentId,
{
agentId: target.agentId,
sessionFile: entry.sessionFile,
sessionId: entry.sessionId,
storePath: target.storePath,
},
limit,
maxChars,
);
@@ -1578,11 +1587,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
let runError: unknown;
let runMeta: Record<string, unknown> | undefined;
const messageSeq = initialMessage
? (await readSessionMessageCountAsync(
createdEntry.sessionId,
target.storePath,
createdEntry.sessionFile,
)) + 1
? (await readSessionMessageCountAsync({
agentId: target.agentId,
sessionFile: createdEntry.sessionFile,
sessionId: createdEntry.sessionId,
storePath: target.storePath,
})) + 1
: undefined;
if (initialMessage) {
@@ -2447,9 +2457,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
return;
}
const { messages } = await readRecentSessionMessagesWithStatsAsync(
entry.sessionId,
storePath,
entry.sessionFile,
{
agentId: requestedAgent.agentId,
sessionFile: entry.sessionFile,
sessionId: entry.sessionId,
storePath,
},
{
maxMessages: limit,
maxLines: limit * 20 + 20,
+9 -2
View File
@@ -18,9 +18,11 @@ import { hasTrackedActiveSessionRun } from "./server-methods/session-active-runs
import { resolveSessionKeyForTranscriptFile } from "./session-transcript-key.js";
import {
attachOpenClawTranscriptMeta,
readSessionMessageCountAsync,
} from "./session-transcript-readers.js";
import {
loadGatewaySessionRow,
loadSessionEntry,
readSessionMessageCountAsync,
type GatewaySessionRow,
} from "./session-utils.js";
@@ -181,7 +183,12 @@ async function handleTranscriptUpdateBroadcast(
const { entry, storePath } = loadSessionEntry(sessionKey, { agentId: visibleAgentId });
messageSeq = entry?.sessionId
? asPositiveSafeInteger(
await readSessionMessageCountAsync(entry.sessionId, storePath, entry.sessionFile),
await readSessionMessageCountAsync({
agentId: visibleAgentId,
sessionFile: entry.sessionFile,
sessionId: entry.sessionId,
storePath,
}),
)
: undefined;
}
+6 -4
View File
@@ -5,7 +5,7 @@ import { createHash } from "node:crypto";
import { describe, expect, test, vi } from "vitest";
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
import { buildSessionHistorySnapshot, SessionHistorySseState } from "./session-history-state.js";
import * as sessionUtils from "./session-utils.js";
import * as sessionTranscriptReaders from "./session-transcript-readers.js";
type HistorySnapshot = ReturnType<typeof buildSessionHistorySnapshot>;
type RawStateOptions = Omit<
@@ -90,7 +90,7 @@ function appendAssistantText(state: SessionHistorySseState, text: string, messag
describe("SessionHistorySseState", () => {
test("uses the initial raw snapshot for both first history and seq seeding", () => {
const readSpy = vi
.spyOn(sessionUtils, "readSessionMessagesAsync")
.spyOn(sessionTranscriptReaders, "readSessionMessagesAsync")
.mockResolvedValue([assistantTextMessage("stale disk message", 1)]);
try {
const state = newState([assistantTextMessage("fresh snapshot message", 2)]);
@@ -407,9 +407,11 @@ describe("SessionHistorySseState", () => {
});
test("refreshes limited SSE history from bounded async tail reads", async () => {
const fullReadSpy = vi.spyOn(sessionUtils, "readSessionMessagesAsync").mockResolvedValue([]);
const fullReadSpy = vi
.spyOn(sessionTranscriptReaders, "readSessionMessagesAsync")
.mockResolvedValue([]);
const tailReadSpy = vi
.spyOn(sessionUtils, "readRecentSessionMessagesWithStatsAsync")
.spyOn(sessionTranscriptReaders, "readRecentSessionMessagesWithStatsAsync")
.mockResolvedValueOnce({
messages: [assistantTextMessage("tail two", 8)],
totalMessages: 8,
+14 -7
View File
@@ -10,7 +10,7 @@ import {
attachOpenClawTranscriptMeta,
readRecentSessionMessagesWithStatsAsync,
readSessionMessagesWithSourceAsync,
} from "./session-utils.js";
} from "./session-transcript-readers.js";
// Session history state owns the SSE-friendly projection of transcript JSONL:
// raw messages are projected for display, paginated by transcript seq, then
@@ -42,6 +42,7 @@ type InlineSessionHistoryAppend = {
};
type SessionHistoryTranscriptTarget = {
agentId?: string;
sessionId: string;
storePath?: string;
sessionFile?: string;
@@ -361,9 +362,12 @@ export class SessionHistorySseState {
private async readRawSnapshotAsync(): Promise<SessionHistoryRawSnapshot> {
if (this.cursor === undefined && typeof this.limit === "number") {
const snapshot = await readRecentSessionMessagesWithStatsAsync(
this.target.sessionId,
this.target.storePath,
this.target.sessionFile,
{
agentId: this.target.agentId,
sessionFile: this.target.sessionFile,
sessionId: this.target.sessionId,
storePath: this.target.storePath,
},
{
...resolveSessionHistoryTailReadOptions(this.limit),
allowResetArchiveFallback: true,
@@ -377,9 +381,12 @@ export class SessionHistorySseState {
};
}
const snapshot = await readSessionMessagesWithSourceAsync(
this.target.sessionId,
this.target.storePath,
this.target.sessionFile,
{
agentId: this.target.agentId,
sessionFile: this.target.sessionFile,
sessionId: this.target.sessionId,
storePath: this.target.storePath,
},
{
mode: "full",
reason: "session history cursor pagination",
+13 -5
View File
@@ -64,10 +64,10 @@ import {
resolveStableSessionEndTranscript,
type ArchivedSessionTranscript,
} from "./session-transcript-files.fs.js";
import { readSessionMessagesAsync } from "./session-transcript-readers.js";
import {
loadSessionEntry,
migrateAndPruneGatewaySessionStoreKey,
readSessionMessagesAsync,
resolveGatewaySessionStoreTarget,
resolveSessionStoreKey,
resolveSessionModelRef,
@@ -806,10 +806,18 @@ export async function emitGatewayBeforeResetPluginHook(params: {
let messages: unknown[] = [];
try {
if (typeof sessionId === "string" && sessionId.trim().length > 0) {
messages = await readSessionMessagesAsync(sessionId, params.storePath, sessionFile, {
mode: "full",
reason: "before_reset hook payload",
});
messages = await readSessionMessagesAsync(
{
agentId,
sessionFile,
sessionId,
storePath: params.storePath,
},
{
mode: "full",
reason: "before_reset hook payload",
},
);
}
} catch (err) {
logVerbose(
+24 -1
View File
@@ -19,6 +19,7 @@ import {
readSessionMessageCountAsync as readSessionMessageCountAsyncFile,
readSessionMessages as readSessionMessagesFile,
readSessionMessagesAsync as readSessionMessagesAsyncFile,
readSessionMessagesWithSourceAsync as readSessionMessagesWithSourceAsyncFile,
readSessionPreviewItemsFromTranscript as readSessionPreviewItemsFromTranscriptFile,
readSessionTitleFieldsFromTranscript as readSessionTitleFieldsFromTranscriptFile,
readSessionTitleFieldsFromTranscriptAsync as readSessionTitleFieldsFromTranscriptAsyncFile,
@@ -27,6 +28,7 @@ import {
} from "./session-utils.fs.js";
export type { ReadRecentSessionMessagesOptions, ReadSessionMessagesAsyncOptions };
export { attachOpenClawTranscriptMeta, capArrayByJsonBytes } from "./session-utils.fs.js";
export type SessionTranscriptReadScope = {
agentId?: string;
@@ -42,9 +44,15 @@ type SessionTitleFields = {
type ReadRecentSessionMessagesResult = {
messages: unknown[];
transcriptPath?: string;
totalMessages: number;
};
type ReadSessionMessagesResult = {
messages: unknown[];
transcriptPath?: string;
};
type ReadSessionMessageByIdResult = {
message?: unknown;
seq?: number;
@@ -126,6 +134,20 @@ export async function readSessionMessagesAsync(
);
}
/** Reads display messages with source metadata through the reader seam. */
export async function readSessionMessagesWithSourceAsync(
scope: SessionTranscriptReadScope,
opts: ReadSessionMessagesAsyncOptions,
): Promise<ReadSessionMessagesResult> {
return await readSessionMessagesWithSourceAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
opts,
scope.agentId,
);
}
/** Reads recent display messages asynchronously through the reader seam. */
export async function readRecentSessionMessagesAsync(
scope: SessionTranscriptReadScope,
@@ -144,13 +166,14 @@ export async function readRecentSessionMessagesAsync(
export async function readSessionMessageByIdAsync(
scope: SessionTranscriptReadScope,
messageId: string,
opts?: { allowResetArchiveFallback?: boolean },
): Promise<ReadSessionMessageByIdResult> {
return await readSessionMessageByIdAsyncFile(
scope.sessionId,
scope.storePath,
scope.sessionFile,
messageId,
{ agentId: scope.agentId },
{ ...opts, agentId: scope.agentId },
);
}
+9 -5
View File
@@ -113,6 +113,10 @@ import type {
export {
archiveFileOnDisk,
archiveSessionTranscripts,
resolveSessionHistoryTranscriptPathAsync,
resolveSessionTranscriptCandidates,
} from "./session-utils.fs.js";
export {
attachOpenClawTranscriptMeta,
capArrayByJsonBytes,
readFirstUserMessageFromTranscript,
@@ -130,12 +134,12 @@ export {
readSessionPreviewItemsFromTranscript,
readSessionMessagesAsync,
readSessionMessagesWithSourceAsync,
resolveSessionHistoryTranscriptPathAsync,
visitSessionMessagesAsync,
resolveSessionTranscriptCandidates,
} from "./session-utils.fs.js";
export type { ReadSessionMessagesAsyncOptions } from "./session-utils.fs.js";
export type { SessionTranscriptReadScope } from "./session-transcript-readers.js";
} from "./session-transcript-readers.js";
export type {
ReadSessionMessagesAsyncOptions,
SessionTranscriptReadScope,
} from "./session-transcript-readers.js";
export { canonicalizeSpawnedByForAgent, resolveSessionStoreKey } from "./session-store-key.js";
export type {
GatewayAgentRow,
@@ -93,9 +93,13 @@ vi.mock("./session-utils.js", () => ({
sessionId: "session-1",
sessionFile: "/tmp/session-1.jsonl",
}),
resolveSessionTranscriptCandidates: () => ["/tmp/session-1.jsonl"],
}));
vi.mock("./session-transcript-readers.js", () => ({
readRecentSessionMessagesWithStatsAsync: async () => ({ messages: [], totalMessages: 0 }),
readSessionMessagesAsync: async () => [],
readSessionMessagesWithSourceAsync: async () => ({ messages: [] }),
resolveSessionTranscriptCandidates: () => ["/tmp/session-1.jsonl"],
}));
vi.mock("./session-history-state.js", () => ({
+15 -6
View File
@@ -34,6 +34,8 @@ import { resolveTranscriptPathForComparison } from "./session-transcript-path.js
import {
readRecentSessionMessagesWithStatsAsync,
readSessionMessagesWithSourceAsync,
} from "./session-transcript-readers.js";
import {
resolveFreshestSessionEntryFromStoreKeys,
resolveGatewaySessionStoreTarget,
resolveSessionTranscriptCandidates,
@@ -145,9 +147,12 @@ export async function handleSessionHistoryHttpRequest(
const boundedSnapshot =
cursor === undefined && typeof limit === "number"
? await readRecentSessionMessagesWithStatsAsync(
entry.sessionId,
target.storePath,
entry.sessionFile,
{
agentId: target.agentId,
sessionFile: entry.sessionFile,
sessionId: entry.sessionId,
storePath: target.storePath,
},
{
...resolveSessionHistoryTailReadOptions(limit),
allowResetArchiveFallback: true,
@@ -159,9 +164,12 @@ export async function handleSessionHistoryHttpRequest(
const fullSnapshot =
boundedSnapshot === undefined && entry?.sessionId
? await readSessionMessagesWithSourceAsync(
entry.sessionId,
target.storePath,
entry.sessionFile,
{
agentId: target.agentId,
sessionFile: entry.sessionFile,
sessionId: entry.sessionId,
storePath: target.storePath,
},
{
mode: "full",
reason: "session history cursor pagination",
@@ -204,6 +212,7 @@ export async function handleSessionHistoryHttpRequest(
let sentHistory = history;
const sseState = SessionHistorySseState.fromRawSnapshot({
target: {
agentId: target.agentId,
sessionId: entry.sessionId,
storePath: target.storePath,
sessionFile: entry.sessionFile,
+7 -5
View File
@@ -43,7 +43,7 @@ import {
} from "../config/sessions.js";
import { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { readRecentSessionUsageFromTranscript } from "../gateway/session-utils.fs.js";
import { readRecentSessionUsageFromTranscript } from "../gateway/session-transcript-readers.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { resolveCommitHash } from "../infra/git-commit.js";
import {
@@ -320,10 +320,12 @@ const readUsageFromSessionLog = (
try {
const snapshot = readRecentSessionUsageFromTranscript(
sessionId,
storePath,
sessionEntry?.sessionFile,
agentId ?? (sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined),
{
agentId: agentId ?? (sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined),
sessionFile: sessionEntry?.sessionFile,
sessionId,
storePath,
},
256 * 1024,
);
if (!snapshot) {
+9 -6
View File
@@ -176,8 +176,6 @@ vi.mock("../gateway/session-utils.js", () => ({
loadSessionEntry: (sessionKey: string, opts?: { agentId?: string }) =>
loadSessionEntryMock(sessionKey, opts),
migrateAndPruneGatewaySessionStoreKey: ({ key }: { key: string }) => ({ primaryKey: key }),
readSessionMessagesAsync: (...args: Parameters<typeof readSessionMessagesAsyncMock>) =>
readSessionMessagesAsyncMock(...args),
resolveGatewaySessionStoreTarget: ({ key }: { key: string }) => ({
canonicalKey: key,
storePath: "/tmp/openclaw-sessions.json",
@@ -193,8 +191,10 @@ vi.mock("../gateway/session-reset-service.js", () => ({
performGatewaySessionReset: () => ({ ok: true, key: "agent:main:main", entry: {} }),
}));
vi.mock("../gateway/session-utils.fs.js", () => ({
vi.mock("../gateway/session-transcript-readers.js", () => ({
capArrayByJsonBytes: (items: unknown[]) => ({ items }),
readSessionMessagesAsync: (...args: Parameters<typeof readSessionMessagesAsyncMock>) =>
readSessionMessagesAsyncMock(...args),
}));
vi.mock("../gateway/sessions-patch.js", () => ({
@@ -650,9 +650,12 @@ describe("EmbeddedTuiBackend", () => {
await backend.loadHistory({ sessionKey: "agent:main:main" });
expect(readSessionMessagesAsyncMock).toHaveBeenCalledWith(
"sess-main",
"/tmp/openclaw-sessions.json",
undefined,
{
agentId: "main",
sessionFile: undefined,
sessionId: "sess-main",
storePath: "/tmp/openclaw-sessions.json",
},
{
mode: "recent",
maxMessages: 200,
+18 -8
View File
@@ -49,7 +49,10 @@ import {
} from "../gateway/server-methods/chat.js";
import { loadGatewayModelCatalog } from "../gateway/server-model-catalog.js";
import { performGatewaySessionReset } from "../gateway/session-reset-service.js";
import { capArrayByJsonBytes } from "../gateway/session-utils.fs.js";
import {
capArrayByJsonBytes,
readSessionMessagesAsync,
} from "../gateway/session-transcript-readers.js";
import {
buildGatewaySessionInfo,
getSessionDefaults,
@@ -60,7 +63,6 @@ import {
migrateAndPruneGatewaySessionStoreKey,
resolveGatewaySessionStoreTarget,
resolveSessionModelRef,
readSessionMessagesAsync,
} from "../gateway/session-utils.js";
import { applySessionsPatchToStore } from "../gateway/sessions-patch.js";
import { type AgentEventPayload, onAgentEvent } from "../infra/agent-events.js";
@@ -445,12 +447,20 @@ export class EmbeddedTuiBackend implements TuiBackend {
const maxHistoryBytes = getMaxChatHistoryMessagesBytes();
const localMessages =
sessionId && storePath
? await readSessionMessagesAsync(sessionId, storePath, entry?.sessionFile, {
mode: "recent",
maxMessages: max,
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
allowResetArchiveFallback: true,
})
? await readSessionMessagesAsync(
{
agentId: sessionAgentId,
sessionFile: entry?.sessionFile,
sessionId,
storePath,
},
{
mode: "recent",
maxMessages: max,
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
allowResetArchiveFallback: true,
},
)
: [];
const rawMessages = augmentChatHistoryWithCliSessionImports({
entry,
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
findSessionTranscriptReaderBoundaryViolations,
migratedSessionTranscriptReaderFiles,
} from "../../scripts/check-session-transcript-reader-boundary.mjs";
describe("session transcript reader boundary guard", () => {
it("ratchets only the files migrated by the transcript reader slice", () => {
expect(migratedSessionTranscriptReaderFiles).toEqual(
new Set([
"src/agents/main-session-restart-recovery.ts",
"src/agents/subagent-announce-output.test.ts",
"src/agents/subagent-announce-output.ts",
"src/agents/subagent-announce.runtime.ts",
"src/agents/subagent-orphan-recovery.test.ts",
"src/agents/subagent-orphan-recovery.ts",
"src/agents/tools/embedded-gateway-stub.runtime.ts",
"src/agents/tools/embedded-gateway-stub.test.ts",
"src/agents/tools/embedded-gateway-stub.ts",
"src/agents/tools/sessions-history-tool.ts",
"src/agents/tools/sessions-list-tool.ts",
"src/gateway/cli-session-history.claude.ts",
"src/gateway/gateway-models.profiles.live.test.ts",
"src/gateway/managed-image-attachments.test.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/server-methods/artifacts.test.ts",
"src/gateway/server-methods/artifacts.ts",
"src/gateway/server-methods/chat.ts",
"src/gateway/server-methods/sessions-files.test.ts",
"src/gateway/server-methods/sessions-files.ts",
"src/gateway/server-methods/sessions.ts",
"src/gateway/server-session-events.ts",
"src/gateway/session-history-state.test.ts",
"src/gateway/session-history-state.ts",
"src/gateway/session-reset-service.ts",
"src/gateway/session-utils.ts",
"src/gateway/sessions-history-http.revocation.test.ts",
"src/gateway/sessions-history-http.ts",
"src/status/status-message.ts",
"src/tui/embedded-backend.test.ts",
"src/tui/embedded-backend.ts",
]),
);
});
it("flags legacy transcript reader imports", () => {
expect(
findSessionTranscriptReaderBoundaryViolations(`
import { readSessionMessagesAsync, loadSessionEntry } from "./session-utils.js";
import { readRecentSessionMessages as readRecent } from "./session-utils.fs.js";
`),
).toEqual([
{
line: 2,
reason:
'imports transcript reader "readSessionMessagesAsync" from legacy module "./session-utils.js"',
},
{
line: 3,
reason:
'imports transcript reader "readRecentSessionMessages" from legacy module "./session-utils.fs.js"',
},
]);
});
it("flags namespace legacy transcript reader references", () => {
expect(
findSessionTranscriptReaderBoundaryViolations(`
import * as sessionUtils from "./session-utils.js";
sessionUtils.readSessionMessagesAsync();
sessionUtils["readRecentSessionMessages"]();
const { readSessionMessages } = sessionUtils;
`),
).toEqual([
{ line: 3, reason: 'references legacy transcript reader "readSessionMessagesAsync"' },
{ line: 4, reason: 'references legacy transcript reader "readRecentSessionMessages"' },
{ line: 5, reason: 'aliases legacy transcript reader "readSessionMessages"' },
]);
});
it("flags legacy transcript reader re-exports", () => {
expect(
findSessionTranscriptReaderBoundaryViolations(`
export { readSessionMessagesAsync } from "./session-utils.js";
export { readRecentSessionMessages as readRecent } from "./session-utils.fs.js";
export * as sessionUtils from "./session-utils.js";
export * from "./session-utils.fs.js";
`),
).toEqual([
{
line: 2,
reason:
're-exports transcript reader "readSessionMessagesAsync" from legacy module "./session-utils.js"',
},
{
line: 3,
reason:
're-exports transcript reader "readRecentSessionMessages" from legacy module "./session-utils.fs.js"',
},
{
line: 4,
reason: 're-exports transcript reader namespace from legacy module "./session-utils.js"',
},
{
line: 5,
reason: 're-exports transcript readers from legacy module "./session-utils.fs.js"',
},
]);
});
it("allows migrated reader facade imports and non-reader session utilities", () => {
expect(
findSessionTranscriptReaderBoundaryViolations(`
import { readSessionMessagesAsync } from "./session-transcript-readers.js";
import { loadSessionEntry } from "./session-utils.js";
export { readSessionMessagesAsync };
await readSessionMessagesAsync(scope, opts);
loadSessionEntry("agent:main");
`),
).toEqual([]);
});
it("allows reader-named destructuring from non-legacy objects", () => {
expect(
findSessionTranscriptReaderBoundaryViolations(`
const { readSessionMessagesAsync } = deps;
const { readSessionMessages: readMessages } = mockReaders;
`),
).toEqual([]);
});
});
+16
View File
@@ -32,6 +32,22 @@ describe("ci workflow guards", () => {
);
});
it("runs the transcript reader ratchet as a visible additional check", () => {
const workflow = readCiWorkflow();
const additionalJob = workflow.jobs["check-additional-shard"];
const matrixRows = additionalJob.strategy.matrix.include;
expect(matrixRows).toContainEqual({
check_name: "check-session-transcript-reader-boundary",
group: "session-transcript-reader-boundary",
});
const runStep = additionalJob.steps.find((step) => step.name === "Run additional check shard");
expect(runStep.run).toContain("session-transcript-reader-boundary)");
expect(runStep.run).toContain(
'run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary',
);
});
it("kills timed manual checkout fetches after the grace period", () => {
const workflowPaths = [
".github/workflows/ci.yml",