fix(cli): bound sessions list output

This commit is contained in:
Peter Steinberger
2026-05-04 21:38:39 +01:00
parent 14b5f73e2a
commit 828b6be39d
10 changed files with 267 additions and 51 deletions

View File

@@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai
- Active Memory: give timeout partial transcript recovery enough abort-settle headroom so temporary recall summaries are returned before cleanup. Thanks @vincentkoc.
- Gateway/chat: clear the active reply-run guard before draining queued same-session follow-up turns, so sequential `chat.send` calls no longer trip `ReplyRunAlreadyActiveError` every other request. Fixes #77485. Thanks @bws14email.
- Agents/media: avoid sending generated image, video, and music attachments twice when streamed reply text arrives before the final `MEDIA:` directive.
- CLI/sessions: cap `openclaw sessions` output to the newest 100 rows by default and add `--limit <n|all>` plus JSON pagination metadata, so repeated machine polling of large session stores cannot fan out into unbounded per-row enrichment/output work. Fixes #77500. Thanks @Kaotic3.
- Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob.
- CLI/update: make package-update follow-up processes write completion results and exit explicitly, so Windows packaged upgrades do not hang after the new package finishes post-core plugin work. Thanks @vincentkoc.
- Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc.

View File

@@ -16,17 +16,19 @@ until a message is processed. Use `openclaw channels status --probe`,
`openclaw status --deep`, or `openclaw health --verbose` when you need live
channel connectivity.
Gateway `sessions.list` responses are bounded by default so large long-lived
stores cannot monopolize the Gateway event loop. Pass an explicit positive
`limit` from RPC clients when a different result window is needed; responses
include `totalCount`, `limitApplied`, and `hasMore` when callers need to show
that more rows exist.
`openclaw sessions` and Gateway `sessions.list` responses are bounded by
default so large long-lived stores cannot monopolize the CLI process or Gateway
event loop. The CLI returns the newest 100 sessions by default; pass
`--limit <n>` for a smaller/larger window or `--limit all` when you intentionally
need the full store. JSON responses include `totalCount`, `limitApplied`, and
`hasMore` when callers need to show that more rows exist.
```bash
openclaw sessions
openclaw sessions --agent work
openclaw sessions --all-agents
openclaw sessions --active 120
openclaw sessions --limit 25
openclaw sessions --verbose
openclaw sessions --json
```
@@ -38,6 +40,7 @@ Scope selection:
- `--agent <id>`: one configured agent store
- `--all-agents`: aggregate all configured agent stores
- `--store <path>`: explicit store path (cannot be combined with `--agent` or `--all-agents`)
- `--limit <n|all>`: max rows to output (default `100`; `all` restores full output)
Export a trajectory bundle for a stored session:
@@ -69,6 +72,9 @@ JSON examples:
],
"allAgents": true,
"count": 2,
"totalCount": 2,
"limitApplied": 100,
"hasMore": false,
"activeMinutes": null,
"sessions": [
{ "agentId": "main", "key": "agent:main:main", "model": "gpt-5" },

View File

@@ -191,6 +191,8 @@ describe("registerStatusHealthSessionsCommands", () => {
"/tmp/sessions.json",
"--active",
"120",
"--limit",
"25",
]);
expect(setVerbose).toHaveBeenCalledWith(true);
@@ -199,6 +201,7 @@ describe("registerStatusHealthSessionsCommands", () => {
json: true,
store: "/tmp/sessions.json",
active: "120",
limit: "25",
}),
runtime,
);

View File

@@ -132,6 +132,7 @@ export function registerStatusHealthSessionsCommands(program: Command) {
.option("--agent <id>", "Agent id to inspect (default: configured default agent)")
.option("--all-agents", "Aggregate sessions across all configured agents", false)
.option("--active <minutes>", "Only show sessions updated within the past N minutes")
.option("--limit <count>", 'Max sessions to show (default: 100; use "all" for full output)')
.addHelpText(
"after",
() =>
@@ -140,6 +141,7 @@ export function registerStatusHealthSessionsCommands(program: Command) {
["openclaw sessions --agent work", "List sessions for one agent."],
["openclaw sessions --all-agents", "Aggregate sessions across agents."],
["openclaw sessions --active 120", "Only last 2 hours."],
["openclaw sessions --limit 25", "Show the newest 25 sessions."],
["openclaw sessions --json", "Machine-readable output."],
["openclaw sessions --store ./tmp/sessions.json", "Use a specific session store."],
])}\n\n${theme.muted(
@@ -160,6 +162,7 @@ export function registerStatusHealthSessionsCommands(program: Command) {
agent: opts.agent as string | undefined,
allAgents: Boolean(opts.allAgents),
active: opts.active as string | undefined,
limit: opts.limit as string | undefined,
},
defaultRuntime,
);

View File

@@ -96,6 +96,8 @@ describe("route-args", () => {
"sqlite",
"--active",
"true",
"--limit",
"25",
]),
).toEqual({
json: true,
@@ -103,8 +105,10 @@ describe("route-args", () => {
agent: "default",
store: "sqlite",
active: "true",
limit: "25",
});
expect(parseSessionsRouteArgs(["node", "openclaw", "sessions", "--agent"])).toBeNull();
expect(parseSessionsRouteArgs(["node", "openclaw", "sessions", "--limit"])).toBeNull();
expect(
parseAgentsListRouteArgs(["node", "openclaw", "agents", "list", "--json", "--bindings"]),
).toEqual({

View File

@@ -144,12 +144,17 @@ export function parseSessionsRouteArgs(argv: string[]) {
if (!active.ok) {
return null;
}
const limit = parseOptionalFlagValue(argv, "--limit");
if (!limit.ok) {
return null;
}
return {
json: hasFlag(argv, "--json"),
allAgents: hasFlag(argv, "--all-agents"),
agent: agent.value,
store: store.value,
active: active.value,
limit: limit.value,
};
}

View File

@@ -31,35 +31,37 @@ export const SESSION_KEY_PAD = 26;
export const SESSION_AGE_PAD = 9;
export const SESSION_MODEL_PAD = 14;
export function toSessionDisplayRow(key: string, entry: SessionEntry): SessionDisplayRow {
const updatedAt = entry?.updatedAt ?? null;
return {
key,
updatedAt,
ageMs: updatedAt ? Date.now() - updatedAt : null,
sessionId: entry?.sessionId,
systemSent: entry?.systemSent,
abortedLastRun: entry?.abortedLastRun,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
traceLevel: entry?.traceLevel,
reasoningLevel: entry?.reasoningLevel,
elevatedLevel: entry?.elevatedLevel,
responseUsage: entry?.responseUsage,
groupActivation: entry?.groupActivation,
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: entry?.totalTokens,
totalTokensFresh: entry?.totalTokensFresh,
model: entry?.model,
modelProvider: entry?.modelProvider,
providerOverride: entry?.providerOverride,
modelOverride: entry?.modelOverride,
contextTokens: entry?.contextTokens,
};
}
export function toSessionDisplayRows(store: Record<string, SessionEntry>): SessionDisplayRow[] {
return Object.entries(store)
.map(([key, entry]) => {
const updatedAt = entry?.updatedAt ?? null;
return {
key,
updatedAt,
ageMs: updatedAt ? Date.now() - updatedAt : null,
sessionId: entry?.sessionId,
systemSent: entry?.systemSent,
abortedLastRun: entry?.abortedLastRun,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
traceLevel: entry?.traceLevel,
reasoningLevel: entry?.reasoningLevel,
elevatedLevel: entry?.elevatedLevel,
responseUsage: entry?.responseUsage,
groupActivation: entry?.groupActivation,
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: entry?.totalTokens,
totalTokensFresh: entry?.totalTokensFresh,
model: entry?.model,
modelProvider: entry?.modelProvider,
providerOverride: entry?.providerOverride,
modelOverride: entry?.modelOverride,
contextTokens: entry?.contextTokens,
} satisfies SessionDisplayRow;
})
.map(([key, entry]) => toSessionDisplayRow(key, entry))
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}

View File

@@ -73,12 +73,13 @@ export function writeStore(data: unknown, prefix = "sessions"): string {
export async function runSessionsJson<T>(
run: (
opts: { json?: boolean; store?: string; active?: string },
opts: { json?: boolean; store?: string; active?: string; limit?: string | number },
runtime: RuntimeEnv,
) => Promise<void>,
store: string,
options?: {
active?: string;
limit?: string | number;
},
): Promise<T> {
const { runtime, logs } = makeRuntime();
@@ -88,6 +89,7 @@ export async function runSessionsJson<T>(
store,
json: true,
active: options?.active,
limit: options?.limit,
},
runtime,
);

View File

@@ -154,6 +154,106 @@ describe("sessionsCommand", () => {
expect(payload.sessions?.map((row) => row.key)).toEqual(["recent"]);
});
it("limits JSON output to the newest 100 sessions by default", async () => {
const entries: Record<string, { sessionId: string; updatedAt: number; model: string }> = {};
for (let i = 0; i < 105; i += 1) {
entries[`session-${String(i).padStart(3, "0")}`] = {
sessionId: `session-${i}`,
updatedAt: Date.now() - i * 60_000,
model: "pi:opus",
};
}
const store = writeStore(entries, "sessions-default-limit");
const payload = await runSessionsJson<{
count?: number;
totalCount?: number;
limitApplied?: number | null;
hasMore?: boolean;
sessions?: Array<{ key: string }>;
}>(sessionsCommand, store);
expect(payload.count).toBe(100);
expect(payload.totalCount).toBe(105);
expect(payload.limitApplied).toBe(100);
expect(payload.hasMore).toBe(true);
expect(payload.sessions?.at(0)?.key).toBe("session-000");
expect(payload.sessions?.some((row) => row.key === "session-104")).toBe(false);
});
it("honors explicit JSON output limits", async () => {
const store = writeStore(
{
newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" },
middle: { sessionId: "middle", updatedAt: Date.now() - 60_000, model: "pi:opus" },
oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" },
},
"sessions-explicit-limit",
);
const payload = await runSessionsJson<{
count?: number;
totalCount?: number;
limitApplied?: number | null;
hasMore?: boolean;
sessions?: Array<{ key: string }>;
}>(sessionsCommand, store, { limit: "2" });
expect(payload.count).toBe(2);
expect(payload.totalCount).toBe(3);
expect(payload.limitApplied).toBe(2);
expect(payload.hasMore).toBe(true);
expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "middle"]);
});
it("allows full JSON output with --limit all", async () => {
const store = writeStore(
{
newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" },
oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" },
},
"sessions-limit-all",
);
const payload = await runSessionsJson<{
count?: number;
totalCount?: number;
limitApplied?: number | null;
hasMore?: boolean;
sessions?: Array<{ key: string }>;
}>(sessionsCommand, store, { limit: "all" });
expect(payload.count).toBe(2);
expect(payload.totalCount).toBe(2);
expect(payload.limitApplied).toBeNull();
expect(payload.hasMore).toBe(false);
expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "oldest"]);
});
it("sorts and slices large explicit limits instead of using top-N insertion", async () => {
const store = writeStore(
{
newest: { sessionId: "newest", updatedAt: Date.now(), model: "pi:opus" },
oldest: { sessionId: "oldest", updatedAt: Date.now() - 120_000, model: "pi:opus" },
},
"sessions-large-limit",
);
const payload = await runSessionsJson<{
count?: number;
totalCount?: number;
limitApplied?: number | null;
hasMore?: boolean;
sessions?: Array<{ key: string }>;
}>(sessionsCommand, store, { limit: "100000" });
expect(payload.count).toBe(2);
expect(payload.totalCount).toBe(2);
expect(payload.limitApplied).toBe(100000);
expect(payload.hasMore).toBe(false);
expect(payload.sessions?.map((row) => row.key)).toEqual(["newest", "oldest"]);
});
it("rejects invalid --active values", async () => {
const store = writeStore(
{
@@ -171,4 +271,22 @@ describe("sessionsCommand", () => {
fs.rmSync(store);
});
it("rejects invalid --limit values", async () => {
const store = writeStore(
{
demo: {
sessionId: "demo",
updatedAt: Date.now() - 5 * 60_000,
},
},
"sessions-limit-invalid",
);
const { runtime, errors } = makeRuntime();
await expect(sessionsCommand({ store, limit: "0" }, runtime)).rejects.toThrow("exit 1");
expect(errors[0]).toContain('--limit must be a positive integer or "all"');
fs.rmSync(store);
});
});

View File

@@ -23,7 +23,7 @@ import {
SESSION_KEY_PAD,
SESSION_MODEL_PAD,
type SessionDisplayRow,
toSessionDisplayRows,
toSessionDisplayRow,
} from "./sessions-table.js";
type SessionRow = SessionDisplayRow & {
@@ -35,10 +35,58 @@ type SessionRow = SessionDisplayRow & {
const AGENT_PAD = 10;
const KIND_PAD = 6;
const TOKENS_PAD = 20;
const DEFAULT_SESSIONS_LIMIT = 100;
const TOP_N_SELECTION_LIMIT = 200;
const contextLookupRuntimeLoader = createLazyImportLoader(() => import("../agents/context.js"));
const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
function compareSessionRowsByUpdatedAt(a: SessionRow, b: SessionRow): number {
return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
}
function selectNewestSessionRows(rows: SessionRow[], limit: number | undefined): SessionRow[] {
if (limit === undefined) {
return rows.toSorted(compareSessionRowsByUpdatedAt);
}
if (limit > TOP_N_SELECTION_LIMIT) {
return rows.toSorted(compareSessionRowsByUpdatedAt).slice(0, limit);
}
const selected: SessionRow[] = [];
for (const row of rows) {
const insertAt = selected.findIndex(
(candidate) => compareSessionRowsByUpdatedAt(row, candidate) < 0,
);
if (insertAt >= 0) {
selected.splice(insertAt, 0, row);
if (selected.length > limit) {
selected.pop();
}
} else if (selected.length < limit) {
selected.push(row);
}
}
return selected;
}
function parseSessionsLimit(value: string | number | undefined): number | undefined | null {
if (value === undefined) {
return DEFAULT_SESSIONS_LIMIT;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.toLowerCase() === "all") {
return undefined;
}
if (!/^\d+$/.test(trimmed)) {
return null;
}
const parsed = Number.parseInt(trimmed, 10);
return parsed > 0 ? parsed : null;
}
return Number.isInteger(value) && value > 0 ? value : null;
}
const colorByPct = (label: string, pct: number | null, rich: boolean) => {
if (!rich || pct === null) {
return label;
@@ -115,7 +163,14 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
};
export async function sessionsCommand(
opts: { json?: boolean; store?: string; active?: string; agent?: string; allAgents?: boolean },
opts: {
json?: boolean;
store?: string;
active?: string;
agent?: string;
allAgents?: boolean;
limit?: string | number;
},
runtime: RuntimeEnv,
) {
const aggregateAgents = opts.allAgents === true;
@@ -150,10 +205,25 @@ export async function sessionsCommand(
activeMinutes = parsed;
}
const rows = targets
.flatMap((target) => {
const store = loadSessionStore(target.storePath);
return toSessionDisplayRows(store).map((row) => {
const limit = parseSessionsLimit(opts.limit);
if (limit === null) {
runtime.error('--limit must be a positive integer or "all"');
runtime.exit(1);
return;
}
const allRows = targets.flatMap((target) => {
const store = loadSessionStore(target.storePath);
return Object.entries(store)
.filter(([, entry]) => {
if (activeMinutes === undefined) {
return true;
}
const updatedAt = entry?.updatedAt;
return typeof updatedAt === "number" && Date.now() - updatedAt <= activeMinutes * 60_000;
})
.map(([key, entry]) => {
const row = toSessionDisplayRow(key, entry);
const agentId = parseAgentSessionKey(row.key)?.agentId ?? target.agentId;
return Object.assign({}, row, {
agentId,
@@ -161,17 +231,10 @@ export async function sessionsCommand(
kind: classifySessionKey(row.key, store[row.key]),
});
});
})
.filter((row) => {
if (activeMinutes === undefined) {
return true;
}
if (!row.updatedAt) {
return false;
}
return Date.now() - row.updatedAt <= activeMinutes * 60_000;
})
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
});
const totalCount = allRows.length;
const rows = selectNewestSessionRows(allRows, limit);
const hasMore = rows.length < totalCount;
if (opts.json) {
const multi = targets.length > 1;
@@ -186,6 +249,9 @@ export async function sessionsCommand(
: undefined,
allAgents: aggregateAgents ? true : undefined,
count: rows.length,
totalCount,
limitApplied: limit ?? null,
hasMore,
activeMinutes: activeMinutes ?? null,
sessions: await Promise.all(
rows.map(async (r) => {
@@ -217,7 +283,13 @@ export async function sessionsCommand(
info(`Session stores: ${targets.length} (${targets.map((t) => t.agentId).join(", ")})`),
);
}
runtime.log(info(`Sessions listed: ${rows.length}`));
runtime.log(
info(
hasMore && limit !== undefined
? `Sessions listed: ${rows.length} of ${totalCount} (limit ${limit})`
: `Sessions listed: ${rows.length}`,
),
);
if (activeMinutes) {
runtime.log(info(`Filtered to last ${activeMinutes} minute(s)`));
}