From c79a5f324e4a4891d8720f10e78378bdf5b353e6 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 19 Apr 2026 17:59:46 -0400 Subject: [PATCH] fix: canonicalize legacy main session aliases --- src/commands/agents.command-shared.ts | 14 +++- src/commands/agents.delete.test.ts | 37 +++++++++- .../sessions.send-followup-status.test.ts | 1 + src/gateway/session-store-key.ts | 29 ++++++++ src/gateway/session-utils.ts | 14 +++- src/gateway/sessions-resolve-store.test.ts | 70 +++++++++++++++++++ 6 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 src/gateway/sessions-resolve-store.test.ts diff --git a/src/commands/agents.command-shared.ts b/src/commands/agents.command-shared.ts index c26d6064375..8b69f50070d 100644 --- a/src/commands/agents.command-shared.ts +++ b/src/commands/agents.command-shared.ts @@ -1,7 +1,8 @@ import { resolveStorePath, updateSessionStore } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveStoredSessionOwnerAgentId } from "../gateway/session-store-key.js"; import { getLogger } from "../logging/logger.js"; -import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { requireValidConfigFileSnapshot as requireValidConfigFileSnapshotBase, @@ -26,10 +27,17 @@ export async function purgeAgentSessionStoreEntries( agentId: string, ): Promise { try { - const storePath = resolveStorePath(cfg.session?.store, { agentId }); + const normalizedAgentId = normalizeAgentId(agentId); + const storePath = resolveStorePath(cfg.session?.store, { agentId: normalizedAgentId }); await updateSessionStore(storePath, (store) => { for (const key of Object.keys(store)) { - if (resolveAgentIdFromSessionKey(key) === agentId) { + if ( + resolveStoredSessionOwnerAgentId({ + cfg, + agentId: normalizedAgentId, + sessionKey: key, + }) === normalizedAgentId + ) { delete store[key]; } } diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index 275ca8b7d4c..605dd5c6e5d 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -41,7 +41,7 @@ describe("agents delete command", () => { it("purges deleted agent entries from the session store", async () => { await withStateDirEnv("openclaw-agents-delete-", async ({ stateDir }) => { - const cfg = { + const cfg: OpenClawConfig = { agents: { list: [ { id: "main", workspace: path.join(stateDir, "workspace-main") }, @@ -81,4 +81,39 @@ describe("agents delete command", () => { }); }); }); + + it("purges legacy main-alias entries owned by the deleted default agent", async () => { + await withStateDirEnv("openclaw-agents-delete-main-alias-", async ({ stateDir }) => { + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "ops", default: true, workspace: path.join(stateDir, "workspace-ops") }], + }, + }; + const storePath = resolveStorePath(cfg.session?.store, { agentId: "ops" }); + await saveSessionStore(storePath, { + "agent:main:main": { sessionId: "sess-default-alias", updatedAt: 1 }, + "agent:ops:discord:direct:u1": { sessionId: "sess-ops-direct", updatedAt: 2 }, + "agent:main:discord:direct:u2": { sessionId: "sess-stale-main", updatedAt: 3 }, + global: { sessionId: "sess-global", updatedAt: 4 }, + }); + await fs.mkdir(path.join(stateDir, "workspace-ops"), { recursive: true }); + await fs.mkdir(path.join(stateDir, "agents", "ops", "agent"), { recursive: true }); + + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: cfg, + runtimeConfig: cfg, + sourceConfig: cfg, + resolved: cfg, + }); + + await agentsDeleteCommand({ id: "ops", force: true, json: true }, runtime); + + expect(runtime.exit).not.toHaveBeenCalled(); + expect(loadSessionStore(storePath, { skipCache: true })).toEqual({ + "agent:main:discord:direct:u2": { sessionId: "sess-stale-main", updatedAt: 3 }, + global: { sessionId: "sess-global", updatedAt: 4 }, + }); + }); + }); }); diff --git a/src/gateway/server-methods/sessions.send-followup-status.test.ts b/src/gateway/server-methods/sessions.send-followup-status.test.ts index b1bbffb8f7c..b9a94d6f814 100644 --- a/src/gateway/server-methods/sessions.send-followup-status.test.ts +++ b/src/gateway/server-methods/sessions.send-followup-status.test.ts @@ -69,6 +69,7 @@ describe("sessions.send completed subagent follow-up status", () => { }; loadSessionEntryMock.mockReturnValue({ + cfg: {}, canonicalKey: childSessionKey, storePath: "/tmp/sessions.json", entry: { sessionId: "sess-followup" }, diff --git a/src/gateway/session-store-key.ts b/src/gateway/session-store-key.ts index 78f1cb3c879..7046fbbe909 100644 --- a/src/gateway/session-store-key.ts +++ b/src/gateway/session-store-key.ts @@ -107,6 +107,35 @@ export function resolveSessionStoreAgentId(cfg: OpenClawConfig, canonicalKey: st return resolveDefaultStoreAgentId(cfg); } +export function resolveStoredSessionKeyForAgentStore(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; +}): string { + const raw = normalizeOptionalString(params.sessionKey) ?? ""; + if (!raw) { + return raw; + } + const lowered = normalizeLowercaseStringOrEmpty(raw); + if (lowered === "global" || lowered === "unknown") { + return lowered; + } + const key = parseAgentSessionKey(raw) ? raw : canonicalizeSessionKeyForAgent(params.agentId, raw); + return resolveSessionStoreKey({ cfg: params.cfg, sessionKey: key }); +} + +export function resolveStoredSessionOwnerAgentId(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; +}): string | null { + const canonicalKey = resolveStoredSessionKeyForAgentStore(params); + if (canonicalKey === "global" || canonicalKey === "unknown") { + return null; + } + return resolveSessionStoreAgentId(params.cfg, canonicalKey); +} + export function canonicalizeSpawnedByForAgent( cfg: OpenClawConfig, agentId: string, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 8c2216be861..9b145fa80e0 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -63,10 +63,10 @@ import { import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js"; import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js"; import { - canonicalizeSessionKeyForAgent, canonicalizeSpawnedByForAgent, resolveSessionStoreAgentId, resolveSessionStoreKey, + resolveStoredSessionKeyForAgentStore, } from "./session-store-key.js"; import { readLatestSessionUsageFromTranscript, @@ -927,7 +927,11 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { const store = loadSessionStore(storePath); const combined: Record = {}; for (const [key, entry] of Object.entries(store)) { - const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key); + const canonicalKey = resolveStoredSessionKeyForAgentStore({ + cfg, + agentId: defaultAgentId, + sessionKey: key, + }); mergeSessionEntryIntoCombined({ cfg, combined, @@ -946,7 +950,11 @@ export function loadCombinedSessionStoreForGateway(cfg: OpenClawConfig): { const storePath = target.storePath; const store = loadSessionStore(storePath); for (const [key, entry] of Object.entries(store)) { - const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key); + const canonicalKey = resolveStoredSessionKeyForAgentStore({ + cfg, + agentId, + sessionKey: key, + }); mergeSessionEntryIntoCombined({ cfg, combined, diff --git a/src/gateway/sessions-resolve-store.test.ts b/src/gateway/sessions-resolve-store.test.ts new file mode 100644 index 00000000000..7afb86f3363 --- /dev/null +++ b/src/gateway/sessions-resolve-store.test.ts @@ -0,0 +1,70 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { saveSessionStore } from "../config/sessions.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; +import { ErrorCodes } from "./protocol/index.js"; +import { resolveSessionKeyFromResolveParams } from "./sessions-resolve.js"; + +describe("resolveSessionKeyFromResolveParams store canonicalization", () => { + it("resolves legacy main-alias matches by sessionId and label for the configured default agent", async () => { + await withStateDirEnv("openclaw-sessions-resolve-alias-", async ({ stateDir }) => { + const storePath = path.join(stateDir, "sessions.json"); + const cfg = { + session: { store: storePath, mainKey: "main" }, + agents: { list: [{ id: "ops", default: true }] }, + } satisfies OpenClawConfig; + await saveSessionStore(storePath, { + "agent:main:main": { + sessionId: "sess-default-alias", + label: "default-alias", + updatedAt: 1, + }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { sessionId: "sess-default-alias" }, + }), + ).resolves.toEqual({ ok: true, key: "agent:ops:main" }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { label: "default-alias" }, + }), + ).resolves.toEqual({ ok: true, key: "agent:ops:main" }); + }); + }); + + it("still rejects non-alias agent:main matches when main is no longer configured", async () => { + await withStateDirEnv("openclaw-sessions-resolve-stale-main-", async ({ stateDir }) => { + const storePath = path.join(stateDir, "sessions.json"); + const cfg = { + session: { store: storePath, mainKey: "main" }, + agents: { list: [{ id: "ops", default: true }] }, + } satisfies OpenClawConfig; + await saveSessionStore(storePath, { + "agent:main:discord:direct:u1": { + sessionId: "sess-stale-main", + label: "stale-main", + updatedAt: 1, + }, + }); + + await expect( + resolveSessionKeyFromResolveParams({ + cfg, + p: { sessionId: "sess-stale-main" }, + }), + ).resolves.toEqual({ + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: 'Agent "main" no longer exists in configuration', + }, + }); + }); + }); +});