diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md index 15a0895fc..74cdee03d 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md @@ -18,6 +18,7 @@ List recent agent conversations **Flags:** - `-n, --limit - Number of conversations (1-1000) - (default: "25")` - `-q, --query - Any matching span selects its conversation; totals include all spans in selected projects and time range` +- `-s, --sort - One conversation field; prefix - for descending (default: -conversation.age; ID breaks ties)` - `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -63,6 +64,12 @@ sentry agent-conversation list my-project # Show more, last 24 hours sentry agent-conversation list --limit 50 --period 24h +# Sort by highest error count +sentry agent-conversation list --sort -conversation.errors + +# Sort by lowest total cost +sentry agent-conversation list --sort conversation.totalCost + # Find conversations with errors sentry agent-conversation list -q "conversation.errors:>0" diff --git a/packages/cli/src/commands/agent-conversation/list.ts b/packages/cli/src/commands/agent-conversation/list.ts index 71c31c5f9..b03c4a0ef 100644 --- a/packages/cli/src/commands/agent-conversation/list.ts +++ b/packages/cli/src/commands/agent-conversation/list.ts @@ -17,6 +17,8 @@ import { formatConversationTable } from "../../lib/formatters/conversation.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { + appendQueryHint, + appendSortHint, buildListCommand, LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT, @@ -43,9 +45,31 @@ import { ConversationListItemSchema, } from "../../types/conversation.js"; +const CONVERSATION_SORT_FIELDS = [ + "conversation.age", + "conversation.timeSpan", + "conversation.generationDuration", + "conversation.errors", + "conversation.llmCalls", + "conversation.toolCalls", + "conversation.toolErrors", + "conversation.inputTokens", + "conversation.outputTokens", + "conversation.totalTokens", + "conversation.totalCost", +] as const; + +type ConversationSortField = (typeof CONVERSATION_SORT_FIELDS)[number]; +type ConversationSort = ConversationSortField | `-${ConversationSortField}`; + +const CONVERSATION_SORT_VALUES = CONVERSATION_SORT_FIELDS.flatMap( + (field): ConversationSort[] => [field, `-${field}`], +); + type ListFlags = { readonly limit: number; readonly query?: string; + readonly sort?: ConversationSort; readonly period: TimeRange; readonly json: boolean; readonly cursor?: string; @@ -106,6 +130,9 @@ export const listCommand = buildListCommand("agent-conversation", { brief: "List recent agent conversations", fullDescription: "List recent agent conversations from Sentry projects.\n\n" + + "Sort by one canonical conversation.* field. Prefix the field with - for descending order. " + + "The default is -conversation.age, and conversation ID ascending breaks ties. " + + "conversation.timeSpan measures elapsed time; conversation.generationDuration sums model call duration.\n\n" + "Target patterns:\n" + " sentry agent-conversation list # Auto-detect organization\n" + " sentry agent-conversation list / # All projects in an organization\n" + @@ -133,6 +160,14 @@ export const listCommand = buildListCommand("agent-conversation", { description: "Show more, last 24 hours", command: "sentry agent-conversation list --limit 50 --period 24h", }, + { + description: "Sort by highest error count", + command: "sentry agent-conversation list --sort -conversation.errors", + }, + { + description: "Sort by lowest total cost", + command: "sentry agent-conversation list --sort conversation.totalCost", + }, { description: "Find conversations with errors", command: 'sentry agent-conversation list -q "conversation.errors:>0"', @@ -179,12 +214,20 @@ export const listCommand = buildListCommand("agent-conversation", { "Any matching span selects its conversation; totals include all spans in selected projects and time range", optional: true, }, + sort: { + kind: "enum", + values: CONVERSATION_SORT_VALUES, + brief: + "One conversation field; prefix - for descending (default: -conversation.age; ID breaks ties)", + optional: true, + }, period: LIST_PERIOD_FLAG, }, aliases: { ...PERIOD_ALIASES, n: "limit", q: "query", + s: "sort", }, }, async *func(this: SentryContext, flags: ListFlags, target?: string) { @@ -202,6 +245,7 @@ export const listCommand = buildListCommand("agent-conversation", { const contextKey = buildPaginationContextKey("agent-conversation", scope, { q: flags.query, + sort: flags.sort, period: serializeTimeRange(flags.period), }); const { cursor, direction } = resolveCursor( @@ -220,6 +264,7 @@ export const listCommand = buildListCommand("agent-conversation", { () => listConversations(org, { query: flags.query, + sort: flags.sort, limit: flags.limit, cursor, project: projectId === undefined ? undefined : String(projectId), @@ -241,9 +286,8 @@ export const listCommand = buildListCommand("agent-conversation", { }); const parts: string[] = []; - if (flags.query) { - parts.push(`-q "${flags.query}"`); - } + appendQueryHint(parts, flags.query); + appendSortHint(parts, flags.sort); appendPeriodHint(parts, flags.period, DEFAULT_PERIOD); const flagSuffix = parts.length > 0 ? ` ${parts.join(" ")}` : ""; diff --git a/packages/cli/src/lib/api/conversations.ts b/packages/cli/src/lib/api/conversations.ts index 76f313fdd..e9d6184f2 100644 --- a/packages/cli/src/lib/api/conversations.ts +++ b/packages/cli/src/lib/api/conversations.ts @@ -48,6 +48,7 @@ async function fetchConversationsPage( orgSlug: string, options: { query?: string; + sort?: string; cursor?: string; statsPeriod?: string; start?: string; @@ -74,6 +75,9 @@ async function fetchConversationsPage( if (options.query) { params.query = options.query; } + if (options.sort) { + params.sort = options.sort; + } if (options.project) { params.project = options.project; } @@ -96,13 +100,14 @@ async function fetchConversationsPage( * pages using cursor-based pagination (bounded by {@link MAX_PAGINATION_PAGES}). * * @param orgSlug - Organization slug - * @param options - Query options (query, limit, cursor, statsPeriod, etc.) + * @param options - Query options (query, sort, limit, cursor, statsPeriod, etc.) * @returns Paginated response with conversation items and optional next cursor */ export async function listConversations( orgSlug: string, options: { query?: string; + sort?: string; limit?: number; cursor?: string; statsPeriod?: string; diff --git a/packages/cli/test/commands/agent-conversation/list.test.ts b/packages/cli/test/commands/agent-conversation/list.test.ts index a796e2661..2e791bd67 100644 --- a/packages/cli/test/commands/agent-conversation/list.test.ts +++ b/packages/cli/test/commands/agent-conversation/list.test.ts @@ -5,9 +5,9 @@ * - Organization and project target resolution * - Organization auto-detection * - Yielding CommandOutput with conversation data - * - Query filter passthrough + * - Query and sort passthrough * - Time params passthrough - * - Pagination hints with -q flag preserved + * - Pagination hints with active filters preserved * - Empty result handling * * Uses spyOn mocking to avoid real HTTP calls or database access. @@ -364,6 +364,26 @@ describe("listCommand.func", () => { ); }); + test("passes sort to API", async () => { + listConversationsSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { ...JSON_FLAGS, sort: "-conversation.totalCost" }, + ORG, + ); + + expect(listConversationsSpy).toHaveBeenCalledWith( + ORG, + expect.objectContaining({ sort: "-conversation.totalCost" }), + ); + }); + test("passes time params to API", async () => { listConversationsSpy.mockResolvedValue({ data: [], @@ -400,7 +420,7 @@ describe("listCommand.func", () => { ); }); - test("preserves project and query in pagination hints", async () => { + test("preserves project, query, and sort in pagination hints", async () => { resolveTargetSpy.mockResolvedValue({ org: ORG, project: PROJECT.slug, @@ -415,7 +435,11 @@ describe("listCommand.func", () => { const func = await listCommand.loader(); await func.call( context, - { ...HUMAN_FLAGS, query: "conversation.errors:>0" }, + { + ...HUMAN_FLAGS, + query: "conversation.errors:>0", + sort: "-conversation.totalCost", + }, `${ORG}/${PROJECT.slug}`, ); @@ -425,6 +449,12 @@ describe("listCommand.func", () => { `agent-conversation list ${ORG}/${PROJECT.slug} -c next`, ); expect(output).toContain('-q "conversation.errors:>0"'); + expect(output).toContain('--sort "-conversation.totalCost"'); + expect(resolveCursorSpy).toHaveBeenCalledWith( + undefined, + "agent-conversation-list", + expect.stringContaining("sort:-conversation.totalCost"), + ); }); test("handles empty results (human mode)", async () => { diff --git a/packages/cli/test/lib/api/conversations.test.ts b/packages/cli/test/lib/api/conversations.test.ts index 345869c3d..c284a96e2 100644 --- a/packages/cli/test/lib/api/conversations.test.ts +++ b/packages/cli/test/lib/api/conversations.test.ts @@ -141,6 +141,14 @@ describe("listConversations", () => { expect(decodeURIComponent(getCapturedUrl())).toContain("query=has:errors"); }); + test("passes sort param", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { sort: "-conversation.totalCost" }); + + expect(getCapturedUrl()).toContain("sort=-conversation.totalCost"); + }); + test("passes statsPeriod param", async () => { const { getCapturedUrl } = mockOk([]); @@ -187,6 +195,7 @@ describe("listConversations", () => { const url = getCapturedUrl(); expect(url).not.toContain("query="); + expect(url).not.toContain("sort="); expect(url).not.toContain("statsPeriod="); expect(url).not.toContain("start="); expect(url).not.toContain("end=");