Files
openclaw/src/auto-reply/reply/agent-runner-session-reset.ts
Val Alexander d12c92c216 fix(usage): roll up session lineage history
Summary:
- Roll up transcript-backed session usage across logical session lineage.
- Preserve lineage through /new and /reset rotations.
- Add Control UI usage scope controls with legacy gateway fallback.
- Refresh generated protocol and Control UI locale fallback surfaces.

Verification:
- pnpm test src/auto-reply/reply/session.test.ts ui/src/ui/controllers/usage.node.test.ts src/gateway/server-methods/usage.sessions-usage.test.ts
- pnpm protocol:check
- pnpm ui:i18n:check
- pnpm ui:build
- git diff --check
- PR CI green on 10f10850ee

Closes #50701.
2026-05-07 22:38:11 -05:00

143 lines
4.7 KiB
TypeScript

import fs from "node:fs";
import type { SessionEntry } from "../../config/sessions.js";
import {
resolveAgentIdFromSessionKey,
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptPath,
updateSessionStore,
} from "../../config/sessions.js";
import { generateSecureUuid } from "../../infra/secure-random.js";
import { defaultRuntime } from "../../runtime.js";
import { refreshQueuedFollowupSession, type FollowupRun } from "./queue.js";
import { replayRecentUserAssistantMessages } from "./session-transcript-replay.js";
type ResetSessionOptions = {
failureLabel: string;
buildLogMessage: (nextSessionId: string) => string;
cleanupTranscripts?: boolean;
};
const deps = {
generateSecureUuid,
updateSessionStore,
refreshQueuedFollowupSession,
error: (message: string) => defaultRuntime.error(message),
};
export function setAgentRunnerSessionResetTestDeps(overrides?: Partial<typeof deps>): void {
Object.assign(deps, {
generateSecureUuid,
updateSessionStore,
refreshQueuedFollowupSession,
error: (message: string) => defaultRuntime.error(message),
...overrides,
});
}
export async function resetReplyRunSession(params: {
options: ResetSessionOptions;
sessionKey?: string;
queueKey: string;
activeSessionEntry?: SessionEntry;
activeSessionStore?: Record<string, SessionEntry>;
storePath?: string;
messageThreadId?: string;
followupRun: FollowupRun;
onActiveSessionEntry: (entry: SessionEntry) => void;
onNewSession: (newSessionId: string, nextSessionFile: string) => void;
}): Promise<boolean> {
if (!params.sessionKey || !params.activeSessionStore || !params.storePath) {
return false;
}
const prevEntry = params.activeSessionStore[params.sessionKey] ?? params.activeSessionEntry;
if (!prevEntry) {
return false;
}
const prevSessionId = params.options.cleanupTranscripts ? prevEntry.sessionId : undefined;
const nextSessionId = deps.generateSecureUuid();
const now = Date.now();
const nextEntry: SessionEntry = {
...prevEntry,
sessionId: nextSessionId,
updatedAt: now,
sessionStartedAt: now,
usageFamilyKey: prevEntry.usageFamilyKey ?? params.sessionKey,
usageFamilySessionIds: Array.from(
new Set([...(prevEntry.usageFamilySessionIds ?? []), prevEntry.sessionId, nextSessionId]),
),
lastInteractionAt: now,
systemSent: false,
abortedLastRun: false,
modelProvider: undefined,
model: undefined,
inputTokens: undefined,
outputTokens: undefined,
totalTokens: undefined,
totalTokensFresh: false,
estimatedCostUsd: undefined,
cacheRead: undefined,
cacheWrite: undefined,
contextTokens: undefined,
systemPromptReport: undefined,
fallbackNoticeSelectedModel: undefined,
fallbackNoticeActiveModel: undefined,
fallbackNoticeReason: undefined,
};
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
const nextSessionFile = resolveSessionTranscriptPath(
nextSessionId,
agentId,
params.messageThreadId,
);
nextEntry.sessionFile = nextSessionFile;
params.activeSessionStore[params.sessionKey] = nextEntry;
try {
await deps.updateSessionStore(params.storePath, (store) => {
store[params.sessionKey!] = nextEntry;
});
} catch (err) {
deps.error(
`Failed to persist session reset after ${params.options.failureLabel} (${params.sessionKey}): ${String(err)}`,
);
}
// Silent rotations (compaction/role-ordering) fire without user intent, so
// preserve recent user/assistant turns for direct-chat continuity.
await replayRecentUserAssistantMessages({
sourceTranscript: prevEntry.sessionFile,
targetTranscript: nextSessionFile,
newSessionId: nextSessionId,
});
params.followupRun.run.sessionId = nextSessionId;
params.followupRun.run.sessionFile = nextSessionFile;
deps.refreshQueuedFollowupSession({
key: params.queueKey,
previousSessionId: prevEntry.sessionId,
nextSessionId,
nextSessionFile,
});
params.onActiveSessionEntry(nextEntry);
params.onNewSession(nextSessionId, nextSessionFile);
deps.error(params.options.buildLogMessage(nextSessionId));
if (params.options.cleanupTranscripts && prevSessionId) {
const transcriptCandidates = new Set<string>();
const resolved = resolveSessionFilePath(
prevSessionId,
prevEntry,
resolveSessionFilePathOptions({ agentId, storePath: params.storePath }),
);
if (resolved) {
transcriptCandidates.add(resolved);
}
transcriptCandidates.add(resolveSessionTranscriptPath(prevSessionId, agentId));
for (const candidate of transcriptCandidates) {
try {
fs.unlinkSync(candidate);
} catch {
// Best-effort cleanup.
}
}
}
return true;
}