mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
refactor: use canonical transcript reader identity (#89581)
* refactor: use canonical transcript reader identity * refactor: keep transcript reader dependency storage-neutral
This commit is contained in:
@@ -95,10 +95,20 @@ export const migratedSessionAccessorFiles = new Set([
|
||||
"src/cron/isolated-agent/delivery-target.ts",
|
||||
"src/cron/service/timer.ts",
|
||||
"src/gateway/session-compaction-checkpoints.ts",
|
||||
"src/gateway/session-history-state.ts",
|
||||
"src/gateway/session-utils.ts",
|
||||
"src/gateway/managed-image-attachments.ts",
|
||||
"src/gateway/server-methods/artifacts.ts",
|
||||
"src/gateway/server-methods/chat.ts",
|
||||
"src/gateway/sessions-resolve.ts",
|
||||
"src/gateway/server-methods/sessions-files.ts",
|
||||
"src/gateway/server-methods/sessions.ts",
|
||||
"src/gateway/server-session-events.ts",
|
||||
"src/gateway/session-reset-service.ts",
|
||||
"src/infra/outbound/message-action-tts.ts",
|
||||
"src/agents/tools/embedded-gateway-stub.ts",
|
||||
"src/agents/tools/sessions-list-tool.ts",
|
||||
"src/status/status-message.ts",
|
||||
"src/tui/embedded-backend.ts",
|
||||
]);
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ const transcriptReaderNames = new Set([
|
||||
"visitSessionMessagesAsync",
|
||||
]);
|
||||
|
||||
const storageSpecificTranscriptReaderAliasNames = new Set(["readSessionMessagesFromFileAsync"]);
|
||||
|
||||
export const migratedSessionTranscriptReaderFiles = new Set([
|
||||
"src/agents/main-session-restart-recovery.ts",
|
||||
"src/agents/subagent-announce-output.test.ts",
|
||||
@@ -126,6 +128,13 @@ export function findSessionTranscriptReaderBoundaryViolations(content, fileName
|
||||
const legacyNamespaces = new Set();
|
||||
|
||||
const visit = (node) => {
|
||||
if (ts.isIdentifier(node) && storageSpecificTranscriptReaderAliasNames.has(node.text)) {
|
||||
violations.push({
|
||||
line: toLine(sourceFile, node),
|
||||
reason: `uses storage-specific transcript reader alias "${node.text}"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
const moduleName = importedModuleName(node);
|
||||
const namedBindings = node.importClause?.namedBindings;
|
||||
|
||||
@@ -805,8 +805,9 @@ async function recoverStore(params: {
|
||||
messages = await readSessionMessagesAsync(
|
||||
{
|
||||
agentId: resolveAgentIdFromSessionKey(sessionKey),
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey,
|
||||
storePath: params.storePath,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -296,8 +296,9 @@ export async function recoverOrphanedSubagentSessions(params: {
|
||||
const messages = await readSessionMessagesAsync(
|
||||
{
|
||||
agentId: resolveAgentIdFromSessionKey(childSessionKey),
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: childSessionKey,
|
||||
storePath,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -69,4 +69,16 @@ vi.mock("../../channels/plugins/session-conversation.js", () => ({
|
||||
threadId: match.groups.threadId,
|
||||
};
|
||||
},
|
||||
resolveSessionThreadInfo: (sessionKey: string | undefined | null) => {
|
||||
const trimmed = sessionKey?.trim();
|
||||
const topicMarker = ":topic:";
|
||||
const topicIndex = trimmed?.lastIndexOf(topicMarker) ?? -1;
|
||||
if (!trimmed || topicIndex < 0) {
|
||||
return { baseSessionKey: trimmed, threadId: undefined };
|
||||
}
|
||||
return {
|
||||
baseSessionKey: trimmed.slice(0, topicIndex),
|
||||
threadId: trimmed.slice(topicIndex + topicMarker.length) || undefined,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -126,8 +126,9 @@ describe("embedded gateway stub", () => {
|
||||
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionFile: undefined,
|
||||
sessionEntry: { sessionId: "sess-main" },
|
||||
sessionId: "sess-main",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/openclaw-sessions.json",
|
||||
},
|
||||
{
|
||||
@@ -192,8 +193,9 @@ describe("embedded gateway stub", () => {
|
||||
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionFile: undefined,
|
||||
sessionEntry: { sessionId: "sess-main" },
|
||||
sessionId: "sess-main",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/openclaw-sessions.json",
|
||||
},
|
||||
{
|
||||
@@ -225,8 +227,9 @@ describe("embedded gateway stub", () => {
|
||||
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionFile: undefined,
|
||||
sessionEntry: { sessionId: "sess-main" },
|
||||
sessionId: "sess-main",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/openclaw-sessions.json",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -155,14 +155,22 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
|
||||
const requested = typeof limit === "number" ? limit : defaultLimit;
|
||||
const max = Math.min(hardMax, requested);
|
||||
const maxHistoryBytes = rt.getMaxChatHistoryMessagesBytes();
|
||||
const sessionEntry =
|
||||
typeof entry?.sessionId === "string"
|
||||
? {
|
||||
sessionId: entry.sessionId,
|
||||
...(typeof entry.sessionFile === "string" ? { sessionFile: entry.sessionFile } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const localMessages =
|
||||
sessionId && storePath
|
||||
? await rt.readSessionMessagesAsync(
|
||||
{
|
||||
agentId: sessionAgentId,
|
||||
sessionFile: entry?.sessionFile as string | undefined,
|
||||
sessionEntry,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -156,8 +156,9 @@ export function createSessionsListTool(opts?: {
|
||||
const titleTargets: Array<{
|
||||
row: SessionListRow;
|
||||
titleEntry: SessionEntry;
|
||||
sessionEntry: { sessionFile?: string; sessionId: string };
|
||||
sessionId: string;
|
||||
sessionFile?: string;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
}> = [];
|
||||
|
||||
@@ -356,8 +357,16 @@ export function createSessionsListTool(opts?: {
|
||||
subject: readStringValue((entry as { subject?: unknown }).subject),
|
||||
updatedAt: typeof row.updatedAt === "number" ? row.updatedAt : 0,
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId,
|
||||
...(sessionFile ? { sessionFile } : {}),
|
||||
},
|
||||
sessionId,
|
||||
...(sessionFile ? { sessionFile } : {}),
|
||||
sessionKey: resolveInternalSessionKey({
|
||||
key,
|
||||
alias,
|
||||
mainKey,
|
||||
}),
|
||||
agentId: resolvedAgentId,
|
||||
});
|
||||
}
|
||||
@@ -385,8 +394,9 @@ export function createSessionsListTool(opts?: {
|
||||
const target = titleTargets[next];
|
||||
const fields = await readSessionTitleFieldsFromTranscriptAsync({
|
||||
agentId: target.agentId,
|
||||
sessionFile: target.sessionFile,
|
||||
sessionEntry: target.sessionEntry,
|
||||
sessionId: target.sessionId,
|
||||
sessionKey: target.sessionKey,
|
||||
storePath,
|
||||
});
|
||||
if (includeDerivedTitles && !target.row.derivedTitle) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
publishTranscriptUpdate,
|
||||
readSessionUpdatedAt,
|
||||
replaceSessionEntry,
|
||||
resolveSessionTranscriptReadTarget,
|
||||
resolveSessionTranscriptRuntimeReadTarget,
|
||||
resolveSessionTranscriptRuntimeTarget,
|
||||
trimSessionTranscriptForManualCompact,
|
||||
@@ -1475,6 +1476,44 @@ describe("session accessor file-backed seam", () => {
|
||||
expect(loadSessionEntry(scope)?.sessionFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses a supplied read session entry without loading the store", () => {
|
||||
const explicitSessionFile = path.join(tempDir, "entry-session.jsonl");
|
||||
fs.writeFileSync(explicitSessionFile, "", "utf8");
|
||||
fs.writeFileSync(storePath, "{not-json", "utf8");
|
||||
|
||||
const target = resolveSessionTranscriptReadTarget({
|
||||
agentId: "main",
|
||||
sessionEntry: {
|
||||
sessionFile: explicitSessionFile,
|
||||
sessionId: "session-1",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath,
|
||||
});
|
||||
|
||||
expect(target).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionFile: fs.realpathSync(explicitSessionFile),
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves an explicit read transcript file without agent identity", () => {
|
||||
const explicitSessionFile = path.join(tempDir, "explicit-read-session.jsonl");
|
||||
|
||||
const target = resolveSessionTranscriptReadTarget({
|
||||
sessionFile: explicitSessionFile,
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
sessionFile: explicitSessionFile,
|
||||
sessionId: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps read and write runtime targets aligned for new topic sessions", async () => {
|
||||
const scope = {
|
||||
agentId: "main",
|
||||
|
||||
@@ -117,27 +117,17 @@ export type SessionAccessScope = {
|
||||
storePath?: string;
|
||||
};
|
||||
|
||||
export type SessionTranscriptReadScope = Omit<SessionAccessScope, "sessionKey"> & {
|
||||
export type SessionTranscriptAccessScope = Omit<SessionAccessScope, "sessionKey"> & {
|
||||
/** Explicit transcript file path; bypasses store lookup when already known. */
|
||||
sessionFile?: string;
|
||||
/** Runtime session id used to derive a transcript file when no explicit file is provided. */
|
||||
sessionId: string;
|
||||
/** Optional key for read callers that can resolve via the session entry. */
|
||||
/** Required when resolving through session metadata; optional for explicit transcript artifacts. */
|
||||
sessionKey?: string;
|
||||
/** Channel thread suffix used when deriving topic transcript paths. */
|
||||
threadId?: string | number;
|
||||
};
|
||||
|
||||
export type SessionTranscriptAccessScope = SessionTranscriptReadScope & {
|
||||
/**
|
||||
* Identifies the owning entry when the transcript target must be resolved
|
||||
* (and possibly persisted) through the session store. May be omitted only
|
||||
* when an explicit sessionFile binds the operation to a concrete artifact;
|
||||
* such writes never read or update entry metadata.
|
||||
*/
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
export type SessionTranscriptRuntimeScope = SessionAccessScope & {
|
||||
/** Resolved file-backed artifact for the current runtime target. */
|
||||
sessionFile?: string;
|
||||
@@ -145,6 +135,21 @@ export type SessionTranscriptRuntimeScope = SessionAccessScope & {
|
||||
threadId?: string | number;
|
||||
};
|
||||
|
||||
export type SessionTranscriptReadScope = Omit<SessionTranscriptRuntimeScope, "sessionKey"> & {
|
||||
/** Canonical key when the caller has a session-store identity for this read. */
|
||||
sessionKey?: string;
|
||||
/** Entry already loaded by hot callers; avoids rereading the session store. */
|
||||
sessionEntry?: Pick<SessionEntry, "sessionFile"> & Partial<Pick<SessionEntry, "sessionId">>;
|
||||
};
|
||||
|
||||
export type SessionTranscriptReadTarget = Omit<
|
||||
SessionTranscriptRuntimeTarget,
|
||||
"agentId" | "sessionKey"
|
||||
> & {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
export type SessionTranscriptWriteScope = Omit<SessionTranscriptAccessScope, "sessionId"> & {
|
||||
/** Optional for appenders that can operate on an existing explicit transcript target. */
|
||||
sessionId?: string;
|
||||
@@ -846,7 +851,7 @@ export async function persistSessionRolloverLifecycle(params: {
|
||||
|
||||
/** Reads parsed transcript records from an explicit or derived transcript target. */
|
||||
export async function loadTranscriptEvents(
|
||||
scope: SessionTranscriptReadScope,
|
||||
scope: SessionTranscriptAccessScope,
|
||||
): Promise<TranscriptEvent[]> {
|
||||
const transcript = await resolveTranscriptReadAccess(scope);
|
||||
const events: TranscriptEvent[] = [];
|
||||
@@ -1479,6 +1484,82 @@ export async function resolveSessionTranscriptRuntimeReadTarget(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current file-backed target for read-only transcript callers.
|
||||
* Unlike writer/runtime resolution, this does not persist missing sessionFile
|
||||
* metadata; reader projections must not mutate session metadata.
|
||||
*/
|
||||
export function resolveSessionTranscriptReadTarget(
|
||||
scope: SessionTranscriptReadScope,
|
||||
): SessionTranscriptReadTarget {
|
||||
const explicitSessionFile = scope.sessionFile?.trim();
|
||||
if (explicitSessionFile) {
|
||||
return {
|
||||
sessionFile: explicitSessionFile,
|
||||
sessionId: scope.sessionId,
|
||||
...(scope.agentId ? { agentId: scope.agentId } : {}),
|
||||
...(scope.sessionKey ? { sessionKey: scope.sessionKey } : {}),
|
||||
};
|
||||
}
|
||||
const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(scope.sessionKey);
|
||||
if (!agentId) {
|
||||
throw new Error(`Cannot resolve transcript scope without an agent id: ${scope.sessionKey}`);
|
||||
}
|
||||
const storePath = resolveConcreteReadStorePath(scope.storePath);
|
||||
const resolvedStoreEntry =
|
||||
scope.sessionEntry || !scope.sessionKey
|
||||
? undefined
|
||||
: storePath
|
||||
? resolveSessionStoreEntry({
|
||||
store: loadSessionStore(storePath, { skipCache: true }),
|
||||
sessionKey: scope.sessionKey,
|
||||
})
|
||||
: undefined;
|
||||
const sessionEntry =
|
||||
scope.sessionEntry ??
|
||||
resolvedStoreEntry?.existing ??
|
||||
(scope.sessionKey ? loadSessionEntry({ ...scope, sessionKey: scope.sessionKey }) : undefined);
|
||||
const sessionKey = resolvedStoreEntry?.normalizedKey ?? scope.sessionKey;
|
||||
const matchingSessionEntry =
|
||||
sessionEntry?.sessionId === undefined || sessionEntry.sessionId === scope.sessionId
|
||||
? sessionEntry
|
||||
: undefined;
|
||||
const threadId =
|
||||
scope.threadId ?? (sessionKey ? parseSessionThreadInfo(sessionKey).threadId : undefined);
|
||||
const sessionFile = matchingSessionEntry?.sessionFile
|
||||
? resolveSessionFilePath(
|
||||
scope.sessionId,
|
||||
matchingSessionEntry,
|
||||
resolveSessionFilePathOptions({
|
||||
agentId,
|
||||
...(storePath ? { storePath } : {}),
|
||||
}),
|
||||
)
|
||||
: storePath
|
||||
? resolveSessionTranscriptPathInDir(
|
||||
// File-backed readers derive beside sessions.json only for the JSON-store
|
||||
// deprecation window; the SQLite flip resolves from canonical metadata.
|
||||
scope.sessionId,
|
||||
path.dirname(path.resolve(storePath)),
|
||||
threadId,
|
||||
)
|
||||
: resolveSessionTranscriptPath(scope.sessionId, agentId, threadId);
|
||||
return {
|
||||
agentId,
|
||||
sessionFile,
|
||||
sessionId: scope.sessionId,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConcreteReadStorePath(storePath: string | undefined): string | undefined {
|
||||
const trimmed = storePath?.trim();
|
||||
if (!trimmed || trimmed === "(multiple)" || trimmed.includes("{agentId}")) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function createFallbackSessionEntry(patch: Partial<SessionEntry>): SessionEntry {
|
||||
const now = Date.now();
|
||||
return {
|
||||
@@ -1555,7 +1636,7 @@ function resolveAccessStorePath(scope: SessionAccessScope): string {
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveTranscriptReadAccess(scope: SessionTranscriptReadScope): Promise<{
|
||||
async function resolveTranscriptReadAccess(scope: SessionTranscriptAccessScope): Promise<{
|
||||
sessionFile: string;
|
||||
}> {
|
||||
if (scope.sessionFile?.trim()) {
|
||||
|
||||
@@ -2127,8 +2127,9 @@ async function readSessionAssistantTexts(sessionKey: string, modelKey?: string):
|
||||
}
|
||||
const messages = await readSessionMessagesAsync(
|
||||
{
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -292,8 +292,12 @@ describe("handleManagedOutgoingImageHttpRequest", () => {
|
||||
expect(readSessionMessagesMock).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: undefined,
|
||||
sessionFile: "session.jsonl",
|
||||
sessionEntry: {
|
||||
sessionFile: "session.jsonl",
|
||||
sessionId: "sess-1",
|
||||
},
|
||||
sessionId: "sess-1",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: path.join(stateDir, "gateway-sessions.json"),
|
||||
},
|
||||
expect.objectContaining({ allowResetArchiveFallback: true }),
|
||||
@@ -1064,8 +1068,12 @@ describe("cleanupManagedOutgoingImageRecords", () => {
|
||||
expect(readSessionMessagesMock).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: undefined,
|
||||
sessionFile: "/tmp/sess-main.jsonl",
|
||||
sessionEntry: {
|
||||
sessionFile: "/tmp/sess-main.jsonl",
|
||||
sessionId: "sess-main",
|
||||
},
|
||||
sessionId: "sess-main",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: path.join(stateDir, "gateway-sessions.json"),
|
||||
},
|
||||
expect.objectContaining({ allowResetArchiveFallback: true }),
|
||||
|
||||
@@ -731,8 +731,9 @@ async function getSessionManagedOutgoingAttachmentIndex(
|
||||
const readResult = await readSessionMessagesWithSourceAsync(
|
||||
{
|
||||
agentId,
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -244,8 +244,12 @@ describe("artifacts RPC handlers", () => {
|
||||
expect(hoisted.visitSessionMessagesAsync).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionFile: "/tmp/sess-main.jsonl",
|
||||
sessionEntry: {
|
||||
sessionFile: "/tmp/sess-main.jsonl",
|
||||
sessionId: "sess-main",
|
||||
},
|
||||
sessionId: "sess-main",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/sessions.json",
|
||||
},
|
||||
expect.any(Function),
|
||||
|
||||
@@ -467,8 +467,9 @@ async function loadArtifacts(
|
||||
await visitSessionMessagesAsync(
|
||||
{
|
||||
agentId: resolved.agentId ?? resolveAgentIdFromSessionKey(sessionKey),
|
||||
sessionFile: entry?.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
(message, seq) => {
|
||||
|
||||
@@ -2551,7 +2551,8 @@ function readChatHistoryMessageId(message: unknown): string | undefined {
|
||||
async function isChatMessageIdVisibleAfterHistoryFilters(params: {
|
||||
sessionId: string;
|
||||
storePath: string | undefined;
|
||||
sessionFile: string | undefined;
|
||||
sessionEntry?: { sessionFile?: string; sessionId?: string };
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
messageId: string;
|
||||
sessionStartedAt?: number;
|
||||
@@ -2563,8 +2564,9 @@ async function isChatMessageIdVisibleAfterHistoryFilters(params: {
|
||||
const messages = await readSessionMessagesAsync(
|
||||
{
|
||||
agentId: params.agentId,
|
||||
sessionFile: params.sessionFile,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
},
|
||||
{
|
||||
@@ -2683,8 +2685,9 @@ async function handleChatHistoryRequest({
|
||||
? await readRecentSessionMessagesAsync(
|
||||
{
|
||||
agentId: sessionAgentId,
|
||||
sessionFile: entry?.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId,
|
||||
sessionKey: canonicalKey,
|
||||
storePath,
|
||||
},
|
||||
{
|
||||
@@ -2876,8 +2879,9 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
const resolved = await readSessionMessageByIdAsync(
|
||||
{
|
||||
agentId: sessionAgentId,
|
||||
sessionFile: entry?.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
messageId,
|
||||
@@ -2890,7 +2894,8 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
const visible = await isChatMessageIdVisibleAfterHistoryFilters({
|
||||
sessionId,
|
||||
storePath,
|
||||
sessionFile: entry?.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionKey,
|
||||
agentId: sessionAgentId,
|
||||
messageId,
|
||||
sessionStartedAt:
|
||||
|
||||
@@ -604,8 +604,9 @@ async function loadSessionFiles(params: {
|
||||
await visitSessionMessagesAsync(
|
||||
{
|
||||
agentId,
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: canonicalKey,
|
||||
storePath,
|
||||
},
|
||||
(message) => collectTouchedFilesFromMessage(message, files),
|
||||
|
||||
@@ -830,8 +830,9 @@ async function handleSessionSend(params: {
|
||||
const messageSeq =
|
||||
(await readSessionMessageCountAsync({
|
||||
agentId: requestedAgentId,
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: canonicalKey,
|
||||
storePath,
|
||||
})) + 1;
|
||||
let sendAcked = false;
|
||||
@@ -1173,8 +1174,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const items = readSessionPreviewItemsFromTranscript(
|
||||
{
|
||||
agentId: target.agentId,
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: target.canonicalKey,
|
||||
storePath: target.storePath,
|
||||
},
|
||||
limit,
|
||||
@@ -1532,8 +1534,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const messageSeq = initialMessage
|
||||
? (await readSessionMessageCountAsync({
|
||||
agentId: target.agentId,
|
||||
sessionFile: createdEntry.sessionFile,
|
||||
sessionEntry: createdEntry,
|
||||
sessionId: createdEntry.sessionId,
|
||||
sessionKey: target.canonicalKey,
|
||||
storePath: target.storePath,
|
||||
})) + 1
|
||||
: undefined;
|
||||
@@ -2398,8 +2401,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const { messages } = await readRecentSessionMessagesWithStatsAsync(
|
||||
{
|
||||
agentId: requestedAgent.agentId,
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: key,
|
||||
storePath,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -185,8 +185,9 @@ async function handleTranscriptUpdateBroadcast(
|
||||
? asPositiveSafeInteger(
|
||||
await readSessionMessageCountAsync({
|
||||
agentId: visibleAgentId,
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ function userTextMessage(text: string, seq: number) {
|
||||
|
||||
function newState(rawMessages: Array<Record<string, unknown>>, options: RawStateOptions = {}) {
|
||||
return SessionHistorySseState.fromRawSnapshot({
|
||||
target: { sessionId: "sess-main" },
|
||||
target: { sessionId: "sess-main", sessionKey: "agent:main:main" },
|
||||
rawMessages,
|
||||
...options,
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe("SessionHistorySseState", () => {
|
||||
|
||||
test("keeps message-tool mirror pending across projected sessions_send inline history", () => {
|
||||
const state = SessionHistorySseState.fromRawSnapshot({
|
||||
target: { sessionId: "sess-main" },
|
||||
target: { sessionId: "sess-main", sessionKey: "agent:main:main" },
|
||||
rawMessages: [
|
||||
{
|
||||
role: "assistant",
|
||||
|
||||
@@ -43,9 +43,10 @@ type InlineSessionHistoryAppend = {
|
||||
|
||||
type SessionHistoryTranscriptTarget = {
|
||||
agentId?: string;
|
||||
sessionEntry?: { sessionFile?: string; sessionId?: string };
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionFile?: string;
|
||||
};
|
||||
|
||||
type SessionHistoryRawSnapshot = {
|
||||
@@ -364,8 +365,9 @@ export class SessionHistorySseState {
|
||||
const snapshot = await readRecentSessionMessagesWithStatsAsync(
|
||||
{
|
||||
agentId: this.target.agentId,
|
||||
sessionFile: this.target.sessionFile,
|
||||
sessionEntry: this.target.sessionEntry,
|
||||
sessionId: this.target.sessionId,
|
||||
sessionKey: this.target.sessionKey,
|
||||
storePath: this.target.storePath,
|
||||
},
|
||||
{
|
||||
@@ -383,8 +385,9 @@ export class SessionHistorySseState {
|
||||
const snapshot = await readSessionMessagesWithSourceAsync(
|
||||
{
|
||||
agentId: this.target.agentId,
|
||||
sessionFile: this.target.sessionFile,
|
||||
sessionEntry: this.target.sessionEntry,
|
||||
sessionId: this.target.sessionId,
|
||||
sessionKey: this.target.sessionKey,
|
||||
storePath: this.target.storePath,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -800,8 +800,9 @@ export async function emitGatewayBeforeResetPluginHook(params: {
|
||||
messages = await readSessionMessagesAsync(
|
||||
{
|
||||
agentId,
|
||||
sessionFile,
|
||||
sessionEntry: params.entry,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath: params.storePath,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("session transcript reader facade", () => {
|
||||
`${events.map((event) => JSON.stringify(event)).join("\n")}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return { sessionId, storePath };
|
||||
return { sessionId, sessionKey: `agent:main:${sessionId}`, storePath };
|
||||
}
|
||||
|
||||
test("reads active-branch messages and message ids through a scope", async () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SessionTranscriptReadScope } from "../config/sessions/session-accessor.js";
|
||||
import { resolveSessionTranscriptReadTarget } from "../config/sessions/session-accessor.js";
|
||||
import type {
|
||||
ReadRecentSessionMessagesOptions,
|
||||
ReadSessionMessagesAsyncOptions,
|
||||
@@ -30,12 +32,7 @@ import {
|
||||
export type { ReadRecentSessionMessagesOptions, ReadSessionMessagesAsyncOptions };
|
||||
export { attachOpenClawTranscriptMeta, capArrayByJsonBytes } from "./session-utils.fs.js";
|
||||
|
||||
export type SessionTranscriptReadScope = {
|
||||
agentId?: string;
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
storePath?: string;
|
||||
};
|
||||
export type { SessionTranscriptReadScope };
|
||||
|
||||
type SessionTitleFields = {
|
||||
firstUserMessage: string | null;
|
||||
@@ -72,13 +69,40 @@ type SessionTranscriptUsageSnapshot = {
|
||||
costUsd?: number;
|
||||
};
|
||||
|
||||
type FileBackedReadScope = {
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
storePath?: string;
|
||||
};
|
||||
|
||||
function resolveFileBackedReadScope(scope: SessionTranscriptReadScope): FileBackedReadScope {
|
||||
const target = resolveSessionTranscriptReadTarget(scope);
|
||||
const storePath = resolveConcreteReadStorePath(scope.storePath);
|
||||
return {
|
||||
agentId: target.agentId,
|
||||
sessionFile: target.sessionFile,
|
||||
sessionId: target.sessionId,
|
||||
...(storePath ? { storePath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConcreteReadStorePath(storePath: string | undefined): string | undefined {
|
||||
const trimmed = storePath?.trim();
|
||||
if (!trimmed || trimmed === "(multiple)" || trimmed.includes("{agentId}")) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Reads display messages from a session transcript through the reader seam. */
|
||||
export function readSessionMessages(scope: SessionTranscriptReadScope): unknown[] {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return readSessionMessagesFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,12 +111,13 @@ export function readRecentSessionMessages(
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts?: ReadRecentSessionMessagesOptions,
|
||||
): unknown[] {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return readRecentSessionMessagesFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
opts,
|
||||
scope.agentId,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,22 +126,24 @@ export function visitSessionMessages(
|
||||
scope: SessionTranscriptReadScope,
|
||||
visit: (message: unknown, seq: number) => void,
|
||||
): number {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return visitSessionMessagesFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
visit,
|
||||
scope.agentId,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Counts display messages in a session transcript through the reader seam. */
|
||||
export function readSessionMessageCount(scope: SessionTranscriptReadScope): number {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return readSessionMessageCountFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,12 +152,13 @@ export async function readSessionMessagesAsync(
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts: ReadSessionMessagesAsyncOptions,
|
||||
): Promise<unknown[]> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await readSessionMessagesAsyncFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
opts,
|
||||
scope.agentId,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,12 +167,13 @@ export async function readSessionMessagesWithSourceAsync(
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts: ReadSessionMessagesAsyncOptions,
|
||||
): Promise<ReadSessionMessagesResult> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await readSessionMessagesWithSourceAsyncFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
opts,
|
||||
scope.agentId,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,12 +182,13 @@ export async function readRecentSessionMessagesAsync(
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts?: ReadRecentSessionMessagesOptions,
|
||||
): Promise<unknown[]> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await readRecentSessionMessagesAsyncFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
opts,
|
||||
scope.agentId,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,12 +198,13 @@ export async function readSessionMessageByIdAsync(
|
||||
messageId: string,
|
||||
opts?: { allowResetArchiveFallback?: boolean },
|
||||
): Promise<ReadSessionMessageByIdResult> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await readSessionMessageByIdAsyncFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
messageId,
|
||||
{ ...opts, agentId: scope.agentId },
|
||||
{ ...opts, agentId: target.agentId },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,13 +214,14 @@ export async function visitSessionMessagesAsync(
|
||||
visit: (message: unknown, seq: number) => void,
|
||||
opts: { mode: "full"; reason: string; cache?: "reuse" | "skip" },
|
||||
): Promise<number> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await visitSessionMessagesAsyncFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
visit,
|
||||
opts,
|
||||
scope.agentId,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,11 +229,12 @@ export async function visitSessionMessagesAsync(
|
||||
export async function readSessionMessageCountAsync(
|
||||
scope: SessionTranscriptReadScope,
|
||||
): Promise<number> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await readSessionMessageCountAsyncFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,12 +243,13 @@ export function readRecentSessionMessagesWithStats(
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts: ReadRecentSessionMessagesOptions,
|
||||
): ReadRecentSessionMessagesResult {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return readRecentSessionMessagesWithStatsFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
opts,
|
||||
scope.agentId,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,12 +258,13 @@ export async function readRecentSessionMessagesWithStatsAsync(
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts: ReadRecentSessionMessagesOptions,
|
||||
): Promise<ReadRecentSessionMessagesResult> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await readRecentSessionMessagesWithStatsAsyncFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
opts,
|
||||
scope.agentId,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,11 +274,12 @@ export function readRecentSessionTranscriptLines(
|
||||
maxLines: number;
|
||||
},
|
||||
): { lines: string[]; totalLines: number } | null {
|
||||
const target = resolveFileBackedReadScope(params);
|
||||
return readRecentSessionTranscriptLinesFile({
|
||||
sessionId: params.sessionId,
|
||||
storePath: params.storePath,
|
||||
sessionFile: params.sessionFile,
|
||||
agentId: params.agentId,
|
||||
sessionId: target.sessionId,
|
||||
storePath: target.storePath,
|
||||
sessionFile: target.sessionFile,
|
||||
agentId: target.agentId,
|
||||
maxLines: params.maxLines,
|
||||
});
|
||||
}
|
||||
@@ -253,11 +289,12 @@ export function readSessionTitleFieldsFromTranscript(
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts?: { includeInterSession?: boolean },
|
||||
): SessionTitleFields {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return readSessionTitleFieldsFromTranscriptFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
@@ -267,11 +304,12 @@ export async function readSessionTitleFieldsFromTranscriptAsync(
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts?: { includeInterSession?: boolean },
|
||||
): Promise<SessionTitleFields> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await readSessionTitleFieldsFromTranscriptAsyncFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
@@ -281,11 +319,12 @@ export function readFirstUserMessageFromTranscript(
|
||||
scope: SessionTranscriptReadScope,
|
||||
opts?: { includeInterSession?: boolean },
|
||||
): string | null {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return readFirstUserMessageFromTranscriptFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
@@ -294,11 +333,12 @@ export function readFirstUserMessageFromTranscript(
|
||||
export function readLatestSessionUsageFromTranscript(
|
||||
scope: SessionTranscriptReadScope,
|
||||
): SessionTranscriptUsageSnapshot | null {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return readLatestSessionUsageFromTranscriptFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -306,11 +346,12 @@ export function readLatestSessionUsageFromTranscript(
|
||||
export async function readLatestSessionUsageFromTranscriptAsync(
|
||||
scope: SessionTranscriptReadScope,
|
||||
): Promise<SessionTranscriptUsageSnapshot | null> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await readLatestSessionUsageFromTranscriptAsyncFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -319,11 +360,12 @@ export async function readRecentSessionUsageFromTranscriptAsync(
|
||||
scope: SessionTranscriptReadScope,
|
||||
maxBytes: number,
|
||||
): Promise<SessionTranscriptUsageSnapshot | null> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await readRecentSessionUsageFromTranscriptAsyncFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
maxBytes,
|
||||
);
|
||||
}
|
||||
@@ -333,11 +375,12 @@ export async function readLatestRecentSessionUsageFromTranscriptAsync(
|
||||
scope: SessionTranscriptReadScope,
|
||||
maxBytes: number,
|
||||
): Promise<SessionTranscriptUsageSnapshot | null> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return await readLatestRecentSessionUsageFromTranscriptAsyncFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
maxBytes,
|
||||
);
|
||||
}
|
||||
@@ -347,11 +390,12 @@ export function readRecentSessionUsageFromTranscript(
|
||||
scope: SessionTranscriptReadScope,
|
||||
maxBytes: number,
|
||||
): SessionTranscriptUsageSnapshot | null {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return readRecentSessionUsageFromTranscriptFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
maxBytes,
|
||||
);
|
||||
}
|
||||
@@ -362,11 +406,12 @@ export function readSessionPreviewItemsFromTranscript(
|
||||
maxItems: number,
|
||||
maxChars: number,
|
||||
): ReturnType<typeof readSessionPreviewItemsFromTranscriptFile> {
|
||||
const target = resolveFileBackedReadScope(scope);
|
||||
return readSessionPreviewItemsFromTranscriptFile(
|
||||
scope.sessionId,
|
||||
scope.storePath,
|
||||
scope.sessionFile,
|
||||
scope.agentId,
|
||||
target.sessionId,
|
||||
target.storePath,
|
||||
target.sessionFile,
|
||||
target.agentId,
|
||||
maxItems,
|
||||
maxChars,
|
||||
);
|
||||
|
||||
+105
-104
@@ -886,8 +886,9 @@ function resolveTranscriptUsageFallback(params: {
|
||||
const snapshot = readScopedRecentSessionUsageFromTranscript(
|
||||
{
|
||||
agentId,
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: params.key,
|
||||
storePath: params.storePath,
|
||||
},
|
||||
typeof params.maxTranscriptBytes === "number" ? params.maxTranscriptBytes : 256 * 1024,
|
||||
@@ -1294,9 +1295,7 @@ export function listAgentsForGateway(
|
||||
.filter(Boolean),
|
||||
);
|
||||
const allowedIds = explicitIds.size > 0 ? new Set([...explicitIds, defaultId]) : null;
|
||||
let agentIds = listGatewayAgentIds(cfg).filter((id) =>
|
||||
allowedIds ? allowedIds.has(id) : true,
|
||||
);
|
||||
let agentIds = listGatewayAgentIds(cfg).filter((id) => (allowedIds ? allowedIds.has(id) : true));
|
||||
if (mainKey && !agentIds.includes(mainKey) && (!allowedIds || allowedIds.has(mainKey))) {
|
||||
agentIds = [...agentIds, mainKey];
|
||||
}
|
||||
@@ -2162,8 +2161,9 @@ export function buildGatewaySessionRow(params: {
|
||||
if (entry?.sessionId && (params.includeDerivedTitles || params.includeLastMessage)) {
|
||||
const fields = readScopedSessionTitleFieldsFromTranscript({
|
||||
agentId: sessionAgentId,
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: key,
|
||||
storePath,
|
||||
});
|
||||
if (params.includeDerivedTitles) {
|
||||
@@ -2795,110 +2795,111 @@ export async function listSessionsFromStoreAsync(params: {
|
||||
// between rows, the memo never hits, and each row triggers a full
|
||||
// loadPluginMetadataSnapshot scan (~100 ms).
|
||||
return withPinnedActivePluginRegistryWorkspaceDir(async () => {
|
||||
const { cfg, storePath, store, opts } = params;
|
||||
const now = Date.now();
|
||||
const sessionListTranscriptUsageMaxBytes = 64 * 1024;
|
||||
const sessionListTranscriptFieldRows = 100;
|
||||
let rowContext: SessionListRowContext | undefined;
|
||||
const getRowContext = () => {
|
||||
rowContext ??= buildSessionListRowContext({ store, now });
|
||||
return rowContext;
|
||||
};
|
||||
const includeDerivedTitles = opts.includeDerivedTitles === true;
|
||||
const includeLastMessage = opts.includeLastMessage === true;
|
||||
const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0;
|
||||
const { cfg, storePath, store, opts } = params;
|
||||
const now = Date.now();
|
||||
const sessionListTranscriptUsageMaxBytes = 64 * 1024;
|
||||
const sessionListTranscriptFieldRows = 100;
|
||||
let rowContext: SessionListRowContext | undefined;
|
||||
const getRowContext = () => {
|
||||
rowContext ??= buildSessionListRowContext({ store, now });
|
||||
return rowContext;
|
||||
};
|
||||
const includeDerivedTitles = opts.includeDerivedTitles === true;
|
||||
const includeLastMessage = opts.includeLastMessage === true;
|
||||
const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0;
|
||||
|
||||
const selection = selectSessionEntries({
|
||||
cfg,
|
||||
store,
|
||||
opts,
|
||||
now,
|
||||
getRowContext:
|
||||
hasSpawnedByFilter || Boolean(normalizeOptionalString(opts.search))
|
||||
? getRowContext
|
||||
: undefined,
|
||||
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
|
||||
});
|
||||
const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection;
|
||||
const fullRowContext =
|
||||
rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE
|
||||
? getRowContext()
|
||||
: undefined;
|
||||
const sharedRowContext =
|
||||
fullRowContext ??
|
||||
(entries.length > 0 ? buildSessionListRowMetadataContext({ now }) : undefined);
|
||||
|
||||
const sessions: GatewaySessionRow[] = [];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const [key, entry] = entries[i];
|
||||
const includeTranscriptFields = i < sessionListTranscriptFieldRows;
|
||||
const rowAgentId =
|
||||
key === "global" && typeof opts.agentId === "string"
|
||||
? normalizeAgentId(opts.agentId)
|
||||
: undefined;
|
||||
const storeChildSessionsByKey =
|
||||
fullRowContext?.storeChildSessionsByKey ??
|
||||
buildSingleRowStoreChildSessionsByKey({ store, storePath, key, now });
|
||||
const row = buildGatewaySessionRow({
|
||||
const selection = selectSessionEntries({
|
||||
cfg,
|
||||
storePath,
|
||||
store,
|
||||
key,
|
||||
entry,
|
||||
agentId: rowAgentId,
|
||||
modelCatalog: params.modelCatalog,
|
||||
opts,
|
||||
now,
|
||||
includeDerivedTitles: false,
|
||||
includeLastMessage: false,
|
||||
transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes,
|
||||
storeChildSessionsByKey,
|
||||
rowContext: sharedRowContext,
|
||||
skipTranscriptUsageFallback: true,
|
||||
lightweightListRow: true,
|
||||
getRowContext:
|
||||
hasSpawnedByFilter || Boolean(normalizeOptionalString(opts.search))
|
||||
? getRowContext
|
||||
: undefined,
|
||||
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
|
||||
});
|
||||
if (
|
||||
entry?.sessionId &&
|
||||
includeTranscriptFields &&
|
||||
(includeDerivedTitles || includeLastMessage)
|
||||
) {
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
const sessionAgentId =
|
||||
rowAgentId ??
|
||||
(parsed?.agentId ? normalizeAgentId(parsed.agentId) : resolveDefaultAgentId(cfg));
|
||||
const fields = await readScopedSessionTitleFieldsFromTranscriptAsync({
|
||||
agentId: sessionAgentId,
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionId: entry.sessionId,
|
||||
storePath,
|
||||
});
|
||||
if (includeDerivedTitles) {
|
||||
row.derivedTitle = deriveSessionTitle(entry, fields.firstUserMessage);
|
||||
}
|
||||
if (includeLastMessage && fields.lastMessagePreview) {
|
||||
row.lastMessagePreview = fields.lastMessagePreview;
|
||||
}
|
||||
}
|
||||
sessions.push(row);
|
||||
// Yield to the event loop between batches so WebSocket heartbeats,
|
||||
// channel I/O, and concurrent RPC calls are not starved.
|
||||
if ((i + 1) % SESSIONS_LIST_YIELD_BATCH_SIZE === 0 && i + 1 < entries.length) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection;
|
||||
const fullRowContext =
|
||||
rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE
|
||||
? getRowContext()
|
||||
: undefined;
|
||||
const sharedRowContext =
|
||||
fullRowContext ??
|
||||
(entries.length > 0 ? buildSessionListRowMetadataContext({ now }) : undefined);
|
||||
|
||||
return {
|
||||
ts: now,
|
||||
path: storePath,
|
||||
count: sessions.length,
|
||||
totalCount,
|
||||
limitApplied,
|
||||
offset: offset > 0 ? offset : undefined,
|
||||
nextOffset,
|
||||
hasMore,
|
||||
defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }),
|
||||
sessions,
|
||||
};
|
||||
const sessions: GatewaySessionRow[] = [];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const [key, entry] = entries[i];
|
||||
const includeTranscriptFields = i < sessionListTranscriptFieldRows;
|
||||
const rowAgentId =
|
||||
key === "global" && typeof opts.agentId === "string"
|
||||
? normalizeAgentId(opts.agentId)
|
||||
: undefined;
|
||||
const storeChildSessionsByKey =
|
||||
fullRowContext?.storeChildSessionsByKey ??
|
||||
buildSingleRowStoreChildSessionsByKey({ store, storePath, key, now });
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath,
|
||||
store,
|
||||
key,
|
||||
entry,
|
||||
agentId: rowAgentId,
|
||||
modelCatalog: params.modelCatalog,
|
||||
now,
|
||||
includeDerivedTitles: false,
|
||||
includeLastMessage: false,
|
||||
transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes,
|
||||
storeChildSessionsByKey,
|
||||
rowContext: sharedRowContext,
|
||||
skipTranscriptUsageFallback: true,
|
||||
lightweightListRow: true,
|
||||
});
|
||||
if (
|
||||
entry?.sessionId &&
|
||||
includeTranscriptFields &&
|
||||
(includeDerivedTitles || includeLastMessage)
|
||||
) {
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
const sessionAgentId =
|
||||
rowAgentId ??
|
||||
(parsed?.agentId ? normalizeAgentId(parsed.agentId) : resolveDefaultAgentId(cfg));
|
||||
const fields = await readScopedSessionTitleFieldsFromTranscriptAsync({
|
||||
agentId: sessionAgentId,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: key,
|
||||
storePath,
|
||||
});
|
||||
if (includeDerivedTitles) {
|
||||
row.derivedTitle = deriveSessionTitle(entry, fields.firstUserMessage);
|
||||
}
|
||||
if (includeLastMessage && fields.lastMessagePreview) {
|
||||
row.lastMessagePreview = fields.lastMessagePreview;
|
||||
}
|
||||
}
|
||||
sessions.push(row);
|
||||
// Yield to the event loop between batches so WebSocket heartbeats,
|
||||
// channel I/O, and concurrent RPC calls are not starved.
|
||||
if ((i + 1) % SESSIONS_LIST_YIELD_BATCH_SIZE === 0 && i + 1 < entries.length) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ts: now,
|
||||
path: storePath,
|
||||
count: sessions.length,
|
||||
totalCount,
|
||||
limitApplied,
|
||||
offset: offset > 0 ? offset : undefined,
|
||||
nextOffset,
|
||||
hasMore,
|
||||
defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }),
|
||||
sessions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,8 +149,9 @@ export async function handleSessionHistoryHttpRequest(
|
||||
? await readRecentSessionMessagesWithStatsAsync(
|
||||
{
|
||||
agentId: target.agentId,
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: target.canonicalKey,
|
||||
storePath: target.storePath,
|
||||
},
|
||||
{
|
||||
@@ -166,8 +167,9 @@ export async function handleSessionHistoryHttpRequest(
|
||||
? await readSessionMessagesWithSourceAsync(
|
||||
{
|
||||
agentId: target.agentId,
|
||||
sessionFile: entry.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: target.canonicalKey,
|
||||
storePath: target.storePath,
|
||||
},
|
||||
{
|
||||
@@ -213,9 +215,10 @@ export async function handleSessionHistoryHttpRequest(
|
||||
const sseState = SessionHistorySseState.fromRawSnapshot({
|
||||
target: {
|
||||
agentId: target.agentId,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey: target.canonicalKey,
|
||||
storePath: target.storePath,
|
||||
sessionFile: entry.sessionFile,
|
||||
},
|
||||
rawMessages: rawSnapshot,
|
||||
rawTranscriptSeq: boundedSnapshot?.totalMessages,
|
||||
|
||||
@@ -322,8 +322,10 @@ const readUsageFromSessionLog = (
|
||||
const snapshot = readRecentSessionUsageFromTranscript(
|
||||
{
|
||||
agentId: agentId ?? (sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined),
|
||||
sessionFile: sessionEntry?.sessionFile,
|
||||
sessionEntry,
|
||||
sessionFile: logPath,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
},
|
||||
256 * 1024,
|
||||
|
||||
@@ -674,8 +674,9 @@ describe("EmbeddedTuiBackend", () => {
|
||||
expect(readSessionMessagesAsyncMock).toHaveBeenCalledWith(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionFile: undefined,
|
||||
sessionEntry: { sessionId: "sess-main" },
|
||||
sessionId: "sess-main",
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/openclaw-sessions.json",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -450,8 +450,9 @@ export class EmbeddedTuiBackend implements TuiBackend {
|
||||
? await readSessionMessagesAsync(
|
||||
{
|
||||
agentId: sessionAgentId,
|
||||
sessionFile: entry?.sessionFile,
|
||||
sessionEntry: entry,
|
||||
sessionId,
|
||||
sessionKey: canonicalKey,
|
||||
storePath,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -43,10 +43,20 @@ describe("session accessor boundary guard", () => {
|
||||
"src/cron/isolated-agent/delivery-target.ts",
|
||||
"src/cron/service/timer.ts",
|
||||
"src/gateway/session-compaction-checkpoints.ts",
|
||||
"src/gateway/session-history-state.ts",
|
||||
"src/gateway/session-utils.ts",
|
||||
"src/gateway/managed-image-attachments.ts",
|
||||
"src/gateway/server-methods/artifacts.ts",
|
||||
"src/gateway/server-methods/chat.ts",
|
||||
"src/gateway/sessions-resolve.ts",
|
||||
"src/gateway/server-methods/sessions-files.ts",
|
||||
"src/gateway/server-methods/sessions.ts",
|
||||
"src/gateway/server-session-events.ts",
|
||||
"src/gateway/session-reset-service.ts",
|
||||
"src/infra/outbound/message-action-tts.ts",
|
||||
"src/agents/tools/embedded-gateway-stub.ts",
|
||||
"src/agents/tools/sessions-list-tool.ts",
|
||||
"src/status/status-message.ts",
|
||||
"src/tui/embedded-backend.ts",
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -128,4 +128,22 @@ describe("session transcript reader boundary guard", () => {
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags storage-specific reader aliases in migrated files", () => {
|
||||
expect(
|
||||
findSessionTranscriptReaderBoundaryViolations(`
|
||||
import { readSessionMessagesAsync as readSessionMessagesFromFileAsync } from "./session-transcript-readers.js";
|
||||
await readSessionMessagesFromFileAsync(scope, opts);
|
||||
`),
|
||||
).toEqual([
|
||||
{
|
||||
line: 2,
|
||||
reason: 'uses storage-specific transcript reader alias "readSessionMessagesFromFileAsync"',
|
||||
},
|
||||
{
|
||||
line: 3,
|
||||
reason: 'uses storage-specific transcript reader alias "readSessionMessagesFromFileAsync"',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user