mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
refactor: route session status through session accessors (#96460)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user