mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user