diff --git a/CHANGELOG.md b/CHANGELOG.md index 830f50b617c..75f3b5cef5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - Discord/proxy: keep Carbon REST, monitor startup, and webhook sends on the configured Discord proxy while falling back cleanly when the proxy URL is invalid, so Discord replies and deploys do not hard-fail on malformed proxy config. (#57465) Thanks @geekhuashan. - Mobile pairing/device approval: mint both node and operator device tokens when one approval grants merged roles, so mixed mobile bootstrap pairings stop reconnecting as operator-only and showing the node offline. (#60208) Thanks @obviyus. - Agents/tool policy: stop `tools.profile` warnings from flagging runtime-gated baseline core tools as unknown when the coding profile is missing tools like `code_execution`, `x_search`, `image`, or `image_generate`, while still warning on explicit extra allowlist entries. Thanks @vincentkoc. +- Sessions/resolution: collapse alias-duplicate session-id matches before scoring, keep distinct structural ties ambiguous, and prefer current-store reuse when resolving equal cross-store duplicates so follow-up turns stop dropping or duplicating sessions on timestamp ties. ## 2026.4.2 diff --git a/src/agents/command/session.resolve-session-key.test.ts b/src/agents/command/session.resolve-session-key.test.ts new file mode 100644 index 00000000000..492c7ebbc7d --- /dev/null +++ b/src/agents/command/session.resolve-session-key.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; + +const hoisted = vi.hoisted(() => ({ + loadSessionStoreMock: vi.fn<(storePath: string) => Record>(), + listAgentIdsMock: vi.fn<() => string[]>(), +})); + +vi.mock("../../config/sessions.js", async () => { + const actual = await vi.importActual( + "../../config/sessions.js", + ); + return { + ...actual, + loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath), + resolveStorePath: (store?: string, params?: { agentId?: string }) => + `/stores/${params?.agentId ?? "main"}.json`, + resolveAgentIdFromSessionKey: () => "main", + resolveExplicitAgentSessionKey: () => undefined, + }; +}); + +vi.mock("../agent-scope.js", () => ({ + listAgentIds: () => hoisted.listAgentIdsMock(), +})); + +const { resolveSessionKeyForRequest } = await import("./session.js"); + +describe("resolveSessionKeyForRequest", () => { + beforeEach(() => { + hoisted.loadSessionStoreMock.mockReset(); + hoisted.listAgentIdsMock.mockReset(); + hoisted.listAgentIdsMock.mockReturnValue(["main", "other"]); + }); + + it("prefers the current store when equal duplicates exist across stores", () => { + const mainStore = { + "agent:main:main": { sessionId: "sid", updatedAt: 10 }, + } satisfies Record; + const otherStore = { + "agent:other:main": { sessionId: "sid", updatedAt: 10 }, + } satisfies Record; + hoisted.loadSessionStoreMock.mockImplementation((storePath) => { + if (storePath === "/stores/main.json") { + return mainStore; + } + if (storePath === "/stores/other.json") { + return otherStore; + } + return {}; + }); + + const result = resolveSessionKeyForRequest({ + cfg: { + session: { + store: "/stores/{agentId}.json", + }, + } satisfies OpenClawConfig, + sessionId: "sid", + }); + + expect(result.sessionKey).toBe("agent:main:main"); + expect(result.sessionStore).toBe(mainStore); + expect(result.storePath).toBe("/stores/main.json"); + }); +}); diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index d0bcca19864..257112995c8 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -76,16 +76,21 @@ export function resolveSessionKeyForRequest(opts: { (!sessionKey || sessionStore[sessionKey]?.sessionId !== opts.sessionId) ) { const matches: Array<[string, SessionEntry]> = []; + const primaryStoreMatches: Array<[string, SessionEntry]> = []; const storeByKey = new Map(); const addMatches = ( candidateStore: Record, candidateStorePath: string, + options?: { primary?: boolean }, ): void => { for (const [candidateKey, candidateEntry] of Object.entries(candidateStore)) { if (candidateEntry?.sessionId !== opts.sessionId) { continue; } matches.push([candidateKey, candidateEntry]); + if (options?.primary) { + primaryStoreMatches.push([candidateKey, candidateEntry]); + } storeByKey.set(candidateKey, { sessionKey: candidateKey, sessionStore: candidateStore, @@ -94,7 +99,7 @@ export function resolveSessionKeyForRequest(opts: { } }; - addMatches(sessionStore, storePath); + addMatches(sessionStore, storePath, { primary: true }); const allAgentIds = listAgentIds(opts.cfg); for (const agentId of allAgentIds) { if (agentId === storeAgentId) { @@ -106,12 +111,15 @@ export function resolveSessionKeyForRequest(opts: { } const preferredKey = resolvePreferredSessionKeyForSessionIdMatches(matches, opts.sessionId); - if (preferredKey) { - const preferred = storeByKey.get(preferredKey); + const currentStorePreferredKey = + preferredKey ?? + resolvePreferredSessionKeyForSessionIdMatches(primaryStoreMatches, opts.sessionId); + if (currentStorePreferredKey) { + const preferred = storeByKey.get(currentStorePreferredKey); if (preferred) { return preferred; } - sessionKey = preferredKey; + sessionKey = currentStorePreferredKey; } } diff --git a/src/sessions/session-id-resolution.test.ts b/src/sessions/session-id-resolution.test.ts new file mode 100644 index 00000000000..e1435905752 --- /dev/null +++ b/src/sessions/session-id-resolution.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import type { SessionEntry } from "../config/sessions/types.js"; +import { resolvePreferredSessionKeyForSessionIdMatches } from "./session-id-resolution.js"; + +function entry(updatedAt: number, sessionId = "s1"): SessionEntry { + return { sessionId, updatedAt }; +} + +describe("resolvePreferredSessionKeyForSessionIdMatches", () => { + it("returns undefined for empty matches", () => { + expect(resolvePreferredSessionKeyForSessionIdMatches([], "s1")).toBeUndefined(); + }); + + it("returns the only match for a single-element array", () => { + const matches: Array<[string, SessionEntry]> = [["agent:main:main", entry(10)]]; + expect(resolvePreferredSessionKeyForSessionIdMatches(matches, "s1")).toBe("agent:main:main"); + }); + + it("collapses alias duplicates before resolving structural ties", () => { + const matches: Array<[string, SessionEntry]> = [ + ["agent:main:MAIN", entry(10, "main")], + ["agent:main:main", entry(10, "main")], + ]; + + expect(resolvePreferredSessionKeyForSessionIdMatches(matches, "main")).toBe("agent:main:main"); + }); + + it("returns the freshest match when timestamps differ", () => { + const matches: Array<[string, SessionEntry]> = [ + ["agent:main:alpha", entry(10)], + ["agent:main:beta", entry(20)], + ]; + expect(resolvePreferredSessionKeyForSessionIdMatches(matches, "s1")).toBe("agent:main:beta"); + }); + + it("returns undefined for fuzzy-only matches with tied timestamps", () => { + const matches: Array<[string, SessionEntry]> = [ + ["agent:main:beta", entry(10)], + ["agent:main:alpha", entry(10)], + ]; + expect(resolvePreferredSessionKeyForSessionIdMatches(matches, "s1")).toBeUndefined(); + }); + + it("prefers the freshest structural match over a fresher fuzzy match", () => { + const matches: Array<[string, SessionEntry]> = [ + ["agent:main:other", entry(999, "run-dup")], + ["agent:main:acp:run-dup", entry(100, "run-dup")], + ["agent:main:acp2:run-dup", entry(50, "run-dup")], + ]; + + expect(resolvePreferredSessionKeyForSessionIdMatches(matches, "run-dup")).toBe( + "agent:main:acp:run-dup", + ); + }); + + it("preserves ambiguity for distinct structural ties", () => { + const matches: Array<[string, SessionEntry]> = [ + ["agent:main:b:sid", entry(10, "sid")], + ["agent:main:a:sid", entry(10, "sid")], + ["agent:main:extra", entry(500, "sid")], + ]; + + expect(resolvePreferredSessionKeyForSessionIdMatches(matches, "sid")).toBeUndefined(); + }); +}); diff --git a/src/sessions/session-id-resolution.ts b/src/sessions/session-id-resolution.ts index f0cde40c2e1..6ac0ffeffc0 100644 --- a/src/sessions/session-id-resolution.ts +++ b/src/sessions/session-id-resolution.ts @@ -1,6 +1,50 @@ import type { SessionEntry } from "../config/sessions.js"; import { toAgentRequestSessionKey } from "../routing/session-key.js"; +type SessionIdMatch = [string, SessionEntry]; + +function compareUpdatedAtDescending(a: SessionIdMatch, b: SessionIdMatch): number { + return (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0); +} + +function compareStoreKeys(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function collapseAliasMatches(matches: SessionIdMatch[]): SessionIdMatch[] { + const grouped = new Map(); + for (const match of matches) { + const requestKey = toAgentRequestSessionKey(match[0]) ?? match[0]; + const normalizedRequestKey = requestKey.trim().toLowerCase(); + const bucket = grouped.get(normalizedRequestKey); + if (bucket) { + bucket.push(match); + } else { + grouped.set(normalizedRequestKey, [match]); + } + } + + return Array.from(grouped.values(), (group) => { + if (group.length === 1) { + return group[0]; + } + return [...group].toSorted((a, b) => { + const timeDiff = compareUpdatedAtDescending(a, b); + if (timeDiff !== 0) { + return timeDiff; + } + const aNormalizedKey = a[0].trim().toLowerCase(); + const bNormalizedKey = b[0].trim().toLowerCase(); + const aIsCanonical = a[0] === aNormalizedKey; + const bIsCanonical = b[0] === bNormalizedKey; + if (aIsCanonical !== bIsCanonical) { + return aIsCanonical ? -1 : 1; + } + return compareStoreKeys(aNormalizedKey, bNormalizedKey); + })[0]; + }); +} + export function resolvePreferredSessionKeyForSessionIdMatches( matches: Array<[string, SessionEntry]>, sessionId: string, @@ -13,7 +57,11 @@ export function resolvePreferredSessionKeyForSessionIdMatches( } const loweredSessionId = sessionId.trim().toLowerCase(); - const structuralMatches = matches.filter(([storeKey]) => { + const canonicalMatches = collapseAliasMatches(matches); + if (canonicalMatches.length === 1) { + return canonicalMatches[0][0]; + } + const structuralMatches = canonicalMatches.filter(([storeKey]) => { const requestKey = toAgentRequestSessionKey(storeKey)?.toLowerCase(); return ( storeKey.toLowerCase().endsWith(`:${loweredSessionId}`) || @@ -25,12 +73,21 @@ export function resolvePreferredSessionKeyForSessionIdMatches( return structuralMatches[0][0]; } - const sortedMatches = [...matches].toSorted( - (a, b) => (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0), - ); + const structuralSorted = [...structuralMatches].toSorted(compareUpdatedAtDescending); + const [freshestStructural, secondFreshestStructural] = structuralSorted; + if (structuralMatches.length > 1) { + if ( + (freshestStructural?.[1]?.updatedAt ?? 0) > (secondFreshestStructural?.[1]?.updatedAt ?? 0) + ) { + return freshestStructural[0]; + } + return undefined; + } + + const sortedMatches = [...canonicalMatches].toSorted(compareUpdatedAtDescending); const [freshest, secondFreshest] = sortedMatches; if ((freshest?.[1]?.updatedAt ?? 0) > (secondFreshest?.[1]?.updatedAt ?? 0)) { - return freshest?.[0]; + return freshest[0]; } return undefined;