From 7ea7ea47ef80d01cde85f408cc3bdfad1d5e1cd1 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Sun, 31 May 2026 18:56:09 -0700 Subject: [PATCH] refactor: route transcript readers through facade --- .github/workflows/ci.yml | 5 + package.json | 1 + ...eck-session-transcript-reader-boundary.mjs | 259 ++++++++++++++++++ scripts/lib/ts-guard-utils.mjs | 1 + src/agents/main-session-restart-recovery.ts | 18 +- src/agents/subagent-announce-output.test.ts | 7 +- src/agents/subagent-announce-output.ts | 7 +- src/agents/subagent-announce.runtime.ts | 2 +- src/agents/subagent-orphan-recovery.test.ts | 4 +- src/agents/subagent-orphan-recovery.ts | 11 +- .../tools/embedded-gateway-stub.runtime.ts | 6 +- .../tools/embedded-gateway-stub.test.ts | 27 +- src/agents/tools/embedded-gateway-stub.ts | 18 +- src/agents/tools/sessions-history-tool.ts | 2 +- src/agents/tools/sessions-list-tool.ts | 16 +- src/gateway/cli-session-history.claude.ts | 2 +- .../gateway-models.profiles.live.test.ts | 18 +- src/gateway/managed-image-attachments.test.ts | 23 +- src/gateway/managed-image-attachments.ts | 16 +- src/gateway/server-methods/artifacts.test.ts | 28 +- src/gateway/server-methods/artifacts.ts | 12 +- src/gateway/server-methods/chat.ts | 51 +++- .../server-methods/sessions-files.test.ts | 163 ++++++----- src/gateway/server-methods/sessions-files.ts | 12 +- src/gateway/server-methods/sessions.ts | 47 ++-- src/gateway/server-session-events.ts | 11 +- src/gateway/session-history-state.test.ts | 10 +- src/gateway/session-history-state.ts | 21 +- src/gateway/session-reset-service.ts | 18 +- src/gateway/session-transcript-readers.ts | 25 +- src/gateway/session-utils.ts | 14 +- .../sessions-history-http.revocation.test.ts | 6 +- src/gateway/sessions-history-http.ts | 21 +- src/status/status-message.ts | 12 +- src/tui/embedded-backend.test.ts | 15 +- src/tui/embedded-backend.ts | 26 +- ...session-transcript-reader-boundary.test.ts | 131 +++++++++ test/scripts/ci-workflow-guards.test.ts | 16 ++ 38 files changed, 831 insertions(+), 251 deletions(-) create mode 100644 scripts/check-session-transcript-reader-boundary.mjs create mode 100644 test/scripts/check-session-transcript-reader-boundary.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1b47d01c302..7ad8bae7888f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 ;; diff --git a/package.json b/package.json index 9ebe1ba572b9..1ac34275d74c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-session-transcript-reader-boundary.mjs b/scripts/check-session-transcript-reader-boundary.mjs new file mode 100644 index 000000000000..0d82f3016f75 --- /dev/null +++ b/scripts/check-session-transcript-reader-boundary.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); diff --git a/scripts/lib/ts-guard-utils.mjs b/scripts/lib/ts-guard-utils.mjs index fb39cbfb91c5..02ffad269c6a 100644 --- a/scripts/lib/ts-guard-utils.mjs +++ b/scripts/lib/ts-guard-utils.mjs @@ -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, }); diff --git a/src/agents/main-session-restart-recovery.ts b/src/agents/main-session-restart-recovery.ts index 72c8ef2e24d8..04a1b1f9df5c 100644 --- a/src/agents/main-session-restart-recovery.ts +++ b/src/agents/main-session-restart-recovery.ts @@ -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, diff --git a/src/agents/subagent-announce-output.test.ts b/src/agents/subagent-announce-output.test.ts index 6868a5cdb824..46698c209dfc 100644 --- a/src/agents/subagent-announce-output.test.ts +++ b/src/agents/subagent-announce-output.test.ts @@ -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(); diff --git a/src/agents/subagent-announce-output.ts b/src/agents/subagent-announce-output.ts index 46872c7057b0..12dca9afb341 100644 --- a/src/agents/subagent-announce-output.ts +++ b/src/agents/subagent-announce-output.ts @@ -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, diff --git a/src/agents/subagent-announce.runtime.ts b/src/agents/subagent-announce.runtime.ts index 019dd5cc3bc6..0b59c26a2278 100644 --- a/src/agents/subagent-announce.runtime.ts +++ b/src/agents/subagent-announce.runtime.ts @@ -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, diff --git a/src/agents/subagent-orphan-recovery.test.ts b/src/agents/subagent-orphan-recovery.test.ts index bc3aca24d565..fdaa2dd9d49f 100644 --- a/src/agents/subagent-orphan-recovery.test.ts +++ b/src/agents/subagent-orphan-recovery.test.ts @@ -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 () => []), })); diff --git a/src/agents/subagent-orphan-recovery.ts b/src/agents/subagent-orphan-recovery.ts index f85f8e8ebf0a..f5eee43a28cd 100644 --- a/src/agents/subagent-orphan-recovery.ts +++ b/src/agents/subagent-orphan-recovery.ts @@ -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, diff --git a/src/agents/tools/embedded-gateway-stub.runtime.ts b/src/agents/tools/embedded-gateway-stub.runtime.ts index 3bb30f83ce48..5944db73f9b2 100644 --- a/src/agents/tools/embedded-gateway-stub.runtime.ts +++ b/src/agents/tools/embedded-gateway-stub.runtime.ts @@ -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"; diff --git a/src/agents/tools/embedded-gateway-stub.test.ts b/src/agents/tools/embedded-gateway-stub.test.ts index 6a3ead831baf..e50934dded04 100644 --- a/src/agents/tools/embedded-gateway-stub.test.ts +++ b/src/agents/tools/embedded-gateway-stub.test.ts @@ -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, diff --git a/src/agents/tools/embedded-gateway-stub.ts b/src/agents/tools/embedded-gateway-stub.ts index e1d1be5351ec..6f30589ecfcf 100644 --- a/src/agents/tools/embedded-gateway-stub.ts +++ b/src/agents/tools/embedded-gateway-stub.ts @@ -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 | undefined; }; readSessionMessagesAsync: ( - sessionId: string, - storePath: string, - sessionFile: string | undefined, + scope: SessionTranscriptReadScope, opts: ReadSessionMessagesAsyncOptions, ) => Promise; resolveSessionModelRef: ( @@ -158,9 +159,12 @@ async function handleChatHistory(params: Record): 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, diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index ebc79a959add..00442b3cc99e 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -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"; diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index c4b10716a44c..4b02cee13ca2 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -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, diff --git a/src/gateway/cli-session-history.claude.ts b/src/gateway/cli-session-history.claude.ts index 785a54e873f1..4c2e4d783059 100644 --- a/src/gateway/cli-session-history.claude.ts +++ b/src/gateway/cli-session-history.claude.ts @@ -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"); diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index d6462dd582c6..e8dedb699fad 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -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") { diff --git a/src/gateway/managed-image-attachments.test.ts b/src/gateway/managed-image-attachments.test.ts index 1d6a11285eb2..c41b628d1725 100644 --- a/src/gateway/managed-image-attachments.test.ts +++ b/src/gateway/managed-image-attachments.test.ts @@ -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 }), ); }); diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index f40400605899..963d2d1229f2 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -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", diff --git a/src/gateway/server-methods/artifacts.test.ts b/src/gateway/server-methods/artifacts.test.ts index 4f22773157a9..4783e8d2af77 100644 --- a/src/gateway/server-methods/artifacts.test.ts +++ b/src/gateway/server-methods/artifacts.test.ts @@ -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( + "../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" }), ); diff --git a/src/gateway/server-methods/artifacts.ts b/src/gateway/server-methods/artifacts.ts index 79f4a2e28144..73d748eaece6 100644 --- a/src/gateway/server-methods/artifacts.ts +++ b/src/gateway/server-methods/artifacts.ts @@ -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, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index a70c99d43664..d703dd607d66 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -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, diff --git a/src/gateway/server-methods/sessions-files.test.ts b/src/gateway/server-methods/sessions-files.test.ts index 2b60e65004ea..6f9bfa21234d 100644 --- a/src/gateway/server-methods/sessions-files.test.ts +++ b/src/gateway/server-methods/sessions-files.test.ts @@ -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( + "../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", { diff --git a/src/gateway/server-methods/sessions-files.ts b/src/gateway/server-methods/sessions-files.ts index f65903601f4b..0ee41dcc087e 100644 --- a/src/gateway/server-methods/sessions-files.ts +++ b/src/gateway/server-methods/sessions-files.ts @@ -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(); await visitSessionMessagesAsync( - entry.sessionId, - storePath, - entry.sessionFile, + { + agentId, + sessionFile: entry.sessionFile, + sessionId: entry.sessionId, + storePath, + }, (message) => collectTouchedFilesFromMessage(message, files), { mode: "full", diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 5e8339a78202..3da7b6c75793 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -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 | 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, diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index 2dbf07a4acea..1f0c66674e27 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -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; } diff --git a/src/gateway/session-history-state.test.ts b/src/gateway/session-history-state.test.ts index b0659867c091..b948e091a7b1 100644 --- a/src/gateway/session-history-state.test.ts +++ b/src/gateway/session-history-state.test.ts @@ -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; 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, diff --git a/src/gateway/session-history-state.ts b/src/gateway/session-history-state.ts index 87b422b593b5..d85863ac813e 100644 --- a/src/gateway/session-history-state.ts +++ b/src/gateway/session-history-state.ts @@ -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 { 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", diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 8d7003054266..19e890452dd1 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -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( diff --git a/src/gateway/session-transcript-readers.ts b/src/gateway/session-transcript-readers.ts index 75cf420b16ec..38c5bb62dfd1 100644 --- a/src/gateway/session-transcript-readers.ts +++ b/src/gateway/session-transcript-readers.ts @@ -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 { + 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 { return await readSessionMessageByIdAsyncFile( scope.sessionId, scope.storePath, scope.sessionFile, messageId, - { agentId: scope.agentId }, + { ...opts, agentId: scope.agentId }, ); } diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index fcc7de8b023f..9cbcb0001a86 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -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, diff --git a/src/gateway/sessions-history-http.revocation.test.ts b/src/gateway/sessions-history-http.revocation.test.ts index 545b5be504f8..dd1ae8d855a0 100644 --- a/src/gateway/sessions-history-http.revocation.test.ts +++ b/src/gateway/sessions-history-http.revocation.test.ts @@ -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", () => ({ diff --git a/src/gateway/sessions-history-http.ts b/src/gateway/sessions-history-http.ts index 04d4a9081677..29b853ef4d14 100644 --- a/src/gateway/sessions-history-http.ts +++ b/src/gateway/sessions-history-http.ts @@ -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, diff --git a/src/status/status-message.ts b/src/status/status-message.ts index c9ac730b1afb..8a29bcb9473f 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -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) { diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index e044378a78d0..45900c84a24d 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -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) => - 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) => + 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, diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index 7dbf8a3998e8..81690f109ed6 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -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, diff --git a/test/scripts/check-session-transcript-reader-boundary.test.ts b/test/scripts/check-session-transcript-reader-boundary.test.ts new file mode 100644 index 000000000000..bba2f6cb6498 --- /dev/null +++ b/test/scripts/check-session-transcript-reader-boundary.test.ts @@ -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([]); + }); +}); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index a641ae31863c..6911dcc8eb3d 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -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",