Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/src/fragments/commands/ai-conversations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@



## Examples

### List conversations

```bash
# List last 10 AI conversations
sentry ai-conversations list

# Explicit organization
sentry ai-conversations list my-org

# Show more, last 24 hours
sentry ai-conversations list --limit 50 --period 24h

# Filter conversations
sentry ai-conversations list -q "has:errors"

# Paginate through results
sentry ai-conversations list my-org -c next
```

### View a conversation transcript

```bash
# View full transcript
sentry ai-conversations view my-org conv-123

# JSON output
sentry ai-conversations view my-org conv-123 --json
```
6 changes: 4 additions & 2 deletions script/generate-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,8 @@ function renderNamespaceNode(node: NamespaceNode, indent: string): string {
// Render child namespaces
for (const [name, child] of node.children) {
const childBody = renderNamespaceNode(child, `${indent} `);
parts.push(`${indent}${name}: {\n${childBody}\n${indent}},`);
const key = needsQuoting(name) ? `"${name}"` : name;
parts.push(`${indent}${key}: {\n${childBody}\n${indent}},`);
}

return parts.join("\n");
Expand All @@ -508,7 +509,8 @@ function renderNamespaceTypeNode(node: NamespaceNode, indent: string): string {
// Render child namespaces as nested object types
for (const [name, child] of node.children) {
const childBody = renderNamespaceTypeNode(child, `${indent} `);
parts.push(`${indent}${name}: {\n${childBody}\n${indent}};`);
const key = needsQuoting(name) ? `"${name}"` : name;
parts.push(`${indent}${key}: {\n${childBody}\n${indent}};`);
}

return parts.join("\n");
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
UnexpectedPositionalError,
UnsatisfiedPositionalError,
} from "@stricli/core";
import { aiConversationsRoute } from "./commands/ai-conversations/index.js";
import { apiCommand } from "./commands/api.js";
import { authRoute } from "./commands/auth/index.js";
import { whoamiCommand } from "./commands/auth/whoami.js";
Expand Down Expand Up @@ -83,6 +84,7 @@ const PLURAL_TO_SINGULAR: Record<string, string> = {
/** Top-level route map containing all CLI commands */
export const routes = buildRouteMap({
routes: {
"ai-conversations": aiConversationsRoute,
help: helpCommand,
auth: authRoute,
cli: cliRoute,
Expand Down
26 changes: 26 additions & 0 deletions src/commands/ai-conversations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* sentry ai-conversations
*
* List and view AI conversations from Sentry Explore.
*/

import { buildRouteMap } from "../../lib/route-map.js";
import { listCommand } from "./list.js";
import { viewCommand } from "./view.js";

export const aiConversationsRoute = buildRouteMap({
routes: {
list: listCommand,
view: viewCommand,
},
defaultCommand: "list",
docs: {
brief: "List and view AI conversations",
fullDescription:
"List and view AI conversations from Sentry Explore.\n\n" +
"Commands:\n" +
" list List recent AI conversations\n" +
" view View a conversation transcript\n",
hideRoute: {},
},
});
209 changes: 209 additions & 0 deletions src/commands/ai-conversations/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* sentry ai-conversations list
*
* List recent AI conversations from Sentry projects.
*/

import type { SentryContext } from "../../context.js";
import { listConversations } from "../../lib/api/ai-conversations.js";
import { validateLimit } from "../../lib/arg-parsing.js";
import {
advancePaginationState,
buildPaginationContextKey,
hasPreviousPage,
resolveCursor,
} from "../../lib/db/pagination.js";
import { formatConversationTable } from "../../lib/formatters/ai-conversations.js";
import { filterFields } from "../../lib/formatters/json.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import {
buildListCommand,
LIST_DEFAULT_LIMIT,
LIST_MAX_LIMIT,
LIST_MIN_LIMIT,
LIST_PERIOD_FLAG,
PERIOD_ALIASES,
paginationHint,
} from "../../lib/list-command.js";
import { withProgress } from "../../lib/polling.js";
import { resolveOrg } from "../../lib/resolve-target.js";
import {
appendPeriodHint,
serializeTimeRange,
type TimeRange,
timeRangeToApiParams,
} from "../../lib/time-range.js";
import {
type ConversationListItem,
ConversationListItemSchema,
} from "../../types/ai-conversations.js";

type ListFlags = {
readonly limit: number;
readonly query?: string;
readonly period: TimeRange;
readonly json: boolean;
readonly cursor?: string;
readonly fresh: boolean;
readonly fields?: string[];
};

type ConversationListResult = {
conversations: ConversationListItem[];
hasMore: boolean;
hasPrev?: boolean;
nextCursor?: string;
org: string;
};

const COMMAND_NAME = "ai-conversations list";
const PAGINATION_KEY = "ai-conversations-list";
const DEFAULT_PERIOD = "14d";

function parseLimit(value: string): number {
return validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT);
}

function formatListHuman(result: ConversationListResult): string {
const { conversations, hasMore, org } = result;
if (conversations.length === 0) {
return hasMore
? "No conversations on this page."
: "No AI conversations found.";
}
return `AI conversations in ${org}:\n\n${formatConversationTable(conversations)}`;
}

function jsonTransform(
result: ConversationListResult,
fields?: string[]
): unknown {
const items =
fields && fields.length > 0
? result.conversations.map((c) => filterFields(c, fields))
: result.conversations;

const envelope: Record<string, unknown> = {
data: items,
hasMore: result.hasMore,
hasPrev: !!result.hasPrev,
};
if (result.nextCursor) {
envelope.nextCursor = result.nextCursor;
}
return envelope;
}

export const listCommand = buildListCommand("ai-conversations", {
docs: {
brief: "List recent AI conversations",
fullDescription:
"List recent AI conversations from a Sentry organization.\n\n" +
"Examples:\n" +
" sentry ai-conversations list # List last 10 conversations\n" +
" sentry ai-conversations list my-org # Explicit org\n" +
" sentry ai-conversations list --limit 50 # Show more\n" +
" sentry ai-conversations list --period 24h # Last 24 hours\n" +
' sentry ai-conversations list -q "has:errors" # Filter\n',
},
output: {
human: formatListHuman,
jsonTransform,
schema: ConversationListItemSchema,
},
parameters: {
positional: {
kind: "tuple",
parameters: [
{
placeholder: "org",
brief: "Organization slug",
parse: String,
optional: true,
},
],
},
flags: {
limit: {
kind: "parsed",
parse: parseLimit,
brief: `Number of conversations (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`,
default: String(LIST_DEFAULT_LIMIT),
},
query: {
kind: "parsed",
parse: String,
brief: "Search query",
optional: true,
},
period: LIST_PERIOD_FLAG,
},
aliases: {
...PERIOD_ALIASES,
n: "limit",
q: "query",
},
},
async *func(this: SentryContext, flags: ListFlags, target?: string) {
const { cwd } = this;

const resolved = await resolveOrg({ org: target, cwd });
if (!resolved) {
throw new Error(
`Could not determine organization. Pass it explicitly: sentry ${COMMAND_NAME} <org>`
);
}
const org = resolved.org;

const contextKey = buildPaginationContextKey("ai-conversations", org, {
q: flags.query,
period: serializeTimeRange(flags.period),
});
const { cursor, direction } = resolveCursor(
flags.cursor,
PAGINATION_KEY,
contextKey
);

const timeParams = timeRangeToApiParams(flags.period);

const { data: conversations, nextCursor } = await withProgress(
{
message: `Fetching conversations (up to ${flags.limit})...`,
json: flags.json,
},
() =>
listConversations(org, {
query: flags.query,
limit: flags.limit,
cursor,
...timeParams,
})
);

advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor);
const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey);
const hasMore = !!nextCursor;

yield new CommandOutput<ConversationListResult>({
conversations,
hasMore,
hasPrev,
nextCursor,
org,
});

const parts: string[] = [];
appendPeriodHint(parts, flags.period, DEFAULT_PERIOD);
const flagSuffix = parts.length > 0 ? ` ${parts.join(" ")}` : "";

return {
hint: paginationHint({
hasMore,
hasPrev: !!hasPrev,
nextHint: `sentry ai-conversations list ${org} -c next${flagSuffix}`,
prevHint: `sentry ai-conversations list ${org} -c prev${flagSuffix}`,
}),
};
},

Check warning on line 208 in src/commands/ai-conversations/list.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

Pagination hint drops `--query` filter, causing next/prev to show unfiltered results

When `--query` is active, the pagination hint omits `-q "..."` so following the hint silently shows a different (unfiltered) result set.
});
80 changes: 80 additions & 0 deletions src/commands/ai-conversations/view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* sentry ai-conversations view
*
* View the transcript of a specific AI conversation.
*/

import type { SentryContext } from "../../context.js";
import { getConversationSpans } from "../../lib/api/ai-conversations.js";
import { buildCommand } from "../../lib/command.js";
import {
buildTranscriptResult,
formatTranscriptResult,
type TranscriptResult,
} from "../../lib/formatters/ai-conversations.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import {
applyFreshFlag,
FRESH_ALIASES,
FRESH_FLAG,
} from "../../lib/list-command.js";
import { withProgress } from "../../lib/polling.js";

type ViewFlags = {
readonly json: boolean;
readonly fresh: boolean;
};

export const viewCommand = buildCommand({
docs: {
brief: "View an AI conversation transcript",
fullDescription:
"View the full transcript of an AI conversation.\n\n" +
"Examples:\n" +
" sentry ai-conversations view my-org conv-123\n" +
" sentry ai-conversations view my-org conv-123 --json\n",
},
output: {
human: formatTranscriptResult,
},
parameters: {
positional: {
kind: "tuple",
parameters: [
{
placeholder: "org",
brief: "Organization slug",
parse: String,
},
{
placeholder: "conversation-id",
brief: "AI conversation ID",
parse: String,
},
],
},

Check warning on line 55 in src/commands/ai-conversations/view.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

`org` positional is required and never resolved via `resolveOrg`, silently ignoring `SENTRY_ORG` and config

The `org` positional is required with no `optional: true` and the `func` never calls `resolveOrg`, so `SENTRY_ORG` env vars, `.sentryclirc` config, and DSN auto-detection are all silently ignored — the command always requires an explicit org argument, unlike every other command in the same group.
flags: {
fresh: FRESH_FLAG,
},
aliases: FRESH_ALIASES,
},
async *func(
this: SentryContext,
flags: ViewFlags,
org: string,
conversationId: string
) {
applyFreshFlag(flags);

const spans = await withProgress(
{
message: "Fetching conversation spans...",
json: flags.json,
},
() => getConversationSpans(org, conversationId)
);

const result = buildTranscriptResult(conversationId, org, spans);
yield new CommandOutput<TranscriptResult>(result);
},
});
Loading
Loading