fix(gateway): scope session data lookups by agent [AI] (#81386)

* fix: scope gateway session lookups by agent

* addressing review-skill

* addressing review-skill

* addressing review-skill

* addressing review-skill

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing review-skill

* addressing review-skill

* addressing review-skill

* addressing review-skill

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing ci

* addressing ci

* fix: complete root-cause handling

* addressing review-skill

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* Fix Swift protocol optional initializer defaults

* Stabilize node command lookup in approval test

* Fix browser proxy approval test node lookup

* Trim unrelated changes from issue 642 fix

* Remove unrelated formatting churn from issue 642 fix

* Fix Swift protocol generator lint

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-16 22:31:02 +05:30
committed by GitHub
parent 9a204008ba
commit 6a12c6f799
23 changed files with 2098 additions and 126 deletions
+1
View File
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- fix(gateway): scope session data lookups by agent [AI]. (#81386) Thanks @pgondhi987.
- Agents/media: accept generated media attachments on internal completion events and report delivery-loss failures as errors, so completed background music/video tasks do not disappear after provider success.
- Matrix/approvals: release in-flight reaction bindings when the channel approval handler stops mid-delivery, preventing stale approval targets after restart. Fixes #82485. (#82482) Thanks @Feelw00.
- Matrix/E2EE: stop requesting MSC4222 `state_after` sync responses so homeservers with incomplete state-after data do not leave fresh encrypted rooms without outbound room encryptors. Fixes #82515. Thanks @nickdecooman.
@@ -2109,18 +2109,22 @@ public struct SessionsMessagesUnsubscribeParams: Codable, Sendable {
public struct SessionsAbortParams: Codable, Sendable {
public let key: String?
public let runid: String?
public let agentid: String?
public init(
key: String?,
runid: String?)
runid: String?,
agentid: String? = nil)
{
self.key = key
self.runid = runid
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case key
case runid = "runId"
case agentid = "agentId"
}
}
@@ -2338,6 +2342,7 @@ public struct SessionsCompactParams: Codable, Sendable {
public struct SessionsUsageParams: Codable, Sendable {
public let key: String?
public let agentid: String?
public let startdate: String?
public let enddate: String?
public let mode: AnyCodable?
@@ -2350,6 +2355,7 @@ public struct SessionsUsageParams: Codable, Sendable {
public init(
key: String?,
agentid: String? = nil,
startdate: String?,
enddate: String?,
mode: AnyCodable?,
@@ -2361,6 +2367,7 @@ public struct SessionsUsageParams: Codable, Sendable {
includecontextweight: Bool?)
{
self.key = key
self.agentid = agentid
self.startdate = startdate
self.enddate = enddate
self.mode = mode
@@ -2374,6 +2381,7 @@ public struct SessionsUsageParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case key
case agentid = "agentId"
case startdate = "startDate"
case enddate = "endDate"
case mode
@@ -4274,21 +4282,25 @@ public struct ArtifactsListParams: Codable, Sendable {
public let sessionkey: String?
public let runid: String?
public let taskid: String?
public let agentid: String?
public init(
sessionkey: String?,
runid: String?,
taskid: String?)
taskid: String?,
agentid: String? = nil)
{
self.sessionkey = sessionkey
self.runid = runid
self.taskid = taskid
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case runid = "runId"
case taskid = "taskId"
case agentid = "agentId"
}
}
@@ -4310,17 +4322,20 @@ public struct ArtifactsGetParams: Codable, Sendable {
public let sessionkey: String?
public let runid: String?
public let taskid: String?
public let agentid: String?
public let artifactid: String
public init(
sessionkey: String?,
runid: String?,
taskid: String?,
agentid: String? = nil,
artifactid: String)
{
self.sessionkey = sessionkey
self.runid = runid
self.taskid = taskid
self.agentid = agentid
self.artifactid = artifactid
}
@@ -4328,6 +4343,7 @@ public struct ArtifactsGetParams: Codable, Sendable {
case sessionkey = "sessionKey"
case runid = "runId"
case taskid = "taskId"
case agentid = "agentId"
case artifactid = "artifactId"
}
}
@@ -4350,17 +4366,20 @@ public struct ArtifactsDownloadParams: Codable, Sendable {
public let sessionkey: String?
public let runid: String?
public let taskid: String?
public let agentid: String?
public let artifactid: String
public init(
sessionkey: String?,
runid: String?,
taskid: String?,
agentid: String? = nil,
artifactid: String)
{
self.sessionkey = sessionkey
self.runid = runid
self.taskid = taskid
self.agentid = agentid
self.artifactid = artifactid
}
@@ -4368,6 +4387,7 @@ public struct ArtifactsDownloadParams: Codable, Sendable {
case sessionkey = "sessionKey"
case runid = "runId"
case taskid = "taskId"
case agentid = "agentId"
case artifactid = "artifactId"
}
}
@@ -4,14 +4,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js";
import { asOpenClawConfig } from "./tools.test-helpers.js";
const crossAgentStore = {
type TestSessionEntry = {
sessionId: string;
updatedAt: number;
sessionFile: string;
};
const crossAgentStore: Record<string, TestSessionEntry> = {
"agent:peer:only": {
sessionId: "w1",
updatedAt: 1,
sessionFile: "/tmp/sessions/w1.jsonl",
},
};
let combinedSessionStore: typeof crossAgentStore | Record<string, never> = crossAgentStore;
let combinedSessionStore: Record<string, TestSessionEntry> = crossAgentStore;
vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => {
const actual =
@@ -100,10 +106,19 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
hits,
});
expect(sessionTranscriptHit.loadCombinedSessionStoreForGateway).toHaveBeenCalledTimes(1);
expect(sessionTranscriptHit.loadCombinedSessionStoreForGateway).toHaveBeenCalledWith(cfg);
expect(sessionTranscriptHit.loadCombinedSessionStoreForGateway).toHaveBeenCalledWith(cfg, {
agentId: "main",
});
});
it("allows cross-agent session hits when visibility=all and agent-to-agent is enabled", async () => {
it("keeps same-agent session hits when visibility=all and agent-to-agent is enabled", async () => {
combinedSessionStore = {
"agent:main:only": {
sessionId: "w1",
updatedAt: 1,
sessionFile: "/tmp/sessions/w1.jsonl",
},
};
const hit: MemorySearchResult = {
path: "sessions/w1.jsonl",
source: "sessions",
@@ -127,6 +142,120 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
expect(filtered).toEqual([hit]);
});
it("keeps global-scope session hits for non-default agents", async () => {
combinedSessionStore = {
global: {
sessionId: "w1",
updatedAt: 1,
sessionFile: "/tmp/sessions/w1.jsonl",
},
};
const hit: MemorySearchResult = {
path: "sessions/w1.jsonl",
source: "sessions",
score: 1,
snippet: "x",
startLine: 1,
endLine: 2,
};
const cfg = asOpenClawConfig({
session: { scope: "global" },
tools: {
sessions: { visibility: "all" },
agentToAgent: { enabled: true, allow: ["*"] },
},
});
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg,
agentId: "secondary",
requesterSessionKey: "agent:secondary:main",
sandboxed: false,
hits: [hit],
});
expect(filtered).toEqual([hit]);
});
it("does not keep cross-agent session hits outside the scoped store", async () => {
combinedSessionStore = {};
const hit: MemorySearchResult = {
path: "sessions/w1.jsonl",
source: "sessions",
score: 1,
snippet: "x",
startLine: 1,
endLine: 2,
};
const cfg = asOpenClawConfig({
tools: {
sessions: { visibility: "all" },
agentToAgent: { enabled: true, allow: ["*"] },
},
});
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg,
requesterSessionKey: "agent:main:main",
sandboxed: false,
hits: [hit],
});
expect(filtered).toStrictEqual([]);
});
it("does not keep cross-agent session hits when a shared store returns out-of-scope keys", async () => {
combinedSessionStore = crossAgentStore;
const hit: MemorySearchResult = {
path: "sessions/w1.jsonl",
source: "sessions",
score: 1,
snippet: "x",
startLine: 1,
endLine: 2,
};
const cfg = asOpenClawConfig({
tools: {
sessions: { visibility: "all" },
agentToAgent: { enabled: true, allow: ["*"] },
},
});
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg,
requesterSessionKey: "agent:main:main",
sandboxed: false,
hits: [hit],
});
expect(filtered).toStrictEqual([]);
});
it("does not keep owner-qualified cross-agent hits that collide with a scoped stem", async () => {
combinedSessionStore = {
"agent:main:main": {
sessionId: "main",
updatedAt: 1,
sessionFile: "/tmp/sessions/main.jsonl",
},
};
const hit: MemorySearchResult = {
path: "sessions/peer/main.jsonl",
source: "sessions",
score: 1,
snippet: "x",
startLine: 1,
endLine: 2,
};
const cfg = asOpenClawConfig({
tools: {
sessions: { visibility: "all" },
agentToAgent: { enabled: true, allow: ["*"] },
},
});
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg,
requesterSessionKey: "agent:main:main",
sandboxed: false,
hits: [hit],
});
expect(filtered).toStrictEqual([]);
});
it("denies cross-agent session hits when agent-to-agent is disabled", async () => {
const hit: MemorySearchResult = {
path: "sessions/w1.jsonl",
@@ -203,4 +332,31 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
expect(filtered).toStrictEqual([]);
});
it("does not keep cross-agent deleted archive hits outside the scoped store when a2a is allowed", async () => {
combinedSessionStore = {};
const hit: MemorySearchResult = {
path: "sessions/peer/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z",
source: "sessions",
score: 1,
snippet: "x",
startLine: 1,
endLine: 2,
};
const cfg = asOpenClawConfig({
tools: {
sessions: { visibility: "all" },
agentToAgent: { enabled: true, allow: ["*"] },
},
});
const filtered = await filterMemorySearchHitsBySessionVisibility({
cfg,
requesterSessionKey: "agent:main:main",
sandboxed: false,
hits: [hit],
});
expect(filtered).toStrictEqual([]);
});
});
@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
import {
extractTranscriptIdentityFromSessionsMemoryHit,
loadCombinedSessionStoreForGateway,
@@ -11,8 +12,38 @@ import {
resolveEffectiveSessionToolsVisibility,
} from "openclaw/plugin-sdk/session-visibility";
function normalizeAgentIdForCompare(value: string | undefined): string | undefined {
return value?.trim().toLowerCase() || undefined;
}
function isGlobalSessionKeyForSharedScope(cfg: OpenClawConfig, key: string): boolean {
return cfg.session?.scope === "global" && key.trim().toLowerCase() === "global";
}
function filterSessionKeysByScopedAgent(params: {
cfg: OpenClawConfig;
keys: string[];
scopedAgentId: string | undefined;
}): string[] {
const scopedAgentId = normalizeAgentIdForCompare(params.scopedAgentId);
if (!scopedAgentId) {
return params.keys;
}
return params.keys.filter((key) => {
if (isGlobalSessionKeyForSharedScope(params.cfg, key)) {
return true;
}
const ownerAgentId = resolveSessionAgentId({
sessionKey: key,
config: params.cfg,
});
return normalizeAgentIdForCompare(ownerAgentId) === scopedAgentId;
});
}
export async function filterMemorySearchHitsBySessionVisibility(params: {
cfg: OpenClawConfig;
agentId?: string;
requesterSessionKey: string | undefined;
sandboxed: boolean;
hits: MemorySearchResult[];
@@ -22,6 +53,13 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
sandboxed: params.sandboxed,
});
const a2aPolicy = createAgentToAgentPolicy(params.cfg);
const requesterAgentId = params.requesterSessionKey
? resolveSessionAgentId({
sessionKey: params.requesterSessionKey,
config: params.cfg,
})
: undefined;
const scopedAgentId = params.agentId?.trim() || requesterAgentId;
const guard = params.requesterSessionKey
? await createSessionVisibilityGuard({
action: "history",
@@ -31,7 +69,10 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
})
: null;
const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway(params.cfg);
const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway(
params.cfg,
scopedAgentId ? { agentId: scopedAgentId } : {},
);
const next: MemorySearchResult[] = [];
for (const hit of params.hits) {
@@ -46,12 +87,31 @@ export async function filterMemorySearchHitsBySessionVisibility(params: {
if (!identity) {
continue;
}
const keys = resolveTranscriptStemToSessionKeys({
store: combinedSessionStore,
stem: identity.stem,
...(identity.archived && identity.ownerAgentId
? { archivedOwnerAgentId: identity.ownerAgentId }
: {}),
const normalizedScopedAgentId = normalizeAgentIdForCompare(scopedAgentId);
const normalizedOwnerAgentId = normalizeAgentIdForCompare(identity.ownerAgentId);
if (
normalizedScopedAgentId &&
normalizedOwnerAgentId &&
normalizedOwnerAgentId !== normalizedScopedAgentId
) {
continue;
}
const archivedOwnerMatchesScope = Boolean(
identity.archived &&
identity.ownerAgentId &&
(!scopedAgentId ||
normalizeAgentIdForCompare(identity.ownerAgentId) ===
normalizeAgentIdForCompare(scopedAgentId)),
);
const archivedOwnerAgentId = archivedOwnerMatchesScope ? identity.ownerAgentId : undefined;
const keys = filterSessionKeysByScopedAgent({
cfg: params.cfg,
scopedAgentId,
keys: resolveTranscriptStemToSessionKeys({
store: combinedSessionStore,
stem: identity.stem,
...(archivedOwnerAgentId ? { archivedOwnerAgentId } : {}),
}),
});
if (keys.length === 0) {
continue;
+1
View File
@@ -306,6 +306,7 @@ export function createMemorySearchTool(options: {
});
rawResults = await filterMemorySearchHitsBySessionVisibility({
cfg,
agentId,
requesterSessionKey: options.agentSessionKey,
sandboxed: options.sandboxed === true,
hits: rawResults,
@@ -489,6 +489,43 @@ describe("memory-wiki gateway methods", () => {
});
});
it("passes the default agent scope to shared wiki.search gateway calls", async () => {
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
const { api, registerGatewayMethod } = createPluginApi();
const appConfig = {
agents: {
list: [{ id: "main", default: true }],
},
};
registerMemoryWikiGatewayMethods({ api, config, appConfig });
const handler = findGatewayHandler(registerGatewayMethod, "wiki.search");
if (!handler) {
throw new Error("wiki.search handler missing");
}
const respond = vi.fn();
await handler({
params: {
query: "sessions",
corpus: "memory",
backend: "shared",
},
respond,
});
expect(searchMemoryWiki).toHaveBeenCalledWith({
config,
appConfig,
agentId: "main",
query: "sessions",
maxResults: undefined,
searchBackend: "shared",
searchCorpus: "memory",
mode: undefined,
});
});
it("registers wiki.ingest with admin scope and keeps compile at write scope", async () => {
const { config } = await createVault({ prefix: "memory-wiki-gateway-" });
const { api, registerGatewayMethod } = createPluginApi();
+15
View File
@@ -1,4 +1,5 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/memory-host-core";
import type { OpenClawConfig, OpenClawPluginApi } from "../api.js";
import { applyMemoryWikiMutation, normalizeMemoryWikiMutationInput } from "./apply.js";
import { compileMemoryWikiVault } from "./compile.js";
@@ -88,6 +89,16 @@ function respondError(respond: GatewayRespond, error: unknown) {
respond(false, undefined, { code: "internal_error", message });
}
function resolveGatewayAgentId(
requestParams: Record<string, unknown>,
appConfig: OpenClawConfig | undefined,
): string | undefined {
return (
readStringParam(requestParams, "agentId") ??
(appConfig ? resolveDefaultAgentId(appConfig) : undefined)
);
}
async function syncImportedSourcesIfNeeded(
config: ResolvedMemoryWikiConfig,
appConfig?: OpenClawConfig,
@@ -280,11 +291,13 @@ export function registerMemoryWikiGatewayMethods(params: {
const searchBackend = readEnumParam(requestParams, "backend", WIKI_SEARCH_BACKENDS);
const searchCorpus = readEnumParam(requestParams, "corpus", WIKI_SEARCH_CORPORA);
const mode = readEnumParam(requestParams, "mode", WIKI_SEARCH_MODES);
const agentId = resolveGatewayAgentId(requestParams, appConfig);
respond(
true,
await searchMemoryWiki({
config,
appConfig,
...(agentId ? { agentId } : {}),
query,
maxResults,
searchBackend,
@@ -328,11 +341,13 @@ export function registerMemoryWikiGatewayMethods(params: {
const lineCount = readNumberParam(requestParams, "lineCount");
const searchBackend = readEnumParam(requestParams, "backend", WIKI_SEARCH_BACKENDS);
const searchCorpus = readEnumParam(requestParams, "corpus", WIKI_SEARCH_CORPORA);
const agentId = resolveGatewayAgentId(requestParams, appConfig);
respond(
true,
await getMemoryWikiPage({
config,
appConfig,
...(agentId ? { agentId } : {}),
lookup,
fromLine,
lineCount,
+274 -3
View File
@@ -18,9 +18,10 @@ const {
getActiveMemorySearchManagerMock: vi.fn(),
loadCombinedSessionStoreForGatewayMock: vi.fn(),
resolveDefaultAgentIdMock: vi.fn(() => "main"),
resolveSessionAgentIdMock: vi.fn(({ sessionKey }: { sessionKey?: string }) =>
sessionKey === "agent:secondary:thread" ? "secondary" : "main",
),
resolveSessionAgentIdMock: vi.fn(({ sessionKey }: { sessionKey?: string }) => {
const match = /^agent:([^:]+):/.exec(sessionKey ?? "");
return match?.[1] ?? "main";
}),
}));
vi.mock("openclaw/plugin-sdk/memory-host-search", () => ({
@@ -819,6 +820,276 @@ describe("searchMemoryWiki", () => {
]);
});
it("scopes gateway-style session memory search by agent", async () => {
const { config } = await createQueryVault({
initialize: true,
config: {
search: { backend: "shared", corpus: "memory" },
},
});
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(test)",
store: {
"agent:secondary:visible-session": {
sessionId: "visible-session",
updatedAt: 1,
sessionFile: "/tmp/openclaw/visible-session.jsonl",
},
},
});
const manager = createMemoryManager({
searchResults: [
{
path: "sessions/visible-session.jsonl",
startLine: 1,
endLine: 2,
score: 30,
snippet: "visible transcript",
source: "sessions",
},
{
path: "sessions/other-session.jsonl",
startLine: 3,
endLine: 4,
score: 20,
snippet: "other transcript",
source: "sessions",
},
{
path: "MEMORY.md",
startLine: 5,
endLine: 6,
score: 10,
snippet: "durable memory",
source: "memory",
},
],
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const results = await searchMemoryWiki({
config,
appConfig: createAppConfig(),
agentId: "secondary",
query: "transcript",
maxResults: 10,
});
expect(loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(createAppConfig(), {
agentId: "secondary",
});
expect(results.map((result) => result.path)).toEqual([
"sessions/visible-session.jsonl",
"MEMORY.md",
]);
});
it("keeps gateway-style global session memory hits for non-default agents", async () => {
const { config } = await createQueryVault({
initialize: true,
config: {
search: { backend: "shared", corpus: "memory" },
},
});
const appConfig = {
session: { scope: "global" },
agents: {
list: [{ id: "main", default: true }, { id: "secondary" }],
},
} as OpenClawConfig;
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(test)",
store: {
global: {
sessionId: "visible-session",
updatedAt: 1,
sessionFile: "/tmp/openclaw/visible-session.jsonl",
},
},
});
const manager = createMemoryManager({
searchResults: [
{
path: "sessions/visible-session.jsonl",
startLine: 1,
endLine: 2,
score: 30,
snippet: "global transcript",
source: "sessions",
},
{
path: "MEMORY.md",
startLine: 5,
endLine: 6,
score: 10,
snippet: "durable memory",
source: "memory",
},
],
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const results = await searchMemoryWiki({
config,
appConfig,
agentId: "secondary",
query: "transcript",
maxResults: 10,
});
expect(results.map((result) => result.path)).toEqual([
"sessions/visible-session.jsonl",
"MEMORY.md",
]);
});
it("keeps gateway-style archived session memory hits when the path owner matches agent scope", async () => {
const { config } = await createQueryVault({
initialize: true,
config: {
search: { backend: "shared", corpus: "memory" },
},
});
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(test)",
store: {},
});
const manager = createMemoryManager({
searchResults: [
{
path: "sessions/secondary/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z",
startLine: 1,
endLine: 2,
score: 30,
snippet: "archived transcript",
source: "sessions",
},
{
path: "MEMORY.md",
startLine: 5,
endLine: 6,
score: 10,
snippet: "durable memory",
source: "memory",
},
],
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const results = await searchMemoryWiki({
config,
appConfig: createAppConfig(),
agentId: "secondary",
query: "transcript",
maxResults: 10,
});
expect(results.map((result) => result.path)).toEqual([
"sessions/secondary/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z",
"MEMORY.md",
]);
});
it("drops gateway-style owner-qualified session hits that collide with the scoped store", async () => {
const { config } = await createQueryVault({
initialize: true,
config: {
search: { backend: "shared", corpus: "memory" },
},
});
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(test)",
store: {
"agent:secondary:main": {
sessionId: "main",
updatedAt: 1,
sessionFile: "/tmp/openclaw/main.jsonl",
},
},
});
const manager = createMemoryManager({
searchResults: [
{
path: "sessions/other/main.jsonl",
startLine: 1,
endLine: 2,
score: 30,
snippet: "other transcript",
source: "sessions",
},
{
path: "MEMORY.md",
startLine: 5,
endLine: 6,
score: 10,
snippet: "durable memory",
source: "memory",
},
],
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const results = await searchMemoryWiki({
config,
appConfig: createAppConfig(),
agentId: "secondary",
query: "transcript",
maxResults: 10,
});
expect(results.map((result) => result.path)).toEqual(["MEMORY.md"]);
});
it("drops gateway-style session memory hits when shared store keys belong to another agent", async () => {
const { config } = await createQueryVault({
initialize: true,
config: {
search: { backend: "shared", corpus: "memory" },
},
});
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(test)",
store: {
"agent:other:visible-session": {
sessionId: "visible-session",
updatedAt: 1,
sessionFile: "/tmp/openclaw/visible-session.jsonl",
},
},
});
const manager = createMemoryManager({
searchResults: [
{
path: "sessions/visible-session.jsonl",
startLine: 1,
endLine: 2,
score: 30,
snippet: "other transcript",
source: "sessions",
},
{
path: "MEMORY.md",
startLine: 5,
endLine: 6,
score: 10,
snippet: "durable memory",
source: "memory",
},
],
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const results = await searchMemoryWiki({
config,
appConfig: createAppConfig(),
agentId: "secondary",
query: "transcript",
maxResults: 10,
});
expect(results.map((result) => result.path)).toEqual(["MEMORY.md"]);
});
it("requires appConfig for session-bound shared memory searches", async () => {
const { config } = await createQueryVault({
initialize: true,
+75 -11
View File
@@ -4,7 +4,7 @@ import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-ru
import { resolveDefaultAgentId, resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core";
import { getActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search";
import {
extractTranscriptStemFromSessionsMemoryHit,
extractTranscriptIdentityFromSessionsMemoryHit,
loadCombinedSessionStoreForGateway,
resolveTranscriptStemToSessionKeys,
} from "openclaw/plugin-sdk/session-transcript-hit";
@@ -963,10 +963,15 @@ function buildLookupCandidates(lookup: string): string[] {
}
function shouldEnforceSessionVisibility(params: {
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
}): boolean {
return params.sandboxed === true || Boolean(params.agentSessionKey?.trim());
return (
params.sandboxed === true ||
Boolean(params.agentSessionKey?.trim()) ||
Boolean(params.agentId?.trim())
);
}
function shouldSearchSharedMemoryCorpus(config: ResolvedMemoryWikiConfig): boolean {
@@ -980,6 +985,7 @@ function shouldUseSharedMemory(config: ResolvedMemoryWikiConfig): boolean {
function assertSessionVisibilityAppConfig(params: {
config: ResolvedMemoryWikiConfig;
appConfig?: OpenClawConfig;
agentId?: string;
agentSessionKey?: string;
sandboxed?: boolean;
operation: string;
@@ -1205,6 +1211,7 @@ function toMemoryWikiSearchResult(
async function filterMemoryWikiSearchHitsBySessionVisibility(params: {
cfg: OpenClawConfig;
agentId: string | undefined;
requesterSessionKey: string | undefined;
sandboxed: boolean;
hits: MemorySearchResult[];
@@ -1215,6 +1222,7 @@ async function filterMemoryWikiSearchHitsBySessionVisibility(params: {
const canReadSessionPath = await createSessionMemoryPathVisibilityChecker({
cfg: params.cfg,
agentId: params.agentId,
requesterSessionKey: params.requesterSessionKey,
sandboxed: params.sandboxed,
});
@@ -1226,8 +1234,30 @@ async function filterMemoryWikiSearchHitsBySessionVisibility(params: {
type SessionMemoryPathVisibilityChecker = (relPath: string) => boolean;
function filterSessionKeysByScopedAgent(params: {
cfg: OpenClawConfig;
keys: string[];
scopedAgentId: string | undefined;
}): string[] {
const scopedAgentId = normalizeLowercaseStringOrEmpty(params.scopedAgentId);
if (!scopedAgentId) {
return params.keys;
}
return params.keys.filter((key) => {
if (params.cfg.session?.scope === "global" && key.trim().toLowerCase() === "global") {
return true;
}
const ownerAgentId = resolveSessionAgentId({
sessionKey: key,
config: params.cfg,
});
return normalizeLowercaseStringOrEmpty(ownerAgentId) === scopedAgentId;
});
}
async function createSessionMemoryPathVisibilityChecker(params: {
cfg: OpenClawConfig;
agentId: string | undefined;
requesterSessionKey: string | undefined;
sandboxed: boolean;
}): Promise<SessionMemoryPathVisibilityChecker> {
@@ -1236,6 +1266,13 @@ async function createSessionMemoryPathVisibilityChecker(params: {
sandboxed: params.sandboxed,
});
const a2aPolicy = createAgentToAgentPolicy(params.cfg);
const requesterAgentId = params.requesterSessionKey
? resolveSessionAgentId({
sessionKey: params.requesterSessionKey,
config: params.cfg,
})
: undefined;
const scopedAgentId = params.agentId?.trim() || requesterAgentId;
const guard = params.requesterSessionKey
? await createSessionVisibilityGuard({
action: "history",
@@ -1244,20 +1281,43 @@ async function createSessionMemoryPathVisibilityChecker(params: {
a2aPolicy,
})
: null;
if (!guard) {
return () => false;
}
const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway(params.cfg);
const { store: combinedSessionStore } = loadCombinedSessionStoreForGateway(
params.cfg,
scopedAgentId ? { agentId: scopedAgentId } : {},
);
return (relPath) => {
const stem = extractTranscriptStemFromSessionsMemoryHit(relPath);
if (!stem) {
const identity = extractTranscriptIdentityFromSessionsMemoryHit(relPath);
if (!identity) {
return false;
}
const keys = resolveTranscriptStemToSessionKeys({
store: combinedSessionStore,
stem,
const normalizedScopedAgentId = normalizeLowercaseStringOrEmpty(scopedAgentId);
const normalizedOwnerAgentId = normalizeLowercaseStringOrEmpty(identity.ownerAgentId);
if (
normalizedScopedAgentId &&
normalizedOwnerAgentId &&
normalizedOwnerAgentId !== normalizedScopedAgentId
) {
return false;
}
const archivedOwnerMatchesScope = Boolean(
identity.archived &&
identity.ownerAgentId &&
(!normalizedScopedAgentId || normalizedOwnerAgentId === normalizedScopedAgentId),
);
const archivedOwnerAgentId = archivedOwnerMatchesScope ? identity.ownerAgentId : undefined;
const keys = filterSessionKeysByScopedAgent({
cfg: params.cfg,
scopedAgentId,
keys: resolveTranscriptStemToSessionKeys({
store: combinedSessionStore,
stem: identity.stem,
...(archivedOwnerAgentId ? { archivedOwnerAgentId } : {}),
}),
});
if (!guard) {
return Boolean(scopedAgentId && keys.length > 0);
}
return keys.some((key) => guard.check(key).allowed);
};
}
@@ -1383,6 +1443,7 @@ export async function searchMemoryWiki(params: {
assertSessionVisibilityAppConfig({
config: effectiveConfig,
appConfig: params.appConfig,
...(params.agentId ? { agentId: params.agentId } : {}),
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
operation: "wiki_search",
@@ -1417,6 +1478,7 @@ export async function searchMemoryWiki(params: {
) {
rawMemoryResults = await filterMemoryWikiSearchHitsBySessionVisibility({
cfg: params.appConfig,
agentId: params.agentId,
requesterSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed === true,
hits: rawMemoryResults,
@@ -1448,6 +1510,7 @@ export async function getMemoryWikiPage(params: {
assertSessionVisibilityAppConfig({
config: effectiveConfig,
appConfig: params.appConfig,
...(params.agentId ? { agentId: params.agentId } : {}),
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
operation: "wiki_get",
@@ -1510,6 +1573,7 @@ export async function getMemoryWikiPage(params: {
lookupCandidates.some((relPath) => isSessionMemoryPath(relPath))
? await createSessionMemoryPathVisibilityChecker({
cfg: params.appConfig,
agentId: params.agentId,
requesterSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed === true,
})
+3 -3
View File
@@ -101,9 +101,9 @@ export type ArtifactSummary = {
};
export type ArtifactQuery =
| { sessionKey: string; runId?: string; taskId?: string }
| { runId: string; sessionKey?: string; taskId?: string }
| { taskId: string; sessionKey?: string; runId?: string };
| { sessionKey: string; runId?: string; taskId?: string; agentId?: string }
| { runId: string; sessionKey?: string; taskId?: string; agentId?: string }
| { taskId: string; sessionKey?: string; runId?: string; agentId?: string };
export type ArtifactsListResult = {
artifacts: ArtifactSummary[];
+35 -3
View File
@@ -41,6 +41,11 @@ const STRICT_LITERAL_STRUCTS = new Set([
]);
const DEFAULTED_OPTIONAL_INIT_PARAMS: Record<string, Set<string>> = {
SessionsAbortParams: new Set(["agentId"]),
SessionsUsageParams: new Set(["agentId"]),
ArtifactsListParams: new Set(["agentId"]),
ArtifactsGetParams: new Set(["agentId"]),
ArtifactsDownloadParams: new Set(["agentId"]),
MessageActionParams: new Set(["inboundTurnKind"]),
CronRunLogEntry: new Set(["failureNotificationDelivery"]),
};
@@ -173,6 +178,22 @@ function swiftType(schema: JsonSchema, required: boolean, allowStructuralNamed =
return isOptional ? `${base}?` : base;
}
function swiftInitializerParam(params: {
structName: string;
key: string;
name: string;
schema: JsonSchema;
required: boolean;
allowStructuralNamed?: boolean;
}): string {
const type = swiftType(params.schema, true, params.allowStructuralNamed ?? true);
if (params.required) {
return `${params.name}: ${type}`;
}
const defaultNil = DEFAULTED_OPTIONAL_INIT_PARAMS[params.structName]?.has(params.key) ?? false;
return `${params.name}: ${type}?${defaultNil ? " = nil" : ""}`;
}
function emitEnum(name: string, schema: JsonSchema): string {
const cases = schema.enum ?? [];
return [
@@ -224,7 +245,13 @@ function emitStruct(name: string, schema: JsonSchema): string {
.map(([key, prop]) => {
const propName = safeName(key);
const req = required.has(key);
return ` ${propName}: ${swiftType(prop, true, true)}${req ? "" : "?"}`;
return ` ${swiftInitializerParam({
structName: name,
key,
name: propName,
schema: prop,
required: req,
})}`;
});
lines.push(
"\n public init(\n" +
@@ -309,8 +336,13 @@ function emitStruct(name: string, schema: JsonSchema): string {
.map(([key, prop]) => {
const propName = safeName(key);
const req = required.has(key);
const defaultValue = DEFAULTED_OPTIONAL_INIT_PARAMS[name]?.has(key) ? " = nil" : "";
return ` ${propName}: ${swiftType(prop, true, true)}${req ? "" : "?"}${defaultValue}`;
return ` ${swiftInitializerParam({
structName: name,
key,
name: propName,
schema: prop,
required: req,
})}`;
})
.join(",\n") +
")\n" +
+1
View File
@@ -5,6 +5,7 @@ const ArtifactQueryParamsProperties = {
sessionKey: Type.Optional(NonEmptyString),
runId: Type.Optional(NonEmptyString),
taskId: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
};
export const ArtifactQueryParamsSchema = Type.Object(ArtifactQueryParamsProperties, {
+4 -1
View File
@@ -169,6 +169,7 @@ export const SessionsAbortParamsSchema = Type.Object(
{
key: Type.Optional(NonEmptyString),
runId: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
@@ -351,8 +352,10 @@ export const SessionsCompactionRestoreResultSchema = Type.Object(
export const SessionsUsageParamsSchema = Type.Object(
{
/** Specific session key to analyze; if omitted returns all sessions. */
/** Specific session key to analyze; if omitted returns sessions for the effective agent. */
key: Type.Optional(NonEmptyString),
/** Agent scope for list-style usage queries. */
agentId: Type.Optional(NonEmptyString),
/** Start date for range filter (YYYY-MM-DD). */
startDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
/** End date for range filter (YYYY-MM-DD). */
+298 -1
View File
@@ -61,6 +61,7 @@ function expectFields(value: unknown, expected: Record<string, unknown>): void {
describe("artifacts RPC handlers", () => {
beforeEach(() => {
vi.clearAllMocks();
hoisted.resolveSessionKeyForRun.mockReset();
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue(undefined);
hoisted.loadSessionEntry.mockReturnValue({
storePath: "/tmp/sessions.json",
@@ -123,6 +124,115 @@ describe("artifacts RPC handlers", () => {
expect(artifact).not.toHaveProperty("data");
});
it("applies agentId to direct sessionKey aliases", async () => {
const { calls, respond } = createResponder();
await artifactsHandlers["artifacts.list"]?.({
req: { type: "req", id: "session-alias-agent-scope", method: "artifacts.list", params: {} },
params: { sessionKey: "main", agentId: "work" },
client: null,
isWebchatConnect: () => false,
respond,
context: {} as never,
});
expect(calls[0]?.ok).toBe(true);
expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("agent:work:main");
const payload = calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
expectFields(payload.artifacts?.[0], { sessionKey: "agent:work:main" });
});
it("canonicalizes scoped sessionKey aliases with runtime config", async () => {
const { calls, respond } = createResponder();
await artifactsHandlers["artifacts.list"]?.({
req: { type: "req", id: "session-alias-main-key", method: "artifacts.list", params: {} },
params: { sessionKey: "main", agentId: "work" },
client: null,
isWebchatConnect: () => false,
respond,
context: {
getRuntimeConfig: () => ({
session: { mainKey: "primary" },
agents: { list: [{ id: "main", default: true }, { id: "work" }] },
}),
} as never,
});
expect(calls[0]?.ok).toBe(true);
expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("agent:work:primary");
const payload = calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
expectFields(payload.artifacts?.[0], { sessionKey: "agent:work:primary" });
});
it("preserves agent scope when loading global-scope run artifacts", async () => {
const { calls, respond } = createResponder();
hoisted.resolveSessionKeyForRun.mockReturnValue("global");
mockedMessages([
{
role: "assistant",
content: [{ type: "file", data: "aGVsbG8=", mimeType: "text/plain", title: "out.txt" }],
__openclaw: { seq: 2, runId: "run-global" },
},
]);
await artifactsHandlers["artifacts.list"]?.({
req: { type: "req", id: "global-run-agent-scope", method: "artifacts.list", params: {} },
params: { runId: "run-global", agentId: "work" },
client: null,
isWebchatConnect: () => false,
respond,
context: {
getRuntimeConfig: () => ({
session: { scope: "global" },
agents: { list: [{ id: "main", default: true }, { id: "work" }] },
}),
} as never,
});
expect(calls[0]?.ok).toBe(true);
expect(hoisted.resolveSessionKeyForRun).toHaveBeenCalledWith("run-global", {
agentId: "work",
});
expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("global", { agentId: "work" });
const payload = calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
expectFields(payload.artifacts?.[0], { sessionKey: "global", runId: "run-global" });
});
it("preserves inferred task agent scope when loading global-scope task artifacts", async () => {
const { calls, respond } = createResponder();
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
agentId: "work",
requesterSessionKey: "global",
});
mockedMessages([
{
role: "assistant",
content: [{ type: "file", data: "aGVsbG8=", mimeType: "text/plain", title: "task.txt" }],
__openclaw: { seq: 2, taskId: "task-global" },
},
]);
await artifactsHandlers["artifacts.list"]?.({
req: { type: "req", id: "global-task-agent-scope", method: "artifacts.list", params: {} },
params: { taskId: "task-global" },
client: null,
isWebchatConnect: () => false,
respond,
context: {
getRuntimeConfig: () => ({
session: { scope: "global" },
agents: { list: [{ id: "main", default: true }, { id: "work" }] },
}),
} as never,
});
expect(calls[0]?.ok).toBe(true);
expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("global", { agentId: "work" });
const payload = calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
expectFields(payload.artifacts?.[0], { sessionKey: "global", taskId: "task-global" });
});
it("gets and downloads an inline artifact", async () => {
const listed = collectArtifactsFromMessages({
sessionKey: "agent:main:main",
@@ -200,15 +310,75 @@ describe("artifacts RPC handlers", () => {
});
expect(calls[0]?.ok).toBe(true);
expect(hoisted.resolveSessionKeyForRun).toHaveBeenCalledWith("run-1");
expect(hoisted.resolveSessionKeyForRun).toHaveBeenCalledWith("run-1", {
agentId: "main",
});
const payload = calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
expectFields(payload.artifacts?.[0], { runId: "run-1" });
});
it("passes agentId to runId artifact queries", async () => {
hoisted.resolveSessionKeyForRun.mockReturnValue("main");
mockedMessages([
{
role: "assistant",
content: [{ type: "image", data: "aGVsbG8=", alt: "run-result.png" }],
__openclaw: { seq: 2, runId: "run-1" },
},
]);
const { respond } = createResponder();
await artifactsHandlers["artifacts.list"]?.({
req: { type: "req", id: "agent-run-scope", method: "artifacts.list", params: {} },
params: { runId: "run-1", agentId: "work" },
client: null,
isWebchatConnect: () => false,
respond,
context: {} as never,
});
expect(hoisted.resolveSessionKeyForRun).toHaveBeenCalledWith("run-1", {
agentId: "work",
});
expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("agent:work:main");
});
it("preserves task agent scope when taskId resolves through runId", async () => {
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
runId: "run-for-task-1",
agentId: "work",
});
hoisted.resolveSessionKeyForRun.mockReturnValue("acp:run-for-task-1");
mockedMessages([
{
role: "assistant",
content: [{ type: "image", data: "dGFyZ2V0", alt: "task-result.png" }],
__openclaw: { seq: 2, messageTaskId: "task-1" },
},
]);
const { calls, respond } = createResponder();
await artifactsHandlers["artifacts.list"]?.({
req: { type: "req", id: "task-run-agent-scope", method: "artifacts.list", params: {} },
params: { taskId: "task-1" },
client: null,
isWebchatConnect: () => false,
respond,
context: {} as never,
});
expect(calls[0]?.ok).toBe(true);
expect(hoisted.resolveSessionKeyForRun).toHaveBeenCalledWith("run-for-task-1", {
agentId: "work",
});
expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("agent:work:acp:run-for-task-1");
});
it("resolves taskId queries through task status access and filters artifacts by messageTaskId", async () => {
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
requesterSessionKey: "agent:main:main",
runId: "run-for-task-1",
agentId: "main",
});
mockedMessages([
{
@@ -293,6 +463,133 @@ describe("artifacts RPC handlers", () => {
});
});
it("does not resolve taskId artifact queries when agentId does not match the task", async () => {
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
requesterSessionKey: "agent:work:main",
runId: "run-for-task-1",
agentId: "work",
});
const { calls, respond } = createResponder();
await artifactsHandlers["artifacts.list"]?.({
req: { type: "req", id: "task-agent-mismatch", method: "artifacts.list", params: {} },
params: { taskId: "task-1", agentId: "main" },
client: null,
isWebchatConnect: () => false,
respond,
context: {} as never,
});
expect(calls[0]?.ok).toBe(false);
expect(hoisted.getTaskSessionLookupByIdForStatus).toHaveBeenCalledWith("task-1");
expect(hoisted.loadSessionEntry).not.toHaveBeenCalled();
expect(hoisted.resolveSessionKeyForRun).not.toHaveBeenCalled();
expectFields(calls[0]?.error, {
message: "no session found for artifact query",
});
const error = calls[0]?.error as { details?: Record<string, unknown> };
expectFields(error.details, { type: "artifact_scope_not_found" });
});
it("derives taskId artifact scope from requesterSessionKey when task agentId is absent", async () => {
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
requesterSessionKey: "agent:work:main",
runId: "run-for-task-1",
});
const { calls, respond } = createResponder();
await artifactsHandlers["artifacts.list"]?.({
req: {
type: "req",
id: "task-requester-agent-mismatch",
method: "artifacts.list",
params: {},
},
params: { taskId: "task-1", agentId: "main" },
client: null,
isWebchatConnect: () => false,
respond,
context: {} as never,
});
expect(calls[0]?.ok).toBe(false);
expect(hoisted.getTaskSessionLookupByIdForStatus).toHaveBeenCalledWith("task-1");
expect(hoisted.loadSessionEntry).not.toHaveBeenCalled();
expect(hoisted.resolveSessionKeyForRun).not.toHaveBeenCalled();
const error = calls[0]?.error as { details?: Record<string, unknown> };
expectFields(error.details, { type: "artifact_scope_not_found" });
});
it("treats legacy task requester session keys as the main agent for artifact scope", async () => {
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
requesterSessionKey: "main",
runId: "run-for-task-1",
});
const { calls, respond } = createResponder();
await artifactsHandlers["artifacts.list"]?.({
req: {
type: "req",
id: "task-legacy-requester-agent-mismatch",
method: "artifacts.list",
params: {},
},
params: { taskId: "task-1", agentId: "work" },
client: null,
isWebchatConnect: () => false,
respond,
context: {} as never,
});
expect(calls[0]?.ok).toBe(false);
expect(hoisted.getTaskSessionLookupByIdForStatus).toHaveBeenCalledWith("task-1");
expect(hoisted.loadSessionEntry).not.toHaveBeenCalled();
expect(hoisted.resolveSessionKeyForRun).not.toHaveBeenCalled();
const error = calls[0]?.error as { details?: Record<string, unknown> };
expectFields(error.details, { type: "artifact_scope_not_found" });
});
it("uses the configured default agent for legacy task requester session keys", async () => {
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
requesterSessionKey: "main",
runId: "run-for-task-1",
});
mockedMessages([
{
role: "assistant",
content: [{ type: "image", data: "dGFyZ2V0", alt: "task-result.png" }],
__openclaw: { seq: 2, messageTaskId: "task-1" },
},
]);
const { calls, respond } = createResponder();
await artifactsHandlers["artifacts.list"]?.({
req: {
type: "req",
id: "task-legacy-default-agent",
method: "artifacts.list",
params: {},
},
params: { taskId: "task-1", agentId: "work" },
client: null,
isWebchatConnect: () => false,
respond,
context: {
getRuntimeConfig: () => ({
agents: { list: [{ id: "work", default: true }] },
}),
} as never,
});
expect(calls[0]?.ok).toBe(true);
expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("agent:work:main");
const payload = calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
expectFields(payload.artifacts?.[0], {
taskId: "task-1",
sessionKey: "agent:work:main",
});
});
it("does not return untagged session artifacts for scoped runId queries", async () => {
hoisted.resolveSessionKeyForRun.mockReturnValue("agent:main:main");
const { calls, respond } = createResponder();
+127 -16
View File
@@ -1,4 +1,12 @@
import { createHash } from "node:crypto";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
normalizeAgentId,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
toAgentStoreSessionKey,
} from "../../routing/session-key.js";
import { getTaskSessionLookupByIdForStatus } from "../../tasks/task-status-access.js";
import {
ErrorCodes,
@@ -10,6 +18,11 @@ import {
validateArtifactsListParams,
} from "../protocol/index.js";
import { resolveSessionKeyForRun } from "../server-session-key.js";
import {
resolveSessionStoreAgentId,
resolveSessionStoreKey,
resolveStoredSessionKeyForAgentStore,
} from "../session-store-key.js";
import { loadSessionEntry, visitSessionMessagesAsync } from "../session-utils.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
@@ -25,6 +38,12 @@ type ArtifactQuery = {
sessionKey?: string;
runId?: string;
taskId?: string;
agentId?: string;
};
type ResolvedArtifactSession = {
sessionKey: string;
agentId?: string;
};
function artifactError(type: string, message: string, details?: Record<string, unknown>) {
@@ -46,6 +65,66 @@ function asNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function resolveRequesterSessionAgentId(
sessionKey: string | undefined,
cfg?: OpenClawConfig,
): string | undefined {
const key = asNonEmptyString(sessionKey);
if (!key) {
return undefined;
}
const parsed = parseAgentSessionKey(key);
if (!parsed && key.toLowerCase().startsWith("agent:")) {
return undefined;
}
if (cfg) {
const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key });
return resolveSessionStoreAgentId(cfg, canonicalKey);
}
if (parsed) {
return parsed.agentId;
}
return resolveAgentIdFromSessionKey(key);
}
function resolveScopedArtifactSessionKey(
sessionKey: string | undefined,
agentId: string | undefined,
cfg?: OpenClawConfig,
): string | undefined {
const key = asNonEmptyString(sessionKey);
if (!key) {
return undefined;
}
const scopedAgentId = asNonEmptyString(agentId);
if (!scopedAgentId) {
return key;
}
const parsed = parseAgentSessionKey(key);
if (!parsed && key.toLowerCase().startsWith("agent:")) {
return undefined;
}
if (cfg) {
const scopedKey = resolveStoredSessionKeyForAgentStore({
cfg,
agentId: scopedAgentId,
sessionKey: key,
});
if (
scopedKey !== "global" &&
scopedKey !== "unknown" &&
resolveSessionStoreAgentId(cfg, scopedKey) !== normalizeAgentId(scopedAgentId)
) {
return undefined;
}
return scopedKey;
}
if (parsed && parsed.agentId !== normalizeAgentId(scopedAgentId)) {
return undefined;
}
return toAgentStoreSessionKey({ agentId: scopedAgentId, requestKey: key });
}
function normalizeArtifactType(value: string): string {
const normalized = value.trim().toLowerCase();
if (normalized === "image" || normalized === "input_image" || normalized === "image_url") {
@@ -281,33 +360,62 @@ function collectArtifactsFromMessage(params: {
}
}
function resolveQuerySessionKey(query: ArtifactQuery): string | undefined {
function resolveQuerySession(
query: ArtifactQuery,
cfg?: OpenClawConfig,
): ResolvedArtifactSession | undefined {
if (query.sessionKey) {
return query.sessionKey;
const sessionKey = resolveScopedArtifactSessionKey(query.sessionKey, query.agentId, cfg);
if (!sessionKey) {
return undefined;
}
return { sessionKey, ...(query.agentId ? { agentId: query.agentId } : {}) };
}
if (query.runId) {
return resolveSessionKeyForRun(query.runId);
const agentId = query.agentId ?? resolveDefaultAgentId(cfg ?? {});
const sessionKey = resolveSessionKeyForRun(query.runId, { agentId });
const scopedSessionKey = resolveScopedArtifactSessionKey(sessionKey, agentId, cfg);
return scopedSessionKey ? { sessionKey: scopedSessionKey, agentId } : undefined;
}
if (query.taskId) {
const task = getTaskSessionLookupByIdForStatus(query.taskId);
const requesterSessionKey = asNonEmptyString(task?.requesterSessionKey);
const taskAgentId =
asNonEmptyString(task?.agentId) ?? resolveRequesterSessionAgentId(requesterSessionKey, cfg);
if (
query.agentId &&
taskAgentId &&
normalizeAgentId(query.agentId) !== normalizeAgentId(taskAgentId)
) {
return undefined;
}
const agentId = query.agentId ?? taskAgentId ?? resolveDefaultAgentId(cfg ?? {});
if (requesterSessionKey) {
return requesterSessionKey;
const scopedSessionKey = resolveScopedArtifactSessionKey(requesterSessionKey, agentId, cfg);
return scopedSessionKey ? { sessionKey: scopedSessionKey, agentId } : undefined;
}
const runId = asNonEmptyString(task?.runId);
return runId ? resolveSessionKeyForRun(runId) : undefined;
const sessionKey = runId ? resolveSessionKeyForRun(runId, { agentId }) : undefined;
const scopedSessionKey = resolveScopedArtifactSessionKey(sessionKey, agentId, cfg);
return scopedSessionKey ? { sessionKey: scopedSessionKey, agentId } : undefined;
}
return undefined;
}
async function loadArtifacts(
query: ArtifactQuery,
cfg?: OpenClawConfig,
): Promise<{ artifacts: ArtifactRecord[]; sessionKey?: string }> {
const sessionKey = resolveQuerySessionKey(query);
if (!sessionKey) {
const resolved = resolveQuerySession(query, cfg);
if (!resolved) {
return { artifacts: [] };
}
const { storePath, entry } = loadSessionEntry(sessionKey);
const { sessionKey } = resolved;
const scopedGlobalAgentId =
cfg?.session?.scope === "global" && sessionKey === "global" ? resolved.agentId : undefined;
const { storePath, entry } = scopedGlobalAgentId
? loadSessionEntry(sessionKey, { agentId: scopedGlobalAgentId })
: loadSessionEntry(sessionKey);
const sessionId = entry?.sessionId;
if (!sessionId || !storePath) {
return { sessionKey, artifacts: [] };
@@ -353,11 +461,14 @@ function requireQueryable(params: ArtifactQuery, respond: RespondFn): boolean {
return false;
}
async function findArtifact(params: ArtifactsGetParams): Promise<{
async function findArtifact(
params: ArtifactsGetParams,
cfg?: OpenClawConfig,
): Promise<{
artifact?: ArtifactRecord;
sessionKey?: string;
}> {
const loaded = await loadArtifacts(params);
const loaded = await loadArtifacts(params, cfg);
return {
sessionKey: loaded.sessionKey,
artifact: loaded.artifacts.find((artifact) => artifact.id === params.artifactId),
@@ -370,14 +481,14 @@ function toSummary(artifact: ArtifactRecord): ArtifactSummary {
}
export const artifactsHandlers: GatewayRequestHandlers = {
"artifacts.list": async ({ params, respond }) => {
"artifacts.list": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateArtifactsListParams, "artifacts.list", respond)) {
return;
}
if (!requireQueryable(params, respond)) {
return;
}
const { artifacts, sessionKey } = await loadArtifacts(params);
const { artifacts, sessionKey } = await loadArtifacts(params, context.getRuntimeConfig?.());
if (!sessionKey && (params.runId || params.taskId)) {
respond(
false,
@@ -388,14 +499,14 @@ export const artifactsHandlers: GatewayRequestHandlers = {
}
respond(true, { artifacts: artifacts.map(toSummary) });
},
"artifacts.get": async ({ params, respond }) => {
"artifacts.get": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateArtifactsGetParams, "artifacts.get", respond)) {
return;
}
if (!requireQueryable(params, respond)) {
return;
}
const { artifact } = await findArtifact(params);
const { artifact } = await findArtifact(params, context.getRuntimeConfig?.());
if (!artifact) {
respond(
false,
@@ -408,7 +519,7 @@ export const artifactsHandlers: GatewayRequestHandlers = {
}
respond(true, { artifact: toSummary(artifact) });
},
"artifacts.download": async ({ params, respond }) => {
"artifacts.download": async ({ params, respond, context }) => {
if (
!assertValidParams(params, validateArtifactsDownloadParams, "artifacts.download", respond)
) {
@@ -417,7 +528,7 @@ export const artifactsHandlers: GatewayRequestHandlers = {
if (!requireQueryable(params, respond)) {
return;
}
const { artifact } = await findArtifact(params);
const { artifact } = await findArtifact(params, context.getRuntimeConfig?.());
if (!artifact) {
respond(
false,
@@ -0,0 +1,270 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayRequestContext, RespondFn } from "./types.js";
const chatAbortMock = vi.fn();
const resolveSessionKeyForRunMock = vi.fn();
vi.mock("../server-session-key.js", () => ({
resolveSessionKeyForRun: (...args: unknown[]) => resolveSessionKeyForRunMock(...args),
}));
vi.mock("./chat.js", () => ({
chatHandlers: {
"chat.abort": (...args: unknown[]) => chatAbortMock(...args),
},
}));
vi.mock("../session-utils.js", async () => {
const actual = await vi.importActual<typeof import("../session-utils.js")>("../session-utils.js");
return {
...actual,
loadSessionEntry: (sessionKey: string) => ({ canonicalKey: sessionKey }),
};
});
import { sessionsHandlers } from "./sessions.js";
function createActiveRun(sessionKey: string) {
const now = Date.now();
return {
controller: new AbortController(),
sessionId: "sess-active",
sessionKey,
startedAtMs: now,
expiresAtMs: now + 30_000,
kind: "chat-send" as const,
};
}
describe("sessions.abort agent scope", () => {
beforeEach(() => {
chatAbortMock.mockReset();
resolveSessionKeyForRunMock.mockReset();
});
it("does not abort an active run whose session key belongs to another requested agent", async () => {
const activeRun = createActiveRun("agent:beta:dashboard:target");
const context = {
chatAbortControllers: new Map([["run-beta", activeRun]]),
getRuntimeConfig: () => ({
agents: { list: [{ id: "main", default: true }, { id: "beta" }] },
}),
} as unknown as GatewayRequestContext;
resolveSessionKeyForRunMock.mockReturnValue(undefined);
const respond = vi.fn() as unknown as RespondFn;
await sessionsHandlers["sessions.abort"]({
req: { id: "req-1" } as never,
params: { runId: "run-beta", agentId: "main" },
respond,
context,
client: null,
isWebchatConnect: () => false,
});
expect(resolveSessionKeyForRunMock).toHaveBeenCalledWith("run-beta", { agentId: "main" });
expect(chatAbortMock).not.toHaveBeenCalled();
expect(activeRun.controller.signal.aborted).toBe(false);
expect(respond).toHaveBeenCalledWith(true, {
ok: true,
abortedRunId: null,
status: "no-active-run",
});
});
it("preserves runId-only aborts for active non-default agent runs", async () => {
const activeRun = createActiveRun("agent:beta:dashboard:target");
const context = {
chatAbortControllers: new Map([["run-beta", activeRun]]),
getRuntimeConfig: () => ({
agents: { list: [{ id: "main", default: true }, { id: "beta" }] },
}),
} as unknown as GatewayRequestContext;
resolveSessionKeyForRunMock.mockReturnValue(undefined);
const respond = vi.fn() as unknown as RespondFn;
await sessionsHandlers["sessions.abort"]({
req: { id: "req-2" } as never,
params: { runId: "run-beta" },
respond,
context,
client: null,
isWebchatConnect: () => false,
});
expect(resolveSessionKeyForRunMock).not.toHaveBeenCalled();
expect(chatAbortMock).toHaveBeenCalledTimes(1);
expect(chatAbortMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
params: { sessionKey: "agent:beta:dashboard:target", runId: "run-beta" },
}),
);
});
it("aborts global-scope active runs for non-default agents", async () => {
const activeRun = createActiveRun("global");
const context = {
chatAbortControllers: new Map([["run-global", activeRun]]),
getRuntimeConfig: () => ({
agents: { list: [{ id: "main", default: true }, { id: "work" }] },
session: { scope: "global" },
}),
} as unknown as GatewayRequestContext;
resolveSessionKeyForRunMock.mockReturnValue(undefined);
const respond = vi.fn() as unknown as RespondFn;
await sessionsHandlers["sessions.abort"]({
req: { id: "req-global" } as never,
params: { runId: "run-global", agentId: "work" },
respond,
context,
client: null,
isWebchatConnect: () => false,
});
expect(resolveSessionKeyForRunMock).not.toHaveBeenCalled();
expect(chatAbortMock).toHaveBeenCalledTimes(1);
expect(chatAbortMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
params: { sessionKey: "global", runId: "run-global" },
}),
);
});
it("aborts an active legacy-key run owned by the configured default agent", async () => {
const activeRun = createActiveRun("main");
const context = {
chatAbortControllers: new Map([["run-work", activeRun]]),
getRuntimeConfig: () => ({
agents: { list: [{ id: "work", default: true }] },
}),
} as unknown as GatewayRequestContext;
resolveSessionKeyForRunMock.mockReturnValue(undefined);
const respond = vi.fn() as unknown as RespondFn;
await sessionsHandlers["sessions.abort"]({
req: { id: "req-3" } as never,
params: { runId: "run-work" },
respond,
context,
client: null,
isWebchatConnect: () => false,
});
expect(resolveSessionKeyForRunMock).not.toHaveBeenCalled();
expect(chatAbortMock).toHaveBeenCalledTimes(1);
expect(chatAbortMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
params: { sessionKey: "main", runId: "run-work" },
}),
);
});
it("rejects key-based aborts when key agent does not match agentId", async () => {
const context = {
chatAbortControllers: new Map(),
getRuntimeConfig: () => ({
agents: { list: [{ id: "main", default: true }, { id: "beta" }] },
}),
} as unknown as GatewayRequestContext;
const respond = vi.fn() as unknown as RespondFn;
await sessionsHandlers["sessions.abort"]({
req: { id: "req-4" } as never,
params: { key: "agent:beta:main", agentId: "main" },
respond,
context,
client: null,
isWebchatConnect: () => false,
});
expect(chatAbortMock).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: "session key agent does not match agentId",
}),
);
});
it("applies agentId to legacy key-based abort aliases", async () => {
const context = {
chatAbortControllers: new Map(),
getRuntimeConfig: () => ({
agents: { list: [{ id: "main", default: true }, { id: "work" }] },
}),
} as unknown as GatewayRequestContext;
const respond = vi.fn() as unknown as RespondFn;
await sessionsHandlers["sessions.abort"]({
req: { id: "req-5" } as never,
params: { key: "main", agentId: "work" },
respond,
context,
client: null,
isWebchatConnect: () => false,
});
expect(chatAbortMock).toHaveBeenCalledTimes(1);
expect(chatAbortMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
params: { sessionKey: "agent:work:main", runId: undefined },
}),
);
});
it("does not use a raw legacy key alias that belongs to another agent", async () => {
const activeRun = createActiveRun("main");
const context = {
chatAbortControllers: new Map([["run-work", activeRun]]),
getRuntimeConfig: () => ({
agents: { list: [{ id: "main", default: true }, { id: "work" }] },
}),
} as unknown as GatewayRequestContext;
const respond = vi.fn() as unknown as RespondFn;
await sessionsHandlers["sessions.abort"]({
req: { id: "req-6" } as never,
params: { key: "main", agentId: "work" },
respond,
context,
client: null,
isWebchatConnect: () => false,
});
expect(chatAbortMock).toHaveBeenCalledTimes(1);
expect(chatAbortMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
params: { sessionKey: "agent:work:main", runId: undefined },
}),
);
});
it("keeps the raw legacy key alias when it belongs to the requested agent", async () => {
const activeRun = createActiveRun("main");
const context = {
chatAbortControllers: new Map([["run-work", activeRun]]),
getRuntimeConfig: () => ({
agents: { list: [{ id: "work", default: true }, { id: "main" }] },
}),
} as unknown as GatewayRequestContext;
const respond = vi.fn() as unknown as RespondFn;
await sessionsHandlers["sessions.abort"]({
req: { id: "req-7" } as never,
params: { key: "main", agentId: "work" },
respond,
context,
client: null,
isWebchatConnect: () => false,
});
expect(chatAbortMock).toHaveBeenCalledTimes(1);
expect(chatAbortMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
params: { sessionKey: "main", runId: undefined },
}),
);
});
});
+125 -16
View File
@@ -84,6 +84,12 @@ import {
listSessionCompactionCheckpoints,
} from "../session-compaction-checkpoints.js";
import { triggerSessionPatchHook } from "../session-patch-hooks.js";
import {
resolveSessionStoreAgentId,
resolveSessionStoreKey,
resolveStoredSessionKeyForAgentStore,
resolveStoredSessionOwnerAgentId,
} from "../session-store-key.js";
import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js";
import {
archiveFileOnDisk,
@@ -540,26 +546,80 @@ function resolveAbortSessionKey(params: {
context: Pick<GatewayRequestContext, "chatAbortControllers">;
requestedKey: string;
canonicalKey: string;
runId?: string;
activeRunSessionKey?: string;
aliasKeys?: string[];
}): string {
const activeRunKey =
typeof params.runId === "string"
? params.context.chatAbortControllers.get(params.runId)?.sessionKey
: undefined;
if (activeRunKey) {
return activeRunKey;
if (params.activeRunSessionKey) {
return params.activeRunSessionKey;
}
const candidates = [params.canonicalKey, params.requestedKey, ...(params.aliasKeys ?? [])];
for (const active of params.context.chatAbortControllers.values()) {
if (active.sessionKey === params.canonicalKey) {
return params.canonicalKey;
}
if (active.sessionKey === params.requestedKey) {
return params.requestedKey;
for (const candidate of candidates) {
if (active.sessionKey === candidate) {
return candidate;
}
}
}
return params.requestedKey;
}
function resolveSessionKeyAgentId(
sessionKey: string | undefined,
cfg: OpenClawConfig,
): string | undefined {
const key = normalizeOptionalString(sessionKey);
if (!key) {
return undefined;
}
if (!parseAgentSessionKey(key) && key.toLowerCase().startsWith("agent:")) {
return undefined;
}
const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey: key });
return resolveSessionStoreAgentId(cfg, canonicalKey);
}
function sessionKeyBelongsToAgent(
sessionKey: string | undefined,
agentId: string,
cfg: OpenClawConfig,
): boolean {
const key = normalizeOptionalString(sessionKey);
if (cfg.session?.scope === "global" && key?.toLowerCase() === "global") {
return true;
}
const sessionAgentId = resolveSessionKeyAgentId(sessionKey, cfg);
return Boolean(sessionAgentId && sessionAgentId === normalizeAgentId(agentId));
}
function resolveScopedAbortKey(params: {
cfg: OpenClawConfig;
key: string | undefined;
agentId: string | undefined;
}): string | undefined {
const key = normalizeOptionalString(params.key);
if (!key) {
return undefined;
}
const requestedAgentId = normalizeOptionalString(params.agentId);
if (!requestedAgentId) {
return key;
}
const scopedAgentId = normalizeAgentId(requestedAgentId);
const ownerAgentId = resolveStoredSessionOwnerAgentId({
cfg: params.cfg,
agentId: scopedAgentId,
sessionKey: key,
});
if (ownerAgentId && ownerAgentId !== scopedAgentId) {
return undefined;
}
return resolveStoredSessionKeyForAgentStore({
cfg: params.cfg,
agentId: scopedAgentId,
sessionKey: key,
});
}
function collectTrackedActiveSessionRunKeys(
context: Partial<Pick<GatewayRequestContext, "chatAbortControllers">>,
): Set<string> {
@@ -1676,11 +1736,53 @@ export const sessionsHandlers: GatewayRequestHandlers = {
return;
}
const p = params;
const cfg = context.getRuntimeConfig();
const requestedRunId = readStringValue(p.runId);
const requestedKey = normalizeOptionalString(p.key);
const requestedParamAgentId = normalizeOptionalString(p.agentId);
const scopedRequestedKey = resolveScopedAbortKey({
cfg,
key: requestedKey,
agentId: requestedParamAgentId,
});
if (requestedKey && requestedParamAgentId && !scopedRequestedKey) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"),
);
return;
}
const requestedKeyAgentId = scopedRequestedKey
? resolveSessionKeyAgentId(scopedRequestedKey, cfg)
: undefined;
const activeRunSessionKey = requestedRunId
? context.chatAbortControllers.get(requestedRunId)?.sessionKey
: undefined;
const inferredRunAgentId =
requestedParamAgentId ??
requestedKeyAgentId ??
(requestedRunId && !activeRunSessionKey ? resolveDefaultAgentId(cfg) : undefined);
const requestedRunAgentId = requestedRunId
? inferredRunAgentId
? normalizeAgentId(inferredRunAgentId)
: undefined
: undefined;
const scopedActiveRunSessionKey = activeRunSessionKey
? requestedRunAgentId
? sessionKeyBelongsToAgent(activeRunSessionKey, requestedRunAgentId, cfg)
? activeRunSessionKey
: undefined
: activeRunSessionKey
: undefined;
const keyCandidate =
p.key ??
(requestedRunId ? context.chatAbortControllers.get(requestedRunId)?.sessionKey : undefined) ??
(requestedRunId ? resolveSessionKeyForRun(requestedRunId) : undefined);
scopedRequestedKey ??
scopedActiveRunSessionKey ??
(requestedRunId
? resolveSessionKeyForRun(requestedRunId, {
agentId: requestedRunAgentId ?? resolveDefaultAgentId(cfg),
})
: undefined);
if (!keyCandidate && requestedRunId) {
respond(true, { ok: true, abortedRunId: null, status: "no-active-run" });
return;
@@ -1690,11 +1792,18 @@ export const sessionsHandlers: GatewayRequestHandlers = {
return;
}
const { canonicalKey } = loadSessionEntry(key);
const requestedKeyAliases =
requestedKey &&
requestedKey !== key &&
(!requestedParamAgentId || sessionKeyBelongsToAgent(requestedKey, requestedParamAgentId, cfg))
? [requestedKey]
: undefined;
const abortSessionKey = resolveAbortSessionKey({
context,
requestedKey: key,
canonicalKey,
runId: requestedRunId,
activeRunSessionKey: scopedActiveRunSessionKey,
aliasKeys: requestedKeyAliases,
});
// Capture run kinds before the abort because abortChatRunById deletes entries
// from chatAbortControllers synchronously. We use this snapshot to choose the
@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withEnvAsync } from "../../test-utils/env.js";
vi.mock("../../config/config.js", () => {
@@ -50,6 +51,16 @@ vi.mock("../../infra/session-cost-usage.js", async () => {
},
];
}
if (params?.agentId === "codex") {
return [
{
sessionId: "s-codex",
sessionFile: "/tmp/agents/codex/sessions/s-codex.jsonl",
mtime: 300,
firstUserMessage: "disk",
},
];
}
return [];
}),
loadSessionCostSummaryFromCache: vi.fn(async () => ({
@@ -97,12 +108,15 @@ const TEST_RUNTIME_CONFIG = {
session: {},
};
async function runSessionsUsage(params: Record<string, unknown>) {
async function runSessionsUsage(
params: Record<string, unknown>,
config: OpenClawConfig = TEST_RUNTIME_CONFIG,
) {
const respond = vi.fn();
await usageHandlers["sessions.usage"]({
respond,
params,
context: { getRuntimeConfig: () => TEST_RUNTIME_CONFIG },
context: { getRuntimeConfig: () => config },
} as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]);
return respond;
}
@@ -162,25 +176,231 @@ describe("sessions.usage", () => {
vi.clearAllMocks();
});
it("discovers sessions across configured agents and keeps agentId in key", async () => {
it("defaults list-style usage queries without agentId to the default agent", async () => {
const respond = await runSessionsUsage(BASE_USAGE_RANGE);
expect(vi.mocked(discoverAllSessions)).toHaveBeenCalledTimes(2);
expect(vi.mocked(loadCombinedSessionStoreForGateway)).toHaveBeenCalledWith(
TEST_RUNTIME_CONFIG,
{ agentId: "main" },
);
expect(vi.mocked(discoverAllSessions)).toHaveBeenCalledTimes(1);
expect((mockArg(vi.mocked(discoverAllSessions), 0, 0) as { agentId?: string }).agentId).toBe(
"main",
);
expect((mockArg(vi.mocked(discoverAllSessions), 1, 0) as { agentId?: string }).agentId).toBe(
const sessions = expectSuccessfulSessionsUsage(respond);
expect(sessions).toHaveLength(1);
expect(sessions[0].key).toBe("agent:main:s-main");
expect(sessions[0].agentId).toBe("main");
});
it("uses the requested agent for list-style usage queries", async () => {
const respond = await runSessionsUsage({ ...BASE_USAGE_RANGE, agentId: "opus" });
expect(vi.mocked(loadCombinedSessionStoreForGateway)).toHaveBeenCalledWith(
TEST_RUNTIME_CONFIG,
{ agentId: "opus" },
);
expect(vi.mocked(discoverAllSessions)).toHaveBeenCalledTimes(1);
expect((mockArg(vi.mocked(discoverAllSessions), 0, 0) as { agentId?: string }).agentId).toBe(
"opus",
);
const sessions = expectSuccessfulSessionsUsage(respond);
expect(sessions).toHaveLength(2);
// Sorted by most recent first (mtime=200 -> opus first).
expect(sessions).toHaveLength(1);
expect(sessions[0].key).toBe("agent:opus:s-opus");
expect(sessions[0].agentId).toBe("opus");
expect(sessions[1].key).toBe("agent:main:s-main");
expect(sessions[1].agentId).toBe("main");
});
it("discovers usage for requested disk-only agents not listed in config", async () => {
const respond = await runSessionsUsage({ ...BASE_USAGE_RANGE, agentId: "codex" });
expect(vi.mocked(loadCombinedSessionStoreForGateway)).toHaveBeenCalledWith(
TEST_RUNTIME_CONFIG,
{ agentId: "codex" },
);
expect(vi.mocked(discoverAllSessions)).toHaveBeenCalledTimes(1);
expect((mockArg(vi.mocked(discoverAllSessions), 0, 0) as { agentId?: string }).agentId).toBe(
"codex",
);
const sessions = expectSuccessfulSessionsUsage(respond);
expect(sessions).toHaveLength(1);
expect(sessions[0].key).toBe("agent:codex:s-codex");
expect(sessions[0].agentId).toBe("codex");
});
it("does not attach out-of-scope store entries to list-style usage results", async () => {
vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({
storePath: "(multiple)",
store: {
"agent:main:s-opus": {
sessionId: "s-opus",
sessionFile: "s-opus.jsonl",
label: "Main session",
updatedAt: 999,
},
},
});
const respond = await runSessionsUsage({ ...BASE_USAGE_RANGE, agentId: "opus" });
const sessions = expectSuccessfulSessionsUsage(respond);
expect(sessions).toHaveLength(1);
expect(sessions[0]?.key).toBe("agent:opus:s-opus");
expect(sessions[0]?.agentId).toBe("opus");
expect(vi.mocked(loadSessionCostSummaryFromCache)).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "opus",
sessionId: "s-opus",
}),
);
});
it("uses the requested agent for legacy specific session keys", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const agentSessionsDir = path.join(stateDir, "agents", "opus", "sessions");
fs.mkdirSync(agentSessionsDir, { recursive: true });
const sessionFile = path.join(agentSessionsDir, "main.jsonl");
fs.writeFileSync(sessionFile, "", "utf-8");
vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({
storePath: "(multiple)",
store: {
"agent:opus:main": {
sessionId: "main",
sessionFile: "main.jsonl",
label: "Opus main",
updatedAt: 999,
},
},
});
const respond = await runSessionsUsage({
...BASE_USAGE_RANGE,
key: "main",
agentId: "opus",
});
const sessions = expectSuccessfulSessionsUsage(respond);
expect(sessions).toHaveLength(1);
expect(sessions[0]?.key).toBe("agent:opus:main");
expect(vi.mocked(loadSessionCostSummaryFromCache)).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "opus",
sessionFile,
sessionId: "main",
}),
);
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("keeps global session entries in requested-agent usage lookups", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));
const config: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true }, { id: "opus" }],
},
session: { scope: "global" },
};
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const agentSessionsDir = path.join(stateDir, "agents", "opus", "sessions");
fs.mkdirSync(agentSessionsDir, { recursive: true });
const sessionFile = path.join(agentSessionsDir, "current.jsonl");
fs.writeFileSync(sessionFile, "", "utf-8");
const sessionEntry = {
sessionId: "current",
sessionFile: "current.jsonl",
label: "Opus global",
updatedAt: 999,
};
vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({
storePath: "(multiple)",
store: {
global: sessionEntry,
},
});
const respond = await runSessionsUsage(
{
...BASE_USAGE_RANGE,
key: "global",
agentId: "opus",
},
config,
);
const sessions = expectSuccessfulSessionsUsage(respond);
expect(sessions).toHaveLength(1);
expect(sessions[0]?.key).toBe("global");
expect(sessions[0]?.agentId).toBe("opus");
expect(vi.mocked(loadSessionCostSummaryFromCache)).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "opus",
sessionEntry,
sessionFile,
sessionId: "current",
}),
);
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("does not resolve specific usage keys through out-of-scope sessionId matches", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const agentSessionsDir = path.join(stateDir, "agents", "opus", "sessions");
fs.mkdirSync(agentSessionsDir, { recursive: true });
const sessionFile = path.join(agentSessionsDir, "shared.jsonl");
fs.writeFileSync(sessionFile, "", "utf-8");
vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({
storePath: "(multiple)",
store: {
"agent:main:shared": {
sessionId: "shared",
sessionFile: "shared.jsonl",
label: "Main shared",
updatedAt: 999,
},
},
});
const respond = await runSessionsUsage({
...BASE_USAGE_RANGE,
key: "shared",
agentId: "opus",
});
const sessions = expectSuccessfulSessionsUsage(respond);
expect(sessions).toHaveLength(1);
expect(sessions[0]?.key).toBe("agent:opus:shared");
expect(sessions[0]?.agentId).toBe("opus");
expect(vi.mocked(loadSessionCostSummaryFromCache)).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "opus",
sessionEntry: undefined,
sessionFile,
sessionId: "shared",
}),
);
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => {
+87 -19
View File
@@ -1,4 +1,5 @@
import fs from "node:fs";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
resolveSessionFilePath,
resolveSessionFilePathOptions,
@@ -23,7 +24,7 @@ import {
type DiscoveredSession,
type UsageCacheStatus,
} from "../../infra/session-cost-usage.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import {
@@ -42,6 +43,10 @@ import {
formatValidationErrors,
validateSessionsUsageParams,
} from "../protocol/index.js";
import {
resolveSessionStoreAgentId,
resolveStoredSessionKeyForAgentStore,
} from "../session-store-key.js";
import {
listAgentsForGateway,
loadCombinedSessionStoreForGateway,
@@ -308,6 +313,7 @@ type UsageGroupingMode = "instance" | "family";
type MergedEntry = {
key: string;
agentId: string;
sessionId: string;
sessionFile: string;
label?: string;
@@ -347,24 +353,48 @@ function buildStoreBySessionId(
return storeBySessionId;
}
function filterSessionStoreByAgent(params: {
config: OpenClawConfig;
store: Record<string, SessionEntry>;
agentId: string;
}): Record<string, SessionEntry> {
const scopedAgentId = normalizeAgentId(params.agentId);
const scopedStore: Record<string, SessionEntry> = {};
for (const [key, entry] of Object.entries(params.store)) {
if (params.config.session?.scope === "global" && key.trim().toLowerCase() === "global") {
scopedStore[key] = entry;
continue;
}
if (resolveSessionStoreAgentId(params.config, key) === scopedAgentId) {
scopedStore[key] = entry;
}
}
return scopedStore;
}
async function discoverAllSessionsForUsage(params: {
config: OpenClawConfig;
agentId?: string;
startMs: number;
endMs: number;
}): Promise<DiscoveredSessionWithAgent[]> {
const agents = listAgentsForGateway(params.config).agents;
const results = await Promise.all(
const requestedAgentId = normalizeOptionalString(params.agentId);
const agents = requestedAgentId
? [{ id: normalizeAgentId(requestedAgentId) }]
: listAgentsForGateway(params.config).agents;
const discovered = await Promise.all(
agents.map(async (agent) => {
const agentId = normalizeAgentId(agent.id);
const sessions = await discoverAllSessions({
agentId: agent.id,
agentId,
startMs: params.startMs,
endMs: params.endMs,
includeFirstUserMessage: false,
});
return sessions.map((session) => Object.assign({}, session, { agentId: agent.id }));
return sessions.map((session) => Object.assign({}, session, { agentId }));
}),
);
return results.flat().toSorted((a, b) => b.mtime - a.mtime);
return discovered.flat().toSorted((a, b) => b.mtime - a.mtime);
}
function addUniqueSessionIds(target: string[], ids: Array<string | undefined>): string[] {
@@ -827,30 +857,64 @@ export const usageHandlers: GatewayRequestHandlers = {
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
const includeContextWeight = p.includeContextWeight ?? false;
const specificKey = normalizeOptionalString(p.key) ?? null;
const requestedAgentId = normalizeOptionalString(p.agentId);
const specificKeyAgentId = specificKey ? parseAgentSessionKey(specificKey)?.agentId : undefined;
if (
requestedAgentId &&
specificKeyAgentId &&
normalizeAgentId(requestedAgentId) !== specificKeyAgentId
) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "session key agent does not match agentId"),
);
return;
}
const effectiveAgentId = normalizeAgentId(
requestedAgentId ?? specificKeyAgentId ?? resolveDefaultAgentId(config),
);
const groupingMode: UsageGroupingMode =
p.groupBy === "family" || p.includeHistorical === true ? "family" : "instance";
// Load session store for named sessions
const { storePath, store } = loadCombinedSessionStoreForGateway(config);
const { storePath, store } = loadCombinedSessionStoreForGateway(config, {
agentId: effectiveAgentId,
});
const scopedStore = filterSessionStoreByAgent({
config,
store,
agentId: effectiveAgentId,
});
const now = Date.now();
const mergedEntries: MergedEntry[] = [];
// Optimization: If a specific key is requested, skip full directory scan
if (specificKey) {
const parsed = parseAgentSessionKey(specificKey);
const agentIdFromKey = parsed?.agentId;
const keyRest = parsed?.rest ?? specificKey;
const scopedSpecificKey = resolveStoredSessionKeyForAgentStore({
cfg: config,
agentId: effectiveAgentId,
sessionKey: specificKey,
});
const scopedParsed = parseAgentSessionKey(scopedSpecificKey);
const agentIdFromKey = scopedParsed?.agentId ?? effectiveAgentId;
const keyRest = scopedParsed?.rest ?? specificKey;
// Prefer the store entry when available, even if the caller provides a discovered key
// (`agent:<id>:<sessionId>`) for a session that now has a canonical store key.
const storeBySessionId = buildStoreBySessionId(store);
const storeBySessionId = buildStoreBySessionId(scopedStore);
const storeMatch = store[specificKey]
? { key: specificKey, entry: store[specificKey] }
: null;
const storeByIdMatch = storeBySessionId.get(keyRest) ?? null;
const resolvedStoreKey = storeMatch?.key ?? storeByIdMatch?.key ?? specificKey;
const storeMatch = scopedStore[scopedSpecificKey]
? { key: scopedSpecificKey, entry: scopedStore[scopedSpecificKey] }
: scopedStore[specificKey]
? { key: specificKey, entry: scopedStore[specificKey] }
: null;
const storeByIdMatch =
storeBySessionId.get(keyRest) ??
(keyRest !== specificKey ? storeBySessionId.get(specificKey) : undefined) ??
null;
const resolvedStoreKey = storeMatch?.key ?? storeByIdMatch?.key ?? scopedSpecificKey;
const storeEntry = storeMatch?.entry ?? storeByIdMatch?.entry;
const sessionId = storeEntry?.sessionId ?? keyRest;
@@ -885,6 +949,7 @@ export const usageHandlers: GatewayRequestHandlers = {
groupingMode,
base: {
key: resolvedStoreKey,
agentId: agentIdFromKey,
sessionId,
sessionFile,
label: storeEntry?.label,
@@ -901,15 +966,16 @@ export const usageHandlers: GatewayRequestHandlers = {
// Full discovery for list view
const discoveredSessions = await discoverAllSessionsForUsage({
config,
agentId: effectiveAgentId,
startMs,
endMs,
});
// Build a map of sessionId -> store entry for quick lookup
const storeBySessionId = buildStoreBySessionId(store);
const storeBySessionId = buildStoreBySessionId(scopedStore);
const storeFamilySessionIds = new Set<string>();
if (groupingMode === "family") {
for (const entry of Object.values(store)) {
for (const entry of Object.values(scopedStore)) {
for (const sessionId of entry?.usageFamilySessionIds ?? []) {
storeFamilySessionIds.add(sessionId);
}
@@ -925,6 +991,7 @@ export const usageHandlers: GatewayRequestHandlers = {
groupingMode,
base: {
key: storeMatch.key,
agentId: discovered.agentId,
sessionId: discovered.sessionId,
sessionFile: discovered.sessionFile,
label: storeMatch.entry.label,
@@ -940,6 +1007,7 @@ export const usageHandlers: GatewayRequestHandlers = {
mergedEntries.push({
// Keep agentId in the key so the dashboard can attribute sessions and later fetch logs.
key: `agent:${discovered.agentId}:${discovered.sessionId}`,
agentId: discovered.agentId,
sessionId: discovered.sessionId,
sessionFile: discovered.sessionFile,
label: undefined, // No label for unnamed sessions
@@ -1040,7 +1108,7 @@ export const usageHandlers: GatewayRequestHandlers = {
};
for (const merged of limitedEntries) {
const agentId = parseAgentSessionKey(merged.key)?.agentId;
const agentId = merged.agentId;
let usage: SessionCostSummary | null = null;
const includedSessionIds = merged.includedSessionIds ?? [merged.sessionId];
for (const includedSessionId of includedSessionIds) {
+195 -11
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resetAgentRunContextForTest } from "../infra/agent-events.js";
import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js";
const hoisted = vi.hoisted(() => ({
loadConfigMock: vi.fn<() => OpenClawConfig>(),
@@ -11,16 +11,14 @@ vi.mock("../config/io.js", () => ({
getRuntimeConfig: () => hoisted.loadConfigMock(),
}));
vi.mock("../config/io.js", () => ({
getRuntimeConfig: () => hoisted.loadConfigMock(),
}));
vi.mock("./session-utils.js", async () => {
const actual = await vi.importActual<typeof import("./session-utils.js")>("./session-utils.js");
return {
...actual,
loadCombinedSessionStoreForGateway: (cfg: OpenClawConfig) =>
hoisted.loadCombinedSessionStoreForGatewayMock(cfg),
loadCombinedSessionStoreForGateway: (
cfg: OpenClawConfig,
opts?: { agentId?: string; configuredAgentsOnly?: boolean },
) => hoisted.loadCombinedSessionStoreForGatewayMock(cfg, opts),
};
});
@@ -50,14 +48,200 @@ describe("resolveSessionKeyForRun", () => {
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(multiple)",
store: {
"agent:retired:acp:run-1": { sessionId: "run-1", updatedAt: 123 },
"agent:main:acp:run-1": { sessionId: "run-1", updatedAt: 123 },
},
});
expect(resolveSessionKeyForRun("run-1")).toBe("acp:run-1");
expect(resolveSessionKeyForRun("run-1")).toBe("acp:run-1");
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledTimes(1);
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg);
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg, {
agentId: "main",
});
});
it("uses the requested agent scope for run lookups", () => {
const cfg: OpenClawConfig = {
session: {
store: "/custom/root/agents/{agentId}/sessions/sessions.json",
},
};
hoisted.loadConfigMock.mockReturnValue(cfg);
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(multiple)",
store: {
"agent:retired:acp:run-1": { sessionId: "run-1", updatedAt: 123 },
},
});
expect(resolveSessionKeyForRun("run-1", { agentId: "retired" })).toBe("acp:run-1");
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg, {
agentId: "retired",
});
});
it("defaults run id lookups without explicit agent scope to the default agent", () => {
const cfg: OpenClawConfig = {
session: {
store: "/custom/root/agents/{agentId}/sessions/sessions.json",
},
};
hoisted.loadConfigMock.mockReturnValue(cfg);
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(multiple)",
store: {
"agent:retired:acp:run-1": { sessionId: "run-1", updatedAt: 123 },
},
});
expect(resolveSessionKeyForRun("run-1")).toBeUndefined();
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg, {
agentId: "main",
});
});
it("filters same-run matches by requested agent for shared stores", () => {
const cfg: OpenClawConfig = {
session: {
store: "/custom/root/sessions/sessions.json",
},
};
hoisted.loadConfigMock.mockReturnValue(cfg);
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "/custom/root/sessions/sessions.json",
store: {
"agent:work:acp:run-1": { sessionId: "run-1", updatedAt: 123 },
},
});
expect(resolveSessionKeyForRun("run-1", { agentId: "main" })).toBeUndefined();
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg, {
agentId: "main",
});
});
it("allows literal global session keys for scoped lookups when session scope is global", () => {
const cfg: OpenClawConfig = {
session: {
scope: "global",
},
};
hoisted.loadConfigMock.mockReturnValue(cfg);
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(multiple)",
store: {
global: { sessionId: "run-global", updatedAt: 123 },
},
});
expect(resolveSessionKeyForRun("run-global", { agentId: "work" })).toBe("global");
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg, {
agentId: "work",
});
});
it("does not overwrite active run context when a scoped lookup finds another agent store entry", () => {
hoisted.loadConfigMock.mockReturnValue({});
registerAgentRunContext("run-1", { sessionKey: "agent:retired:acp:run-1" });
hoisted.loadCombinedSessionStoreForGatewayMock.mockImplementation(
(_cfg: OpenClawConfig, opts?: { agentId?: string }) => ({
storePath: "(multiple)",
store:
opts?.agentId === "main"
? {
"agent:main:acp:run-1": { sessionId: "run-1", updatedAt: 123 },
}
: {},
}),
);
expect(resolveSessionKeyForRun("run-1", { agentId: "main" })).toBe("acp:run-1");
expect(resolveSessionKeyForRun("run-1", { agentId: "retired" })).toBe("acp:run-1");
});
it("keeps run lookup cache entries scoped by agent", () => {
hoisted.loadConfigMock.mockReturnValue({});
hoisted.loadCombinedSessionStoreForGatewayMock.mockImplementation(
(_cfg: OpenClawConfig, opts?: { agentId?: string }) => ({
storePath: "(multiple)",
store:
opts?.agentId === "retired"
? {
"agent:retired:acp:run-1": { sessionId: "run-1", updatedAt: 123 },
}
: {},
}),
);
expect(resolveSessionKeyForRun("run-1", { agentId: "retired" })).toBe("acp:run-1");
expect(resolveSessionKeyForRun("run-1", { agentId: "main" })).toBeUndefined();
expect(resolveSessionKeyForRun("run-1")).toBeUndefined();
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledTimes(2);
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenNthCalledWith(
2,
{},
{
agentId: "main",
},
);
});
it("uses active legacy run contexts for the main agent", () => {
hoisted.loadConfigMock.mockReturnValue({});
registerAgentRunContext("run-live-main", { sessionKey: "main" });
expect(resolveSessionKeyForRun("run-live-main")).toBe("main");
expect(hoisted.loadCombinedSessionStoreForGatewayMock).not.toHaveBeenCalled();
});
it("uses active legacy run contexts for the configured default agent", () => {
hoisted.loadConfigMock.mockReturnValue({
agents: { list: [{ id: "work", default: true }] },
});
registerAgentRunContext("run-live-work", { sessionKey: "main" });
expect(resolveSessionKeyForRun("run-live-work")).toBe("main");
expect(hoisted.loadCombinedSessionStoreForGatewayMock).not.toHaveBeenCalled();
});
it("uses non-default active run contexts without an explicit agent scope", () => {
hoisted.loadConfigMock.mockReturnValue({});
registerAgentRunContext("run-live-work", { sessionKey: "agent:work:main" });
expect(resolveSessionKeyForRun("run-live-work")).toBe("agent:work:main");
expect(hoisted.loadCombinedSessionStoreForGatewayMock).not.toHaveBeenCalled();
});
it("uses legacy store entries for the configured default agent", () => {
const cfg: OpenClawConfig = {
agents: { list: [{ id: "work", default: true }] },
};
hoisted.loadConfigMock.mockReturnValue(cfg);
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(multiple)",
store: {
main: { sessionId: "run-legacy-default", updatedAt: 123 },
},
});
expect(resolveSessionKeyForRun("run-legacy-default")).toBe("main");
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledWith(cfg, {
agentId: "work",
});
});
it("lets active run context override a cached miss", () => {
hoisted.loadConfigMock.mockReturnValue({});
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(multiple)",
store: {},
});
expect(resolveSessionKeyForRun("run-race")).toBeUndefined();
registerAgentRunContext("run-race", { sessionKey: "agent:main:main" });
expect(resolveSessionKeyForRun("run-race")).toBe("agent:main:main");
expect(hoisted.loadCombinedSessionStoreForGatewayMock).toHaveBeenCalledTimes(1);
});
it("caches misses briefly before re-checking the combined store", () => {
@@ -85,8 +269,8 @@ describe("resolveSessionKeyForRun", () => {
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(multiple)",
store: {
"agent:main:acp:run-dup": { sessionId: "run-dup", updatedAt: 100 },
"agent:main:other": { sessionId: "run-dup", updatedAt: 999 },
"agent:retired:acp:run-dup": { sessionId: "run-dup", updatedAt: 100 },
},
});
@@ -99,7 +283,7 @@ describe("resolveSessionKeyForRun", () => {
storePath: "(multiple)",
store: {
"agent:main:first": { sessionId: "run-ambiguous", updatedAt: 100 },
"agent:retired:second": { sessionId: "run-ambiguous", updatedAt: 100 },
"agent:main:second": { sessionId: "run-ambiguous", updatedAt: 100 },
},
});
+61 -16
View File
@@ -1,8 +1,15 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { getRuntimeConfig } from "../config/io.js";
import type { SessionEntry } from "../config/sessions.js";
import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js";
import { toAgentRequestSessionKey } from "../routing/session-key.js";
import type { OpenClawConfig } from "../config/types.js";
import { getAgentRunContext } from "../infra/agent-events.js";
import {
normalizeAgentId,
parseAgentSessionKey,
toAgentRequestSessionKey,
} from "../routing/session-key.js";
import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js";
import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js";
import { loadCombinedSessionStoreForGateway } from "./session-utils.js";
const RUN_LOOKUP_CACHE_LIMIT = 256;
@@ -15,12 +22,21 @@ type RunLookupCacheEntry = {
const resolvedSessionKeyByRunId = new Map<string, RunLookupCacheEntry>();
function setResolvedSessionKeyCache(runId: string, sessionKey: string | null): void {
function runLookupCacheKey(runId: string, agentId: string): string {
return `${agentId}\0${runId}`;
}
function setResolvedSessionKeyCache(
runId: string,
agentId: string,
sessionKey: string | null,
): void {
if (!runId) {
return;
}
const cacheKey = runLookupCacheKey(runId, agentId);
if (
!resolvedSessionKeyByRunId.has(runId) &&
!resolvedSessionKeyByRunId.has(cacheKey) &&
resolvedSessionKeyByRunId.size >= RUN_LOOKUP_CACHE_LIMIT
) {
const oldest = resolvedSessionKeyByRunId.keys().next().value;
@@ -28,18 +44,48 @@ function setResolvedSessionKeyCache(runId: string, sessionKey: string | null): v
resolvedSessionKeyByRunId.delete(oldest);
}
}
resolvedSessionKeyByRunId.set(runId, {
resolvedSessionKeyByRunId.set(cacheKey, {
sessionKey,
expiresAt: sessionKey === null ? Date.now() + RUN_LOOKUP_MISS_TTL_MS : null,
});
}
export function resolveSessionKeyForRun(runId: string) {
function sessionKeyMatchesAgent(sessionKey: string, agentId: string, cfg: OpenClawConfig): boolean {
if (cfg.session?.scope === "global" && sessionKey.trim().toLowerCase() === "global") {
return true;
}
const normalizedAgentId = normalizeAgentId(agentId);
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed && sessionKey.trim().toLowerCase().startsWith("agent:")) {
return false;
}
const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey, storeAgentId: agentId });
return resolveSessionStoreAgentId(cfg, canonicalKey) === normalizedAgentId;
}
function resolveRunSessionKeyForCaller(storeKey: string) {
return toAgentRequestSessionKey(storeKey) ?? storeKey;
}
export function resolveSessionKeyForRun(runId: string, opts: { agentId?: string } = {}) {
const cfg = getRuntimeConfig();
const explicitAgentId =
typeof opts.agentId === "string" && opts.agentId.trim()
? normalizeAgentId(opts.agentId)
: undefined;
const cached = getAgentRunContext(runId)?.sessionKey;
if (cached) {
if (!explicitAgentId && cached) {
return cached;
}
const cachedLookup = resolvedSessionKeyByRunId.get(runId);
const requestedAgentId = explicitAgentId ?? normalizeAgentId(resolveDefaultAgentId(cfg));
const cacheAgentId = requestedAgentId;
if (cached && sessionKeyMatchesAgent(cached, requestedAgentId, cfg)) {
const sessionKey = resolveRunSessionKeyForCaller(cached);
setResolvedSessionKeyCache(runId, cacheAgentId, sessionKey);
return sessionKey;
}
const cacheKey = runLookupCacheKey(runId, cacheAgentId);
const cachedLookup = resolvedSessionKeyByRunId.get(cacheKey);
if (cachedLookup !== undefined) {
if (cachedLookup.sessionKey !== null) {
return cachedLookup.sessionKey;
@@ -47,21 +93,20 @@ export function resolveSessionKeyForRun(runId: string) {
if ((cachedLookup.expiresAt ?? 0) > Date.now()) {
return undefined;
}
resolvedSessionKeyByRunId.delete(runId);
resolvedSessionKeyByRunId.delete(cacheKey);
}
const cfg = getRuntimeConfig();
const { store } = loadCombinedSessionStoreForGateway(cfg);
const { store } = loadCombinedSessionStoreForGateway(cfg, { agentId: requestedAgentId });
const matches = Object.entries(store).filter(
(entry): entry is [string, SessionEntry] => entry[1]?.sessionId === runId,
(entry): entry is [string, SessionEntry] =>
entry[1]?.sessionId === runId && sessionKeyMatchesAgent(entry[0], requestedAgentId, cfg),
);
const storeKey = resolvePreferredSessionKeyForSessionIdMatches(matches, runId);
if (storeKey) {
const sessionKey = toAgentRequestSessionKey(storeKey) ?? storeKey;
registerAgentRunContext(runId, { sessionKey });
setResolvedSessionKeyCache(runId, sessionKey);
const sessionKey = resolveRunSessionKeyForCaller(storeKey);
setResolvedSessionKeyCache(runId, cacheAgentId, sessionKey);
return sessionKey;
}
setResolvedSessionKeyCache(runId, null);
setResolvedSessionKeyCache(runId, cacheAgentId, null);
return undefined;
}
+8 -2
View File
@@ -762,12 +762,13 @@ export function resolveDeletedAgentIdFromSessionKey(
return agentId;
}
export function loadSessionEntry(sessionKey: string) {
export function loadSessionEntry(sessionKey: string, opts?: { agentId?: string }) {
const cfg = getRuntimeConfig();
const key = normalizeOptionalString(sessionKey) ?? "";
const target = resolveGatewaySessionStoreTarget({
cfg,
key,
...(opts?.agentId ? { agentId: opts.agentId } : {}),
});
const storePath = target.storePath;
const store = loadSessionStore(storePath);
@@ -1271,6 +1272,7 @@ function resolveExplicitDeletedLegacyMainStoreTarget(params: {
export function resolveGatewaySessionStoreTarget(params: {
cfg: OpenClawConfig;
key: string;
agentId?: string;
scanLegacyKeys?: boolean;
store?: Record<string, SessionEntry>;
}): {
@@ -1293,7 +1295,11 @@ export function resolveGatewaySessionStoreTarget(params: {
cfg: params.cfg,
sessionKey: key,
});
const agentId = resolveSessionStoreAgentId(params.cfg, canonicalKey);
const requestedAgentId = normalizeOptionalString(params.agentId);
const agentId =
canonicalKey === "global" && requestedAgentId
? normalizeAgentId(requestedAgentId)
: resolveSessionStoreAgentId(params.cfg, canonicalKey);
const { storePath, store } = resolveGatewaySessionStoreLookup({
cfg: params.cfg,
key,
+2 -1
View File
@@ -3,12 +3,13 @@ import type { TaskRecord } from "./task-registry.types.js";
export function getTaskSessionLookupByIdForStatus(
taskId: string,
): Pick<TaskRecord, "requesterSessionKey" | "runId"> | undefined {
): Pick<TaskRecord, "requesterSessionKey" | "runId" | "agentId"> | undefined {
const task = getTaskById(taskId);
return task
? {
requesterSessionKey: task.requesterSessionKey,
...(task.runId ? { runId: task.runId } : {}),
...(task.agentId ? { agentId: task.agentId } : {}),
}
: undefined;
}