Skip to content

Commit 3e984c7

Browse files
committed
fix(message-format): honor caller --limit for read, pins and search
1 parent 60bda15 commit 3e984c7

5 files changed

Lines changed: 209 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ Docs: https://docs.openclaw.ai
513513
- Gateway/agent: pass the session-key agent id into inline image attachment validation so the first image in a fresh per-agent session uses the agent's vision-capable model override instead of the text-only system default. Fixes #79407. Thanks @pandadev66.
514514
- Gateway/maintenance: prune dedupe overflow against a stable excess count and keep active agent retries from starting duplicate runs after cache eviction. (#73841) Thanks @thesomewhatyou.
515515
- Control UI/subagents: suppress internal `subagent_announce` handoff prompts from requester transcripts and hide legacy inter-session wrapper rows so completed subagent results no longer surface runtime context in WebChat history. (#79618) Thanks @joshavant.
516+
- CLI/message: honor `--limit` when rendering `message read`, `message pins`, and `message search` output instead of capping the displayed table at 25 rows, and surface a muted hint below the table when the payload has more rows than were displayed, exposes a provider cursor (`hasMore`/`nextBatch`/`@odata.nextLink`), or fills the requested limit so older history may exist. Default behavior is unchanged when no `--limit` is supplied. Fixes #71452. Thanks @pahuchi-joe.
516517
- Discord: preserve username target resolution for Discord outbound sends. (#79076) Thanks @vincentkoc.
517518
- Gateway/sessions: rotate generated transcript paths when gateway sessions reset, complementing the daily-rollover transcript persistence. (#79076) Thanks @vincentkoc.
518519
- Dependencies: pin the transitive `fast-uri` production dependency to `3.1.2` so the production dependency audit no longer resolves the vulnerable `<=3.1.1` range. Thanks @shakkernerd.

docs/cli/message.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Name lookup:
120120
- `pins` (list)
121121
- Channels: Discord/Slack/Matrix
122122
- Required: `--target`
123+
- Optional: `--limit`
123124

124125
- `permissions`
125126
- Channels: Discord/Matrix
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js";
3+
import { formatMessageCliText } from "./message-format.js";
4+
5+
function buildMessages(count: number): Array<Record<string, string>> {
6+
const out: Array<Record<string, string>> = [];
7+
for (let i = 0; i < count; i++) {
8+
out.push({
9+
id: `msg-${i}`,
10+
authorTag: `user${i}`,
11+
timestamp: `2026-05-07T00:00:${String(i).padStart(2, "0")}Z`,
12+
content: `body-marker-${i}-end`,
13+
});
14+
}
15+
return out;
16+
}
17+
18+
function buildResult(action: string, payload: unknown): MessageActionRunResult {
19+
return {
20+
kind: "action",
21+
channel: action === "list-pins" ? "msteams" : "discord",
22+
action,
23+
handledBy: "plugin",
24+
payload,
25+
dryRun: false,
26+
} as MessageActionRunResult;
27+
}
28+
29+
function countRowsInOutput(output: string[], totalCount: number): number {
30+
const joined = output.join("\n");
31+
let rendered = 0;
32+
for (let i = 0; i < totalCount; i++) {
33+
if (joined.includes(`body-marker-${i}-end`)) {
34+
rendered += 1;
35+
}
36+
}
37+
return rendered;
38+
}
39+
40+
describe("formatMessageCliText display limit", () => {
41+
it("read: caps at 25 rows by default", () => {
42+
const result = buildResult("read", { messages: buildMessages(30) });
43+
expect(countRowsInOutput(formatMessageCliText(result), 30)).toBe(25);
44+
});
45+
46+
it("read: honors displayLimit greater than 25", () => {
47+
const result = buildResult("read", { messages: buildMessages(30) });
48+
const out = formatMessageCliText(result, { displayLimit: 30 });
49+
expect(countRowsInOutput(out, 30)).toBe(30);
50+
});
51+
52+
it("list-pins: caps at 25 rows by default and honors displayLimit", () => {
53+
const result = buildResult("list-pins", { pins: buildMessages(30) });
54+
expect(countRowsInOutput(formatMessageCliText(result), 30)).toBe(25);
55+
expect(countRowsInOutput(formatMessageCliText(result, { displayLimit: 30 }), 30)).toBe(30);
56+
});
57+
58+
it("search: caps at 25 rows by default and honors displayLimit", () => {
59+
const messages = buildMessages(30).map((m) => [m]);
60+
const result = buildResult("search", { results: { messages } });
61+
expect(countRowsInOutput(formatMessageCliText(result), 30)).toBe(25);
62+
expect(countRowsInOutput(formatMessageCliText(result, { displayLimit: 30 }), 30)).toBe(30);
63+
});
64+
65+
it("read: payload smaller than displayLimit renders all rows without error", () => {
66+
const result = buildResult("read", { messages: buildMessages(5) });
67+
expect(countRowsInOutput(formatMessageCliText(result, { displayLimit: 50 }), 5)).toBe(5);
68+
});
69+
});
70+
71+
describe("formatMessageCliText pagination hint", () => {
72+
it("emits truncation hint when more rows exist than displayed", () => {
73+
const result = buildResult("read", { messages: buildMessages(40) });
74+
const joined = formatMessageCliText(result).join("\n");
75+
expect(joined).toContain("Showing 25 of 40");
76+
});
77+
78+
it("surfaces hasMore as a pagination hint", () => {
79+
const result = buildResult("read", { messages: buildMessages(5), hasMore: true });
80+
const joined = formatMessageCliText(result).join("\n");
81+
expect(joined).toContain("More results available beyond this page");
82+
});
83+
84+
it("surfaces nextBatch cursor as a generic hint", () => {
85+
const result = buildResult("read", {
86+
messages: buildMessages(5),
87+
nextBatch: "cursor-token-xyz",
88+
});
89+
const joined = formatMessageCliText(result).join("\n");
90+
expect(joined).toContain("More results available beyond this page");
91+
});
92+
93+
it("surfaces @odata.nextLink as a generic hint", () => {
94+
const result = buildResult("list-pins", {
95+
pins: buildMessages(3),
96+
"@odata.nextLink": "https://graph.microsoft.com/...",
97+
});
98+
const joined = formatMessageCliText(result).join("\n");
99+
expect(joined).toContain("More results available beyond this page");
100+
});
101+
102+
it("emits no hint when payload is fully shown and has no cursor metadata", () => {
103+
const result = buildResult("read", { messages: buildMessages(5) });
104+
const joined = formatMessageCliText(result).join("\n");
105+
expect(joined).not.toContain("Showing");
106+
expect(joined).not.toContain("More results available");
107+
expect(joined).not.toContain("Reached --limit");
108+
});
109+
110+
it("emits a heuristic hint when provider returns exactly --limit rows", () => {
111+
// Discord-style: payload has no hasMore/cursor; array length equals requested limit
112+
const result = buildResult("read", { messages: buildMessages(50) });
113+
const joined = formatMessageCliText(result, { displayLimit: 50 }).join("\n");
114+
expect(joined).toContain("Reached --limit (50)");
115+
});
116+
});

src/commands/message-format.ts

Lines changed: 88 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
44
import { formatGatewaySummary, formatOutboundDeliverySummary } from "../infra/outbound/format.js";
55
import type { MessageActionRunResult } from "../infra/outbound/message-action-runner.js";
66
import { formatTargetDisplay } from "../infra/outbound/target-resolver.js";
7-
import { normalizeOptionalString } from "../shared/string-coerce.js";
7+
import { hasNonEmptyString, normalizeOptionalString } from "../shared/string-coerce.js";
88
import { normalizeStringEntries } from "../shared/string-normalization.js";
99
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
1010
import { isRich, theme } from "../terminal/theme.js";
@@ -35,8 +35,11 @@ function extractMessageId(payload: unknown): string | null {
3535

3636
type FormatOpts = {
3737
width: number;
38+
displayLimit?: number;
3839
};
3940

41+
const DEFAULT_MESSAGE_LIST_LIMIT = 25;
42+
4043
function renderObjectSummary(payload: unknown, opts: FormatOpts): string[] {
4144
if (!payload || typeof payload !== "object") {
4245
return [String(payload)];
@@ -85,8 +88,22 @@ function renderObjectSummary(payload: unknown, opts: FormatOpts): string[] {
8588
];
8689
}
8790

88-
function renderMessageList(messages: unknown[], opts: FormatOpts, emptyLabel: string): string[] {
89-
const rows = messages.slice(0, 25).map((m) => {
91+
type ListRenderResult = {
92+
lines: string[];
93+
total: number;
94+
displayed: number;
95+
limit: number;
96+
};
97+
98+
function renderMessageList(
99+
messages: unknown[],
100+
opts: FormatOpts,
101+
emptyLabel: string,
102+
): ListRenderResult {
103+
const limit = opts.displayLimit ?? DEFAULT_MESSAGE_LIST_LIMIT;
104+
const total = messages.length;
105+
const displayed = Math.min(total, limit);
106+
const rows = messages.slice(0, displayed).map((m) => {
90107
const msg = m as Record<string, unknown>;
91108
const id =
92109
(typeof msg.id === "string" && msg.id) ||
@@ -116,24 +133,29 @@ function renderMessageList(messages: unknown[], opts: FormatOpts, emptyLabel: st
116133
});
117134

118135
if (rows.length === 0) {
119-
return [theme.muted(emptyLabel)];
136+
return { lines: [theme.muted(emptyLabel)], total, displayed, limit };
120137
}
121138

122-
return [
123-
renderTable({
124-
width: opts.width,
125-
columns: [
126-
{ key: "Time", header: "Time", minWidth: 14 },
127-
{ key: "Author", header: "Author", minWidth: 10 },
128-
{ key: "Text", header: "Text", flex: true, minWidth: 24 },
129-
{ key: "Id", header: "Id", minWidth: 10 },
130-
],
131-
rows,
132-
}).trimEnd(),
133-
];
139+
return {
140+
lines: [
141+
renderTable({
142+
width: opts.width,
143+
columns: [
144+
{ key: "Time", header: "Time", minWidth: 14 },
145+
{ key: "Author", header: "Author", minWidth: 10 },
146+
{ key: "Text", header: "Text", flex: true, minWidth: 24 },
147+
{ key: "Id", header: "Id", minWidth: 10 },
148+
],
149+
rows,
150+
}).trimEnd(),
151+
],
152+
total,
153+
displayed,
154+
limit,
155+
};
134156
}
135157

136-
function renderMessagesFromPayload(payload: unknown, opts: FormatOpts): string[] | null {
158+
function renderMessagesFromPayload(payload: unknown, opts: FormatOpts): ListRenderResult | null {
137159
if (!payload || typeof payload !== "object") {
138160
return null;
139161
}
@@ -144,7 +166,7 @@ function renderMessagesFromPayload(payload: unknown, opts: FormatOpts): string[]
144166
return renderMessageList(messages, opts, "No messages.");
145167
}
146168

147-
function renderPinsFromPayload(payload: unknown, opts: FormatOpts): string[] | null {
169+
function renderPinsFromPayload(payload: unknown, opts: FormatOpts): ListRenderResult | null {
148170
if (!payload || typeof payload !== "object") {
149171
return null;
150172
}
@@ -155,6 +177,28 @@ function renderPinsFromPayload(payload: unknown, opts: FormatOpts): string[] | n
155177
return renderMessageList(pins, opts, "No pins.");
156178
}
157179

180+
function renderPaginationHint(payload: unknown, rendered: ListRenderResult): string | null {
181+
const { displayed, total, limit } = rendered;
182+
if (total > displayed) {
183+
return `Showing ${displayed} of ${total}; raise --limit to see more`;
184+
}
185+
if (payload && typeof payload === "object") {
186+
const p = payload as Record<string, unknown>;
187+
const hasCursor =
188+
p.hasMore === true ||
189+
hasNonEmptyString(p.nextBatch) ||
190+
hasNonEmptyString(p["@odata.nextLink"]);
191+
if (hasCursor) {
192+
return "More results available beyond this page; use --json for the raw cursor";
193+
}
194+
}
195+
// Heuristic: a full page implies older history may exist beyond it
196+
if (displayed > 0 && displayed === limit) {
197+
return `Reached --limit (${limit}); raise it to fetch older history if any`;
198+
}
199+
return null;
200+
}
201+
158202
function extractDiscordSearchResultsMessages(results: unknown): unknown[] | null {
159203
if (!results || typeof results !== "object") {
160204
return null;
@@ -237,14 +281,21 @@ function renderReactions(payload: unknown, opts: FormatOpts): string[] | null {
237281
];
238282
}
239283

240-
export function formatMessageCliText(result: MessageActionRunResult): string[] {
284+
export type FormatMessageCliTextOpts = {
285+
displayLimit?: number;
286+
};
287+
288+
export function formatMessageCliText(
289+
result: MessageActionRunResult,
290+
textOpts: FormatMessageCliTextOpts = {},
291+
): string[] {
241292
const rich = isRich();
242293
const ok = (text: string) => (rich ? theme.success(text) : text);
243294
const muted = (text: string) => (rich ? theme.muted(text) : text);
244295
const heading = (text: string) => (rich ? theme.heading(text) : text);
245296

246297
const width = getTerminalTableWidth();
247-
const opts: FormatOpts = { width };
298+
const opts: FormatOpts = { width, displayLimit: textOpts.displayLimit };
248299

249300
if (result.handledBy === "dry-run") {
250301
return [muted(`[dry-run] would run ${result.action} via ${result.channel}`)];
@@ -357,31 +408,35 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
357408
return lines;
358409
}
359410

411+
const pushSection = (rendered: ListRenderResult, headingText: string): string[] => {
412+
lines.push(heading(headingText));
413+
lines.push(rendered.lines[0] ?? "");
414+
const hint = renderPaginationHint(payload, rendered);
415+
if (hint) {
416+
lines.push(muted(hint));
417+
}
418+
return lines;
419+
};
420+
360421
if (result.action === "read") {
361-
const messagesTable = renderMessagesFromPayload(payload, opts);
362-
if (messagesTable) {
363-
lines.push(heading("Messages"));
364-
lines.push(messagesTable[0] ?? "");
365-
return lines;
422+
const rendered = renderMessagesFromPayload(payload, opts);
423+
if (rendered) {
424+
return pushSection(rendered, "Messages");
366425
}
367426
}
368427

369428
if (result.action === "list-pins") {
370-
const pinsTable = renderPinsFromPayload(payload, opts);
371-
if (pinsTable) {
372-
lines.push(heading("Pinned messages"));
373-
lines.push(pinsTable[0] ?? "");
374-
return lines;
429+
const rendered = renderPinsFromPayload(payload, opts);
430+
if (rendered) {
431+
return pushSection(rendered, "Pinned messages");
375432
}
376433
}
377434

378435
if (result.action === "search") {
379436
const results = (payload as { results?: unknown }).results;
380437
const list = extractDiscordSearchResultsMessages(results);
381438
if (list) {
382-
lines.push(heading("Search results"));
383-
lines.push(renderMessageList(list, opts, "No results.")[0] ?? "");
384-
return lines;
439+
return pushSection(renderMessageList(list, opts, "No results."), "Search results");
385440
}
386441
}
387442

src/commands/message.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { formatCliCommand } from "../cli/command-format.js";
66
import { getScopedChannelsCommandSecretTargets } from "../cli/command-secret-targets.js";
77
import { resolveMessageSecretScope } from "../cli/message-secret-scope.js";
88
import { createOutboundSendDeps, type CliDeps } from "../cli/outbound-send-deps.js";
9+
import { parsePositiveIntOrUndefined } from "../cli/program/helpers.js";
910
import { withProgress } from "../cli/progress.js";
1011
import { getRuntimeConfig } from "../config/config.js";
1112
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js";
@@ -105,7 +106,8 @@ export async function messageCommand(
105106
}
106107

107108
const { formatMessageCliText } = await import("./message-format.js");
108-
for (const line of formatMessageCliText(result)) {
109+
const displayLimit = parsePositiveIntOrUndefined(opts.limit);
110+
for (const line of formatMessageCliText(result, { displayLimit })) {
109111
runtime.log(line);
110112
}
111113
}

0 commit comments

Comments
 (0)