refactor: route session status through session accessors (#96460)

This commit is contained in:
Josh Lehman
2026-06-24 07:44:19 -07:00
committed by GitHub
parent d83cd282c6
commit b58e6e0734
8 changed files with 473 additions and 165 deletions
@@ -117,6 +117,7 @@ export const migratedSessionAccessorFiles = new Set([
"src/gateway/session-reset-service.ts",
"src/infra/outbound/message-action-tts.ts",
"src/agents/tools/embedded-gateway-stub.ts",
"src/agents/tools/session-status-tool.ts",
"src/agents/tools/sessions-list-tool.ts",
"src/plugins/host-hook-state.ts",
"src/status/status-message.ts",
@@ -142,6 +143,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
"src/auto-reply/reply/abort.ts",
"src/agents/subagent-control.ts",
"src/agents/subagent-registry-helpers.ts",
"src/agents/tools/session-status-tool.ts",
"src/auto-reply/reply/abort-cutoff.runtime.ts",
"src/auto-reply/reply/agent-runner-cli-dispatch.ts",
"src/auto-reply/reply/agent-runner-execution.ts",
@@ -99,9 +99,77 @@ function installScopedSessionStores(syncUpdates = false) {
async function createSessionsModuleMock() {
const actual =
await vi.importActual<typeof import("../config/sessions.js")>("../config/sessions.js");
const resolveMockStorePath = (_store: string | undefined, opts?: { agentId?: string }) =>
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json";
const cloneEntry = (entry: SessionEntry): SessionEntry => structuredClone(entry);
return {
...actual,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
patchSessionEntryWithKey: async (
scope: { agentId?: string; sessionKey: string; storePath?: string },
update: (
entry: SessionEntry,
context: { existingEntry?: SessionEntry },
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | null,
options?: { fallbackEntry?: SessionEntry; replaceEntry?: boolean },
) => {
const storePath =
scope.storePath ?? resolveMockStorePath(undefined, { agentId: scope.agentId });
const store = loadSessionStoreMock(storePath) as Record<string, SessionEntry>;
const resolved = actual.resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey });
const existing = resolved.existing ?? options?.fallbackEntry;
if (!existing) {
return null;
}
const patch = await update(cloneEntry(existing), {
existingEntry: resolved.existing ? cloneEntry(resolved.existing) : undefined,
});
if (!patch) {
return { sessionKey: resolved.normalizedKey, entry: cloneEntry(existing) };
}
const next = options?.replaceEntry
? cloneEntry(patch as SessionEntry)
: actual.mergeSessionEntry(existing, patch);
store[resolved.normalizedKey] = next;
updateSessionStoreMock(storePath, store);
return { sessionKey: resolved.normalizedKey, entry: cloneEntry(next) };
},
resolveSessionEntryCandidateTarget: (scope: {
agentId: string;
candidateKeys: readonly string[];
cfg: { session?: { store?: string } };
fallback?: { sessionKey: string; entry: SessionEntry };
}) => {
const storePath = resolveMockStorePath(scope.cfg.session?.store, { agentId: scope.agentId });
const store = loadSessionStoreMock(storePath) as Record<string, SessionEntry>;
const candidates = [...new Set(scope.candidateKeys.map((key) => key.trim()))];
for (const candidateKey of candidates) {
if (!candidateKey) {
continue;
}
const resolved = actual.resolveSessionStoreEntry({ store, sessionKey: candidateKey });
if (!resolved.existing) {
continue;
}
return {
agentId: scope.agentId,
candidateKey,
entry: cloneEntry(resolved.existing),
persisted: true,
sessionKey: resolved.normalizedKey,
};
}
const fallbackKey = scope.fallback?.sessionKey.trim();
return fallbackKey && scope.fallback
? {
agentId: scope.agentId,
candidateKey: fallbackKey,
entry: cloneEntry(scope.fallback.entry),
persisted: false,
sessionKey: fallbackKey,
}
: null;
},
updateSessionStore: async (
storePath: string,
mutator: (store: Record<string, unknown>) => Promise<void> | void,
@@ -111,8 +179,7 @@ async function createSessionsModuleMock() {
updateSessionStoreMock(storePath, store);
return store;
},
resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) =>
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json",
resolveStorePath: resolveMockStorePath,
};
}
@@ -1191,6 +1258,41 @@ describe("session_status tool", () => {
expect(saved.sessionId).toMatch(UUID_RE);
});
it("preserves an existing legacy main row when implicit fallback mutates model state", async () => {
resetSessionStore({
main: {
sessionId: "legacy-main-session",
updatedAt: 10,
label: "Legacy Main",
lastChannel: "telegram",
},
});
const tool = getSessionStatusTool("agent:main:main");
const result = await tool.execute("call-legacy-main-fallback-model", {
model: "anthropic/claude-sonnet-4-6",
});
const details = result.details as {
ok?: boolean;
sessionKey?: string;
modelOverride?: string | null;
};
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("main");
expect(details.modelOverride).toBe("anthropic/claude-sonnet-4-6");
expect(updateSessionStoreMock).toHaveBeenCalledTimes(1);
const savedStore = latestMockCallArg(updateSessionStoreMock, 1) as Record<string, SessionEntry>;
expect(savedStore.main).toMatchObject({
sessionId: "legacy-main-session",
label: "Legacy Main",
lastChannel: "telegram",
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-6",
liveModelSwitchPending: true,
});
});
it("fires session:patch when session_status changes the persisted session model", async () => {
const events: InternalHookEvent[] = [];
registerInternalHook("session:patch", async (event) => {
@@ -0,0 +1,154 @@
// Status-tool session resolution helpers keep storage lookup out of the tool body.
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { resolveSessionEntryCandidateTarget, type SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { resolveInternalSessionKey } from "./sessions-helpers.js";
export type ResolvedStatusSessionEntry = {
entry: SessionEntry;
key: string;
persisted: boolean;
};
/** Resolves one status lookup against ordered tool-local session key candidates. */
export function resolveSessionStatusEntry(params: {
agentId: string;
alias: string;
cfg: OpenClawConfig;
includeAliasFallback?: boolean;
keyRaw: string;
mainKey: string;
requesterInternalKey?: string;
}): ResolvedStatusSessionEntry | null {
const keyRaw = params.keyRaw.trim();
if (!keyRaw) {
return null;
}
const includeAliasFallback = params.includeAliasFallback ?? true;
const internal = resolveInternalSessionKey({
key: keyRaw,
alias: params.alias,
mainKey: params.mainKey,
requesterInternalKey: params.requesterInternalKey,
});
const candidates: string[] = [keyRaw];
if (!keyRaw.startsWith("agent:")) {
candidates.push(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`);
}
if (includeAliasFallback && internal !== keyRaw) {
candidates.push(internal);
}
if (includeAliasFallback && !keyRaw.startsWith("agent:")) {
const agentInternal = `agent:${DEFAULT_AGENT_ID}:${internal}`;
const agentRaw = `agent:${DEFAULT_AGENT_ID}:${keyRaw}`;
if (agentInternal !== agentRaw) {
candidates.push(agentInternal);
}
}
if (includeAliasFallback && (keyRaw === "main" || keyRaw === "current")) {
const defaultMainKey = buildAgentMainSessionKey({
agentId: DEFAULT_AGENT_ID,
mainKey: params.mainKey,
});
if (!candidates.includes(defaultMainKey)) {
candidates.push(defaultMainKey);
}
}
const resolved = resolveSessionEntryCandidateTarget({
agentId: params.agentId,
candidateKeys: candidates,
cfg: params.cfg,
});
return resolved
? {
entry: resolved.entry,
key: resolved.sessionKey,
persisted: resolved.persisted,
}
: null;
}
/** Maps requester keys into the currently selected agent store's legacy main key shape. */
export function resolveStoreScopedRequesterKey(params: {
agentId: string;
mainKey: string;
requesterKey: string;
}) {
const parsed = parseAgentSessionKey(params.requesterKey);
if (!parsed || parsed.agentId !== params.agentId) {
return params.requesterKey;
}
return parsed.rest === params.mainKey ? params.mainKey : params.requesterKey;
}
function synthesizeImplicitCurrentSessionEntry(): SessionEntry {
return {
sessionId: "",
updatedAt: Date.now(),
};
}
/** Returns a synthesized current-session entry without writing it to storage. */
export function resolveImplicitCurrentSessionFallback(params: {
agentId: string;
allowFallback: boolean;
cfg: OpenClawConfig;
fallbackKey: string;
}): ResolvedStatusSessionEntry | null {
const fallbackKey = params.fallbackKey.trim();
if (!params.allowFallback || !fallbackKey) {
return null;
}
const resolved = resolveSessionEntryCandidateTarget({
agentId: params.agentId,
candidateKeys: [],
cfg: params.cfg,
fallback: {
sessionKey: fallbackKey,
entry: synthesizeImplicitCurrentSessionEntry(),
},
});
return resolved
? {
entry: resolved.entry,
key: resolved.sessionKey,
persisted: resolved.persisted,
}
: null;
}
/** Lists policy-key fallbacks for implicit default-account direct status lookups. */
export function listImplicitDefaultDirectFallbackKeys(params: {
keyRaw: string;
mainKey: string;
}): string[] {
const parsed = parseAgentSessionKey(params.keyRaw.trim());
if (!parsed) {
return [];
}
const parts = parsed.rest.split(":");
if (parts.length < 4 || parts[1] !== "default" || parts[2] !== "direct") {
return [];
}
const channel = parts[0];
const peerParts = parts.slice(3);
if (!channel || peerParts.length === 0) {
return [];
}
const candidates = [
`agent:${parsed.agentId}:${channel}:direct:${peerParts.join(":")}`,
buildAgentMainSessionKey({
agentId: parsed.agentId,
mainKey: params.mainKey,
}),
params.mainKey,
];
return uniqueStrings(candidates);
}
+75 -163
View File
@@ -3,8 +3,8 @@
*
* Reports and updates session runtime state, model overrides, visibility, task status, and delivery context.
*/
import { randomUUID } from "node:crypto";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { Type } from "typebox";
import type {
ElevatedLevel,
@@ -14,11 +14,9 @@ import type {
} from "../../auto-reply/thinking.js";
import { getRuntimeConfig } from "../../config/config.js";
import {
loadSessionStore,
mergeSessionEntry,
patchSessionEntryWithKey,
resolveStorePath,
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js";
@@ -26,7 +24,6 @@ import { resolveSessionModelIdentityRef } from "../../gateway/session-utils.js";
import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js";
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
@@ -59,12 +56,17 @@ import {
} from "../tool-description-presets.js";
import type { AnyAgentTool } from "./common.js";
import { normalizeToolModelOverride, readStringParam } from "./common.js";
import {
listImplicitDefaultDirectFallbackKeys,
resolveImplicitCurrentSessionFallback,
resolveSessionStatusEntry,
resolveStoreScopedRequesterKey,
} from "./session-status-session-resolve.js";
import {
createAgentToAgentPolicy,
createSessionVisibilityGuard,
resolveCurrentSessionClientAlias,
resolveEffectiveSessionToolsVisibility,
resolveInternalSessionKey,
resolveSandboxedSessionToolContext,
resolveSessionReference,
resolveVisibleSessionReference,
@@ -88,121 +90,6 @@ function loadCommandsStatusRuntime(): Promise<CommandsStatusRuntimeModule> {
return commandsStatusRuntimeLoader.load();
}
function resolveSessionEntry(params: {
store: Record<string, SessionEntry>;
keyRaw: string;
alias: string;
mainKey: string;
requesterInternalKey?: string;
includeAliasFallback?: boolean;
}): { key: string; entry: SessionEntry } | null {
const keyRaw = params.keyRaw.trim();
if (!keyRaw) {
return null;
}
const includeAliasFallback = params.includeAliasFallback ?? true;
const internal = resolveInternalSessionKey({
key: keyRaw,
alias: params.alias,
mainKey: params.mainKey,
requesterInternalKey: params.requesterInternalKey,
});
const candidates: string[] = [keyRaw];
if (!keyRaw.startsWith("agent:")) {
candidates.push(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`);
}
if (includeAliasFallback && internal !== keyRaw) {
candidates.push(internal);
}
if (includeAliasFallback && !keyRaw.startsWith("agent:")) {
const agentInternal = `agent:${DEFAULT_AGENT_ID}:${internal}`;
const agentRaw = `agent:${DEFAULT_AGENT_ID}:${keyRaw}`;
if (agentInternal !== agentRaw) {
candidates.push(agentInternal);
}
}
if (includeAliasFallback && (keyRaw === "main" || keyRaw === "current")) {
const defaultMainKey = buildAgentMainSessionKey({
agentId: DEFAULT_AGENT_ID,
mainKey: params.mainKey,
});
if (!candidates.includes(defaultMainKey)) {
candidates.push(defaultMainKey);
}
}
for (const key of candidates) {
const entry = params.store[key];
if (entry) {
return { key, entry };
}
}
return null;
}
function resolveStoreScopedRequesterKey(params: {
requesterKey: string;
agentId: string;
mainKey: string;
}) {
const parsed = parseAgentSessionKey(params.requesterKey);
if (!parsed || parsed.agentId !== params.agentId) {
return params.requesterKey;
}
return parsed.rest === params.mainKey ? params.mainKey : params.requesterKey;
}
function synthesizeImplicitCurrentSessionEntry(): SessionEntry {
return {
sessionId: "",
updatedAt: Date.now(),
};
}
function resolveImplicitCurrentSessionFallback(params: {
allowFallback: boolean;
fallbackKey: string;
}): { key: string; entry: SessionEntry } | null {
const fallbackKey = params.fallbackKey.trim();
if (!params.allowFallback || !fallbackKey) {
return null;
}
return {
key: fallbackKey,
entry: synthesizeImplicitCurrentSessionEntry(),
};
}
function listImplicitDefaultDirectFallbackKeys(params: {
keyRaw: string;
mainKey: string;
}): string[] {
const parsed = parseAgentSessionKey(params.keyRaw.trim());
if (!parsed) {
return [];
}
const parts = parsed.rest.split(":");
if (parts.length < 4 || parts[1] !== "default" || parts[2] !== "direct") {
return [];
}
const channel = parts[0];
const peerParts = parts.slice(3);
if (!channel || peerParts.length === 0) {
return [];
}
const candidates = [
`agent:${parsed.agentId}:${channel}:direct:${peerParts.join(":")}`,
buildAgentMainSessionKey({
agentId: parsed.agentId,
mainKey: params.mainKey,
}),
params.mainKey,
];
return uniqueStrings(candidates);
}
type ActiveStatusModelIdentity = { provider?: string; model: string };
type SessionStatusOriginDetails = {
@@ -642,7 +529,6 @@ export function createSessionStatusTool(opts?: {
? resolveAgentIdFromSessionKey(requestedKeyInput)
: requesterAgentId;
let storePath = resolveStorePath(cfg.session?.store, { agentId });
let store = loadSessionStore(storePath);
let storeScopedRequesterKey = resolveStoreScopedRequesterKey({
requesterKey: effectiveRequesterKey,
agentId,
@@ -650,8 +536,9 @@ export function createSessionStatusTool(opts?: {
});
// Resolve against the requester-scoped store first to avoid leaking default agent data.
let resolved = resolveSessionEntry({
store,
let resolved = resolveSessionStatusEntry({
cfg,
agentId,
keyRaw: requestedKeyRaw,
alias,
mainKey,
@@ -687,14 +574,14 @@ export function createSessionStatusTool(opts?: {
requestedKeyInput = requestedKeyRaw.trim();
agentId = resolveAgentIdFromSessionKey(visibleSession.key);
storePath = resolveStorePath(cfg.session?.store, { agentId });
store = loadSessionStore(storePath);
storeScopedRequesterKey = resolveStoreScopedRequesterKey({
requesterKey: effectiveRequesterKey,
agentId,
mainKey,
});
resolved = resolveSessionEntry({
store,
resolved = resolveSessionStatusEntry({
cfg,
agentId,
keyRaw: requestedKeyRaw,
alias,
mainKey,
@@ -706,8 +593,9 @@ export function createSessionStatusTool(opts?: {
}
if (!resolved && requestedKeyInput === "current" && effectiveRequesterLookupKey) {
resolved = resolveSessionEntry({
store,
resolved = resolveSessionStatusEntry({
cfg,
agentId,
keyRaw: effectiveRequesterLookupKey,
alias,
mainKey,
@@ -717,8 +605,9 @@ export function createSessionStatusTool(opts?: {
}
if (!resolved && requestedKeyInput === "current") {
resolved = resolveSessionEntry({
store,
resolved = resolveSessionStatusEntry({
cfg,
agentId,
keyRaw: requestedKeyRaw,
alias,
mainKey,
@@ -732,8 +621,9 @@ export function createSessionStatusTool(opts?: {
keyRaw: requestedKeyRaw,
mainKey,
})) {
resolved = resolveSessionEntry({
store,
resolved = resolveSessionStatusEntry({
cfg,
agentId,
keyRaw: fallbackKey,
alias,
mainKey,
@@ -750,7 +640,9 @@ export function createSessionStatusTool(opts?: {
if (!resolved) {
const runSessionFallbackKey = opts?.runSessionKey?.trim();
const fallback = resolveImplicitCurrentSessionFallback({
agentId,
allowFallback: isSemanticCurrentRequest || requestedKeyParam === undefined,
cfg,
fallbackKey:
(isSemanticCurrentRequest || isImplicitRunSessionStatus) && runSessionFallbackKey
? runSessionFallbackKey
@@ -793,46 +685,66 @@ export function createSessionStatusTool(opts?: {
sessionEntry: resolved.entry,
agentId,
});
const modelSelection =
selection.kind === "reset"
? {
provider: configured.provider,
model: configured.model,
isDefault: true,
}
: {
provider: selection.provider,
model: selection.model,
isDefault: selection.isDefault,
};
const nextEntry: SessionEntry = { ...resolved.entry };
const applied = applyModelOverrideToSessionEntry({
entry: nextEntry,
selection:
selection.kind === "reset"
? {
provider: configured.provider,
model: configured.model,
isDefault: true,
}
: {
provider: selection.provider,
model: selection.model,
isDefault: selection.isDefault,
},
selection: modelSelection,
markLiveSwitchPending: true,
});
if (applied.updated) {
const persistedEntry = nextEntry.sessionId.trim()
? nextEntry
: (() => {
const persistedEntryPatch: Partial<SessionEntry> = { ...nextEntry };
delete persistedEntryPatch.sessionId;
const existingEntry = store[resolved.key];
const existingWithValidSessionId = existingEntry?.sessionId?.trim()
? existingEntry
: undefined;
return mergeSessionEntry(existingWithValidSessionId, persistedEntryPatch);
})();
store[resolved.key] = persistedEntry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[resolved.key] = persistedEntry;
});
resolved.entry = persistedEntry;
const patchResult = await patchSessionEntryWithKey(
{
agentId,
sessionKey: resolved.key,
storePath,
},
(entry, context) => {
const persistedEntryPatch: SessionEntry = { ...entry };
applyModelOverrideToSessionEntry({
entry: persistedEntryPatch,
selection: modelSelection,
markLiveSwitchPending: true,
});
if (
!persistedEntryPatch.sessionId.trim() &&
!context.existingEntry?.sessionId?.trim()
) {
persistedEntryPatch.sessionId = randomUUID();
}
return persistedEntryPatch;
},
{
fallbackEntry: resolved.persisted ? undefined : resolved.entry,
replaceEntry: true,
},
);
if (!patchResult) {
throw new Error(`Unknown sessionKey: ${resolved.key}`);
}
const persistedEntry = patchResult.entry;
resolved = {
entry: persistedEntry,
key: patchResult.sessionKey,
persisted: true,
};
triggerSessionPatchHook({
cfg,
sessionEntry: persistedEntry,
sessionKey: resolved.key,
sessionKey: patchResult.sessionKey,
patch: {
key: resolved.key,
key: patchResult.sessionKey,
model: selection.kind === "reset" ? null : `${selection.provider}/${selection.model}`,
},
});
+4
View File
@@ -13,12 +13,16 @@ export * from "./sessions/reset.js";
export {
canonicalizeSessionEntryAliases,
deleteSessionEntryLifecycle,
patchSessionEntryWithKey,
resetSessionEntryLifecycle,
resolveSessionEntryCandidateTarget,
type CanonicalizeSessionEntryAliasesResult,
type DeleteSessionEntryLifecycleParams,
type DeleteSessionEntryLifecycleResult,
type ResolvedSessionEntryCandidateTarget,
type ResetSessionEntryLifecycleParams,
type ResetSessionEntryLifecycleResult,
type SessionEntryCandidateAccessScope,
type SessionLifecycleArchivedTranscript,
type SessionLifecycleStoreTarget,
} from "./sessions/session-accessor.js";
@@ -28,6 +28,7 @@ import {
publishTranscriptUpdate,
readSessionUpdatedAt,
replaceSessionEntry,
resolveSessionEntryCandidateTarget,
resolveSessionEntryAccessTarget,
restoreSessionFromCompactionCheckpoint,
resolveSessionTranscriptReadTarget,
@@ -226,6 +227,69 @@ describe("session accessor file-backed seam", () => {
expect(persisted.main).toBeUndefined();
});
it("resolves status-style ordered candidate keys without exposing the store", async () => {
fs.writeFileSync(
storePath,
JSON.stringify({
"agent:main:current": {
label: "literal-current",
sessionId: "session-current",
updatedAt: 30,
},
"agent:main:main": {
label: "main",
sessionId: "session-main",
updatedAt: 10,
},
} satisfies Record<string, SessionEntry>),
"utf8",
);
const resolved = resolveSessionEntryCandidateTarget({
agentId: "main",
candidateKeys: ["agent:main:main", "agent:main:current"],
cfg: { session: { store: storePath } },
});
expect(resolved).toEqual({
agentId: "main",
candidateKey: "agent:main:main",
entry: expect.objectContaining({
label: "main",
sessionId: "session-main",
}),
persisted: true,
sessionKey: "agent:main:main",
});
});
it("returns an implicit candidate fallback without persisting it", () => {
const resolved = resolveSessionEntryCandidateTarget({
agentId: "main",
candidateKeys: ["agent:main:missing"],
cfg: { session: { store: storePath } },
fallback: {
sessionKey: "agent:main:current",
entry: {
sessionId: "",
updatedAt: 40,
},
},
});
expect(resolved).toEqual({
agentId: "main",
candidateKey: "agent:main:current",
entry: {
sessionId: "",
updatedAt: 40,
},
persisted: false,
sessionKey: "agent:main:current",
});
expect(fs.existsSync(storePath)).toBe(false);
});
it("purges deleted-agent entries from the current locked store", async () => {
const cfg = {
session: { store: storePath },
+68
View File
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import {
acquireSessionWriteLock,
resolveSessionWriteLockOptions,
@@ -159,6 +160,35 @@ type ResolvedSessionEntryStoreTarget = ResolvedSessionEntryAccessTarget & {
storePath: string;
};
export type SessionEntryCandidateAccessScope = {
/** Agent owner whose session store is searched. */
agentId: string;
/** Ordered session keys to test inside the resolved store. */
candidateKeys: readonly string[];
/** Runtime config whose session store rule selects the backend target. */
cfg: OpenClawConfig;
/** Environment override used when resolving agent-scoped store paths in tests/tools. */
env?: NodeJS.ProcessEnv;
/** Optional synthesized entry returned only when no candidate exists. */
fallback?: {
entry: SessionEntry;
sessionKey: string;
};
};
export type ResolvedSessionEntryCandidateTarget = {
/** Agent owner whose session store produced this result. */
agentId: string;
/** Candidate key that selected the result, or the fallback key. */
candidateKey: string;
/** Session metadata cloned from storage or from the synthesized fallback. */
entry: SessionEntry;
/** False only for synthesized fallback entries that have not been written. */
persisted: boolean;
/** Persisted key selected by the backend, or the fallback key. */
sessionKey: string;
};
export type ResolvedSessionEntryUpdateContext = Omit<ResolvedSessionEntryAccessTarget, "entry"> & {
/** Mutable entry inside the storage operation. */
entry: SessionEntry;
@@ -747,6 +777,44 @@ export function resolveSessionEntryAccessTarget(
};
}
/** Resolves ordered candidate keys inside one agent-owned session store. */
export function resolveSessionEntryCandidateTarget(
scope: SessionEntryCandidateAccessScope,
): ResolvedSessionEntryCandidateTarget | null {
const storePath = resolveStorePath(scope.cfg.session?.store, {
agentId: scope.agentId,
env: scope.env,
});
const store = loadSessionStore(storePath);
for (const candidateKey of uniqueStrings(scope.candidateKeys.map((key) => key.trim()))) {
if (!candidateKey) {
continue;
}
const resolved = resolveSessionStoreEntry({ store, sessionKey: candidateKey });
if (!resolved.existing) {
continue;
}
return {
agentId: scope.agentId,
candidateKey,
entry: structuredClone(resolved.existing),
persisted: true,
sessionKey: resolved.normalizedKey,
};
}
const fallbackKey = scope.fallback?.sessionKey.trim();
if (!fallbackKey || !scope.fallback) {
return null;
}
return {
agentId: scope.agentId,
candidateKey: fallbackKey,
entry: structuredClone(scope.fallback.entry),
persisted: false,
sessionKey: fallbackKey,
};
}
function resolveSessionEntryStoreTarget(
scope: LogicalSessionAccessScope,
): ResolvedSessionEntryStoreTarget {
@@ -67,6 +67,7 @@ describe("session accessor boundary guard", () => {
"src/gateway/session-reset-service.ts",
"src/infra/outbound/message-action-tts.ts",
"src/agents/tools/embedded-gateway-stub.ts",
"src/agents/tools/session-status-tool.ts",
"src/agents/tools/sessions-list-tool.ts",
"src/plugins/host-hook-state.ts",
"src/status/status-message.ts",
@@ -100,6 +101,7 @@ describe("session accessor boundary guard", () => {
"src/auto-reply/reply/abort.ts",
"src/agents/subagent-control.ts",
"src/agents/subagent-registry-helpers.ts",
"src/agents/tools/session-status-tool.ts",
"src/auto-reply/reply/abort-cutoff.runtime.ts",
"src/auto-reply/reply/agent-runner-cli-dispatch.ts",
"src/auto-reply/reply/agent-runner-execution.ts",