mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(agents): page sessions after filtering (#122725)
This commit is contained in:
committed by
GitHub
parent
5af8935b53
commit
b50a7fc4cd
@@ -524,7 +524,8 @@ describe("sessions tools", () => {
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
label: "mailbox",
|
||||
limit: undefined,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
search: "review",
|
||||
spawnedBy: undefined,
|
||||
},
|
||||
|
||||
@@ -66,6 +66,28 @@ function getSessionsListDetails(result: { details?: unknown }): SessionsListDeta
|
||||
return result.details as SessionsListDetails;
|
||||
}
|
||||
|
||||
function sessionRow(key: string, classification = "dashboard", agentId = "main") {
|
||||
return { key, agentId, kind: "direct", classification };
|
||||
}
|
||||
|
||||
function mockSessionPages(pages: Array<Array<Record<string, unknown>>>) {
|
||||
let pageIndex = 0;
|
||||
let nextOffset = 0;
|
||||
mocks.gatewayCall.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { params?: { limit?: number; offset?: number } };
|
||||
expect(request.params).toEqual(expect.objectContaining({ limit: 200, offset: nextOffset }));
|
||||
const sessions = pages[pageIndex] ?? [];
|
||||
pageIndex += 1;
|
||||
nextOffset += sessions.length;
|
||||
return {
|
||||
path: "/tmp/sessions.json",
|
||||
sessions,
|
||||
hasMore: pageIndex < pages.length,
|
||||
nextOffset: pageIndex < pages.length ? nextOffset : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("sessions-list-tool", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -155,6 +177,68 @@ describe("sessions-list-tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "hidden and global rows",
|
||||
params: { limit: 1 },
|
||||
pages: [
|
||||
[
|
||||
{ key: "global", kind: "global", classification: "global", agentId: "main" },
|
||||
sessionRow("agent:other:dashboard:hidden", "dashboard", "other"),
|
||||
],
|
||||
[sessionRow("agent:main:main", "main")],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "non-matching kinds",
|
||||
params: { kinds: ["main"], limit: 1 },
|
||||
pages: [[sessionRow("agent:main:dashboard:other")], [sessionRow("agent:main:main", "main")]],
|
||||
},
|
||||
])("fills the requested output limit past $name", async ({ params, pages }) => {
|
||||
mockSessionPages(pages);
|
||||
|
||||
const result = await createSessionsListTool({ config: VALID_CONFIG }).execute(
|
||||
"paged-list",
|
||||
params,
|
||||
);
|
||||
|
||||
expect(getSessionsListDetails(result).sessions?.map((session) => session.key)).toEqual([
|
||||
"agent:main:main",
|
||||
]);
|
||||
expect(mocks.gatewayCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails visibly when Gateway pagination stalls", async () => {
|
||||
mocks.gatewayCall.mockResolvedValue({
|
||||
path: "/tmp/sessions.json",
|
||||
sessions: [{ key: "global", kind: "global", classification: "global" }],
|
||||
hasMore: true,
|
||||
nextOffset: 0,
|
||||
});
|
||||
|
||||
await expect(
|
||||
createSessionsListTool({ config: VALID_CONFIG }).execute("stalled-list", { limit: 1 }),
|
||||
).rejects.toThrow("sessions.list returned invalid pagination");
|
||||
});
|
||||
|
||||
it("deduplicates rows when a changing Gateway page overlaps the prior page", async () => {
|
||||
const first = sessionRow("agent:main:dashboard:first");
|
||||
const overlap = sessionRow("agent:main:dashboard:overlap");
|
||||
const finalRow = { ...first, key: "agent:main:dashboard:final" };
|
||||
mockSessionPages([
|
||||
[first, overlap],
|
||||
[overlap, finalRow],
|
||||
]);
|
||||
|
||||
const result = await createSessionsListTool({ config: VALID_CONFIG }).execute(
|
||||
"overlapping-list",
|
||||
{ limit: 3 },
|
||||
);
|
||||
const keys = getSessionsListDetails(result).sessions?.map((session) => session.key) ?? [];
|
||||
|
||||
expect(keys).toEqual([first.key, overlap.key, finalRow.key]);
|
||||
});
|
||||
|
||||
it("adds nonzero state versions with one batch lookup", async () => {
|
||||
mocks.gatewayCall.mockResolvedValue({
|
||||
path: "/tmp/sessions.json",
|
||||
|
||||
@@ -190,50 +190,7 @@ export function createSessionsListTool(opts?: {
|
||||
const gatewayCall = opts?.callGateway ?? callAgentToolGatewayRequest;
|
||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||
const hydrateTranscriptFieldsAfterFiltering = includeDerivedTitles || includeLastMessage;
|
||||
|
||||
const list = await gatewayCall<{ sessions: Array<GatewaySessionListRow>; path: string }>({
|
||||
method: "sessions.list",
|
||||
params: {
|
||||
limit,
|
||||
activeMinutes,
|
||||
label,
|
||||
agentId,
|
||||
search,
|
||||
archived,
|
||||
includeDerivedTitles: false,
|
||||
includeLastMessage: false,
|
||||
includeGlobal: !restrictToSpawned,
|
||||
includeUnknown: !restrictToSpawned,
|
||||
spawnedBy: restrictToSpawned ? effectiveRequesterKey : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Cross-session tool output is copied into durable transcripts, so exposing
|
||||
// incognito rows here would defeat their process-only lifetime.
|
||||
const sessions = (Array.isArray(list?.sessions) ? list.sessions : []).filter(
|
||||
(entry) => !entry || typeof entry !== "object" || !isIncognitoSessionKey(entry.key),
|
||||
);
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const stateVersions = getSessionStateVersions(
|
||||
sessions.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== "object" || typeof entry.key !== "string") {
|
||||
return [];
|
||||
}
|
||||
let stateAgentId =
|
||||
typeof entry.agentId === "string" && entry.agentId ? entry.agentId : undefined;
|
||||
if (!stateAgentId) {
|
||||
try {
|
||||
stateAgentId = resolveAgentIdFromSessionKey(entry.key, defaultAgentId);
|
||||
} catch {
|
||||
// Malformed rows remain subject to the fail-closed visibility checker below,
|
||||
// but cannot participate in agent state-version lookup.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [{ sessionKey: entry.key, agentId: stateAgentId }];
|
||||
}),
|
||||
);
|
||||
const storePath = typeof list?.path === "string" ? list.path : undefined;
|
||||
const visibilityGuard = createSessionVisibilityRowChecker({
|
||||
action: "list",
|
||||
defaultAgentId,
|
||||
@@ -241,6 +198,107 @@ export function createSessionsListTool(opts?: {
|
||||
visibility,
|
||||
a2aPolicy,
|
||||
});
|
||||
const sessions: GatewaySessionListRow[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
const outputLimit = limit ?? 100;
|
||||
let offset = 0;
|
||||
let storePath: string | undefined;
|
||||
for (let pageIndex = 0; sessions.length < outputLimit; pageIndex += 1) {
|
||||
const page = await gatewayCall<{
|
||||
sessions?: GatewaySessionListRow[];
|
||||
path?: string;
|
||||
hasMore?: boolean;
|
||||
nextOffset?: number | null;
|
||||
}>({
|
||||
method: "sessions.list",
|
||||
params: {
|
||||
limit: 200,
|
||||
offset,
|
||||
activeMinutes,
|
||||
label,
|
||||
agentId,
|
||||
search,
|
||||
archived,
|
||||
includeDerivedTitles: false,
|
||||
includeLastMessage: false,
|
||||
includeGlobal: !restrictToSpawned,
|
||||
includeUnknown: !restrictToSpawned,
|
||||
spawnedBy: restrictToSpawned ? effectiveRequesterKey : undefined,
|
||||
},
|
||||
});
|
||||
storePath ??= typeof page?.path === "string" ? page.path : undefined;
|
||||
const pageSessions = Array.isArray(page?.sessions) ? page.sessions : [];
|
||||
for (const entry of pageSessions) {
|
||||
const key =
|
||||
entry && typeof entry === "object" && typeof entry.key === "string" ? entry.key : "";
|
||||
if (!key || seenKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenKeys.add(key);
|
||||
// Cross-session tool output is copied into durable transcripts, so exposing
|
||||
// incognito rows here would defeat their process-only lifetime.
|
||||
if (isIncognitoSessionKey(key)) {
|
||||
continue;
|
||||
}
|
||||
const access = visibilityGuard.check({
|
||||
key,
|
||||
agentId: typeof entry.agentId === "string" ? entry.agentId : undefined,
|
||||
ownerSessionKey:
|
||||
typeof (entry as { ownerSessionKey?: unknown }).ownerSessionKey === "string"
|
||||
? (entry as { ownerSessionKey?: string }).ownerSessionKey
|
||||
: undefined,
|
||||
spawnedBy: typeof entry.spawnedBy === "string" ? entry.spawnedBy : undefined,
|
||||
parentSessionKey:
|
||||
typeof entry.parentSessionKey === "string" ? entry.parentSessionKey : undefined,
|
||||
});
|
||||
const kind = classifySessionListKind(entry);
|
||||
if (
|
||||
access.allowed &&
|
||||
key !== "unknown" &&
|
||||
(key !== "global" || alias === "global") &&
|
||||
(!allowedKinds || allowedKinds.has(kind))
|
||||
) {
|
||||
sessions.push(entry);
|
||||
if (sessions.length === outputLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sessions.length === outputLimit || page?.hasMore !== true) {
|
||||
break;
|
||||
}
|
||||
const nextOffset = page.nextOffset;
|
||||
if (
|
||||
typeof nextOffset !== "number" ||
|
||||
!Number.isSafeInteger(nextOffset) ||
|
||||
nextOffset !== offset + pageSessions.length
|
||||
) {
|
||||
throw new Error(
|
||||
`sessions.list returned invalid pagination metadata (offset=${offset}, nextOffset=${String(nextOffset)})`,
|
||||
);
|
||||
}
|
||||
// Bound unstable Gateway snapshots by both request count and scanned rows.
|
||||
if (pageIndex >= 49 || nextOffset > 10_000) {
|
||||
throw new Error("sessions.list exceeded the 50-page/10,000-row pagination scan limit");
|
||||
}
|
||||
offset = nextOffset;
|
||||
}
|
||||
|
||||
const stateVersions = getSessionStateVersions(
|
||||
sessions.flatMap((entry) => {
|
||||
const key = entry.key;
|
||||
let stateAgentId =
|
||||
typeof entry.agentId === "string" && entry.agentId ? entry.agentId : undefined;
|
||||
if (!stateAgentId) {
|
||||
try {
|
||||
stateAgentId = resolveAgentIdFromSessionKey(key, defaultAgentId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [{ sessionKey: key, agentId: stateAgentId }];
|
||||
}),
|
||||
);
|
||||
const rows: SessionListRow[] = [];
|
||||
const historyTargets: Array<{ row: SessionListRow; resolvedKey: string }> = [];
|
||||
const titleTargets: Array<{
|
||||
@@ -253,42 +311,8 @@ export function createSessionsListTool(opts?: {
|
||||
}> = [];
|
||||
|
||||
for (const entry of sessions) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const key = typeof entry.key === "string" ? entry.key : "";
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const access = visibilityGuard.check({
|
||||
key,
|
||||
agentId: typeof entry.agentId === "string" ? entry.agentId : undefined,
|
||||
ownerSessionKey:
|
||||
typeof (entry as { ownerSessionKey?: unknown }).ownerSessionKey === "string"
|
||||
? (entry as { ownerSessionKey?: string }).ownerSessionKey
|
||||
: undefined,
|
||||
spawnedBy: typeof entry.spawnedBy === "string" ? entry.spawnedBy : undefined,
|
||||
parentSessionKey:
|
||||
typeof entry.parentSessionKey === "string" ? entry.parentSessionKey : undefined,
|
||||
});
|
||||
if (!access.allowed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gateway listings include pseudo/global rows for UI callers. The tool only exposes real
|
||||
// sessions and the explicit global session when the requester is already global.
|
||||
if (key === "unknown") {
|
||||
continue;
|
||||
}
|
||||
if (key === "global" && alias !== "global") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = entry.key;
|
||||
const kind = classifySessionListKind(entry);
|
||||
if (allowedKinds && !allowedKinds.has(kind)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const displayKey = resolveDisplaySessionKey({
|
||||
key,
|
||||
alias,
|
||||
|
||||
Reference in New Issue
Block a user