From 6d85d92c2bb6d3ec3dab9fd993d28e62db5deb12 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 08:06:06 -0400 Subject: [PATCH 01/17] feat: add `sentry ai-conversations` command group Add `sentry ai-conversations list` and `sentry ai-conversations view` commands for browsing AI conversations from Sentry Explore. - list: paginated conversation list with tokens, tool calls, errors - view: full transcript with turns, user/assistant messages, tool calls - Transcript parsing ported from sentry-mcp get-ai-conversation-details Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.ts | 3 + src/commands/ai-conversations/index.ts | 26 ++ src/commands/ai-conversations/list.ts | 208 +++++++++++++++ src/commands/ai-conversations/view.ts | 162 ++++++++++++ src/lib/api-client.ts | 5 + src/lib/api/ai-conversations.ts | 91 +++++++ src/lib/formatters/ai-conversations.ts | 350 +++++++++++++++++++++++++ src/types/ai-conversations.ts | 75 ++++++ 8 files changed, 920 insertions(+) create mode 100644 src/commands/ai-conversations/index.ts create mode 100644 src/commands/ai-conversations/list.ts create mode 100644 src/commands/ai-conversations/view.ts create mode 100644 src/lib/api/ai-conversations.ts create mode 100644 src/lib/formatters/ai-conversations.ts create mode 100644 src/types/ai-conversations.ts diff --git a/src/app.ts b/src/app.ts index 426b4fdc7..438f9a046 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,8 @@ import { UnexpectedPositionalError, UnsatisfiedPositionalError, } from "@stricli/core"; +import { aiConversationsRoute } from "./commands/ai-conversations/index.js"; +import { listCommand as aiConversationsListCommand } from "./commands/ai-conversations/list.js"; import { apiCommand } from "./commands/api.js"; import { authRoute } from "./commands/auth/index.js"; import { whoamiCommand } from "./commands/auth/whoami.js"; @@ -83,6 +85,7 @@ const PLURAL_TO_SINGULAR: Record = { /** Top-level route map containing all CLI commands */ export const routes = buildRouteMap({ routes: { + "ai-conversations": aiConversationsRoute, help: helpCommand, auth: authRoute, cli: cliRoute, diff --git a/src/commands/ai-conversations/index.ts b/src/commands/ai-conversations/index.ts new file mode 100644 index 000000000..ed021a969 --- /dev/null +++ b/src/commands/ai-conversations/index.ts @@ -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: {}, + }, +}); diff --git a/src/commands/ai-conversations/list.ts b/src/commands/ai-conversations/list.ts new file mode 100644 index 000000000..b9d237f51 --- /dev/null +++ b/src/commands/ai-conversations/list.ts @@ -0,0 +1,208 @@ +/** + * 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 = { + 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: 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} `, + ); + } + 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, + statsPeriod: timeParams.statsPeriod, + }), + ); + + advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); + const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + const hasMore = !!nextCursor; + + yield new CommandOutput({ + 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}`, + }), + }; + }, +}); diff --git a/src/commands/ai-conversations/view.ts b/src/commands/ai-conversations/view.ts new file mode 100644 index 000000000..d1bce17a1 --- /dev/null +++ b/src/commands/ai-conversations/view.ts @@ -0,0 +1,162 @@ +/** + * 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, + formatTranscript, + 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"; +import { resolveOrg } from "../../lib/resolve-target.js"; + +type ViewFlags = { + readonly json: boolean; + readonly fresh: boolean; +}; + +function formatViewHuman(result: TranscriptResult): string { + if (result.spanCount === 0) { + return `No spans found for conversation ${result.conversationId} in the last 30 days.`; + } + + const lines: string[] = []; + lines.push(`AI Conversation: ${result.conversationId}`); + lines.push(""); + lines.push(` Org: ${result.org}`); + lines.push(` Projects: ${result.projects.join(", ") || "—"}`); + lines.push( + ` Started: ${new Date(result.startTimestamp * 1000).toISOString()}`, + ); + lines.push( + ` Ended: ${new Date(result.endTimestamp * 1000).toISOString()}`, + ); + lines.push(` Turns: ${result.turns.length}`); + lines.push(` Spans: ${result.spanCount}`); + lines.push(` Tokens: ${result.totalTokens}`); + lines.push(""); + + for (const turn of result.turns) { + const meta = [ + turn.model, + turn.agentName, + turn.totalTokens > 0 ? `${turn.totalTokens} tokens` : null, + turn.durationMs < 1000 + ? `${turn.durationMs}ms` + : `${(turn.durationMs / 1000).toFixed(1)}s`, + ] + .filter(Boolean) + .join(" | "); + + lines.push( + `── Turn ${turn.turn} — ${new Date(turn.started * 1000).toISOString()}`, + ); + if (meta) lines.push(` ${meta}`); + lines.push(""); + + if (turn.userContent) { + lines.push(" [user]"); + const content = + turn.userContent.length > 600 + ? `${turn.userContent.slice(0, 599)}…` + : turn.userContent; + for (const line of content.split("\n")) { + lines.push(` ${line}`); + } + lines.push(""); + } + + if (turn.assistantContent) { + lines.push(" [assistant]"); + const content = + turn.assistantContent.length > 600 + ? `${turn.assistantContent.slice(0, 599)}…` + : turn.assistantContent; + for (const line of content.split("\n")) { + lines.push(` ${line}`); + } + lines.push(""); + } + + if (turn.toolCalls.length > 0) { + lines.push(" [tools]"); + for (const tc of turn.toolCalls) { + const dur = + tc.durationMs < 1000 + ? `${tc.durationMs}ms` + : `${(tc.durationMs / 1000).toFixed(1)}s`; + const status = + tc.status && tc.status !== "ok" ? ` (${tc.status})` : ""; + lines.push(` • ${tc.name} — ${dur}${status}`); + } + lines.push(""); + } + } + + return lines.join("\n"); +} + +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: formatViewHuman, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org", + brief: "Organization slug", + parse: String, + }, + { + placeholder: "conversation-id", + brief: "AI conversation ID", + parse: String, + }, + ], + }, + 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(result); + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 02d37c76e..8629e2490 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -16,10 +16,15 @@ * - traces: trace details and transactions * - logs: log listing, detailed fetch, trace-logs * - seer: Seer AI root cause analysis and planning + * - ai-conversations: AI conversation listing and detail * - trials: product trial management * - users: current user info */ +export { + getConversationSpans, + listConversations, +} from "./api/ai-conversations.js"; export { createDashboard, getDashboard, diff --git a/src/lib/api/ai-conversations.ts b/src/lib/api/ai-conversations.ts new file mode 100644 index 000000000..d85e7bb90 --- /dev/null +++ b/src/lib/api/ai-conversations.ts @@ -0,0 +1,91 @@ +/** + * AI Conversations API functions + * + * Functions for listing and retrieving AI conversation data + * from the Sentry Explore conversations endpoints. + */ + +import { + AIConversationSpanSchema, + ConversationListItemSchema, + type AIConversationSpan, + type ConversationListItem, +} from "../../types/ai-conversations.js"; + +import { resolveOrgRegion } from "../region.js"; + +import { + apiRequestToRegion, + type PaginatedResponse, + parseLinkHeader, +} from "./infrastructure.js"; + +export async function listConversations( + orgSlug: string, + options: { + query?: string; + limit?: number; + cursor?: string; + statsPeriod?: string; + project?: string; + } = {}, +): Promise> { + const regionUrl = await resolveOrgRegion(orgSlug); + + const params: Record = { + per_page: String(options.limit ?? 10), + }; + if (options.statsPeriod) params.statsPeriod = options.statsPeriod; + if (options.cursor) params.cursor = options.cursor; + if (options.query) params.query = options.query; + if (options.project) params.project = options.project; + + const { data, headers } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/ai-conversations/`, + { params }, + ); + + const items = data.map((item) => ConversationListItemSchema.parse(item)); + const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); + + return { data: items, nextCursor }; +} + +export async function getConversationSpans( + orgSlug: string, + conversationId: string, + options: { + statsPeriod?: string; + project?: string; + perPage?: number; + } = {}, +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + + const params: Record = { + per_page: String(options.perPage ?? 1000), + statsPeriod: options.statsPeriod ?? "30d", + }; + if (options.project) params.project = options.project; + + const spans: AIConversationSpan[] = []; + let cursor: string | undefined; + + for (let page = 0; page < 10; page++) { + if (cursor) params.cursor = cursor; + + const { data, headers } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/ai-conversations/${encodeURIComponent(conversationId)}/`, + { params }, + ); + + spans.push(...data.map((s) => AIConversationSpanSchema.parse(s))); + const parsed = parseLinkHeader(headers.get("link") ?? null); + cursor = parsed.nextCursor; + if (!cursor) break; + } + + return spans; +} diff --git a/src/lib/formatters/ai-conversations.ts b/src/lib/formatters/ai-conversations.ts new file mode 100644 index 000000000..4fa8bee0d --- /dev/null +++ b/src/lib/formatters/ai-conversations.ts @@ -0,0 +1,350 @@ +/** + * AI Conversations formatters + * + * Human-readable formatting for conversation list and detail views. + * Transcript parsing logic ported from sentry-mcp get-ai-conversation-details. + */ + +import type { + AIConversationSpan, + ConversationListItem, +} from "../../types/ai-conversations.js"; + +// --------------------------------------------------------------------------- +// List formatter +// --------------------------------------------------------------------------- + +function truncate(value: string, max = 60): string { + if (value.length <= max) return value; + return `${value.slice(0, max - 1)}…`; +} + +function formatTimestamp(ms: number): string { + if (ms === 0) return "—"; + return new Date(ms).toLocaleString(); +} + +export function formatConversationTable(items: ConversationListItem[]): string { + const rows = items.map((c) => { + const input = c.firstInput ? truncate(c.firstInput) : "—"; + const user = c.user?.email ?? c.user?.username ?? "—"; + const time = formatTimestamp(c.startTimestamp); + return ` ${truncate(c.conversationId, 40)} ${time} ${String(c.totalTokens).padStart(8)} ${String(c.toolCalls).padStart(5)} ${String(c.errors).padStart(4)} ${user} ${input}`; + }); + + const header = + " ID Started Tokens Tools Errs User First Input"; + return [header, ...rows].join("\n"); +} + +// --------------------------------------------------------------------------- +// Transcript parsing (ported from sentry-mcp get-ai-conversation-details) +// --------------------------------------------------------------------------- + +type ToolCall = { + name: string; + spanId: string; + timestamp: number; + durationMs: number; + status?: string | null; +}; + +type ConversationTurn = { + turn: number; + spanId: string; + traceId: string; + started: number; + ended: number; + durationMs: number; + userContent?: string | null; + assistantContent?: string | null; + toolCalls: ToolCall[]; + model?: string | null; + agentName?: string | null; + totalTokens: number; + status?: string | null; +}; + +function numeric(value: string | number | null | undefined): number { + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function getOperationType(span: AIConversationSpan): string | undefined { + const explicit = span["gen_ai.operation.type"]; + if (explicit) return explicit; + + const spanName = span["span.name"]; + if (!spanName?.startsWith("gen_ai.")) return undefined; + if (spanName === "gen_ai.execute_tool") return "tool"; + if ( + spanName === "gen_ai.invoke_agent" || + spanName === "gen_ai.create_agent" + ) + return "agent"; + if (spanName === "gen_ai.handoff") return "handoff"; + return "ai_client"; +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function stringifyContent(value: unknown): string | null { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + const parts = value + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object") { + const r = part as Record; + if (typeof r.text === "string") return r.text; + if (typeof r.content === "string") return r.content; + } + return null; + }) + .filter((p): p is string => Boolean(p)); + return parts.length > 0 ? parts.join("\n") : null; + } + if (value && typeof value === "object") { + const r = value as Record; + if (typeof r.text === "string") return r.text; + if (typeof r.content === "string") return r.content; + if (typeof r.message === "string") return r.message; + } + return value == null ? null : JSON.stringify(value); +} + +function collectMessages( + value: unknown, +): { role?: string; content: string }[] { + const source = + value && + typeof value === "object" && + !Array.isArray(value) && + Array.isArray((value as Record).messages) + ? (value as Record).messages + : value; + + if (!Array.isArray(source)) { + const content = stringifyContent(source); + return content ? [{ content }] : []; + } + + return source + .map((msg) => { + if (typeof msg === "string") return { content: msg }; + if (!msg || typeof msg !== "object") return null; + const r = msg as Record; + const content = stringifyContent(r.content ?? r.text ?? r); + if (!content) return null; + return { + role: typeof r.role === "string" ? r.role : undefined, + content, + }; + }) + .filter((m): m is { role?: string; content: string } => Boolean(m)); +} + +function extractUserContent(span: AIConversationSpan): string | null { + const raw = + span["gen_ai.input.messages"] ?? span["gen_ai.request.messages"] ?? null; + if (!raw) return null; + if (raw === "[Filtered]") return raw; + const messages = collectMessages(parseJson(raw)); + const userMsg = messages.findLast((m) => m.role === "user"); + return userMsg?.content ?? messages.at(-1)?.content ?? null; +} + +function extractAssistantContent(span: AIConversationSpan): string | null { + const outputMessages = span["gen_ai.output.messages"]; + if (outputMessages) { + if (outputMessages === "[Filtered]") return outputMessages; + const messages = collectMessages(parseJson(outputMessages)); + const assistantMsg = messages.findLast((m) => m.role === "assistant"); + const content = assistantMsg?.content ?? messages.at(-1)?.content; + if (content) return content; + } + return ( + span["gen_ai.response.text"] ?? span["gen_ai.response.object"] ?? null + ); +} + +export function extractTurns(spans: AIConversationSpan[]): ConversationTurn[] { + const sorted = [...spans].sort( + (a, b) => a["precise.start_ts"] - b["precise.start_ts"], + ); + const aiClientSpans = sorted.filter( + (s) => getOperationType(s) === "ai_client", + ); + const toolSpans = sorted.filter((s) => getOperationType(s) === "tool"); + + return aiClientSpans.map((span, index) => { + const nextTs = + index < aiClientSpans.length - 1 + ? aiClientSpans[index + 1]!["precise.start_ts"] + : Number.POSITIVE_INFINITY; + const toolCalls = toolSpans + .filter((ts) => { + const t = ts["precise.start_ts"]; + return t >= span["precise.start_ts"] && t < nextTs; + }) + .map((ts) => ({ + name: ts["gen_ai.tool.name"] ?? "unknown", + spanId: ts.span_id, + timestamp: ts["precise.start_ts"], + durationMs: Math.round( + (ts["precise.finish_ts"] - ts["precise.start_ts"]) * 1000, + ), + status: ts["span.status"], + })); + + return { + turn: index + 1, + spanId: span.span_id, + traceId: span.trace, + started: span["precise.start_ts"], + ended: span["precise.finish_ts"], + durationMs: Math.round( + (span["precise.finish_ts"] - span["precise.start_ts"]) * 1000, + ), + userContent: extractUserContent(span), + assistantContent: extractAssistantContent(span), + toolCalls, + model: span["gen_ai.response.model"] ?? span["gen_ai.request.model"], + agentName: span["gen_ai.agent.name"], + totalTokens: numeric(span["gen_ai.usage.total_tokens"]), + status: span["span.status"], + }; + }); +} + +// --------------------------------------------------------------------------- +// Human formatter for transcript view +// --------------------------------------------------------------------------- + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(ms % 1000 === 0 ? 0 : 1)}s`; +} + +function formatEpoch(ts: number): string { + return new Date(ts * 1000).toISOString(); +} + +function formatTurnHuman(turn: ConversationTurn): string { + const meta = [ + turn.model, + turn.agentName, + turn.totalTokens > 0 ? `${turn.totalTokens} tokens` : null, + formatDuration(turn.durationMs), + ] + .filter(Boolean) + .join(" | "); + + const lines: string[] = []; + lines.push(`── Turn ${turn.turn} — ${formatEpoch(turn.started)}`); + if (meta) lines.push(` ${meta}`); + lines.push(""); + + if (turn.userContent) { + lines.push(" [user]"); + for (const line of truncate(turn.userContent, 600).split("\n")) { + lines.push(` ${line}`); + } + lines.push(""); + } + + if (turn.assistantContent) { + lines.push(" [assistant]"); + for (const line of truncate(turn.assistantContent, 600).split("\n")) { + lines.push(` ${line}`); + } + lines.push(""); + } + + if (turn.toolCalls.length > 0) { + lines.push(" [tools]"); + for (const tc of turn.toolCalls) { + const status = tc.status && tc.status !== "ok" ? ` (${tc.status})` : ""; + lines.push(` • ${tc.name} — ${formatDuration(tc.durationMs)}${status}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +export function formatTranscript( + conversationId: string, + org: string, + spans: AIConversationSpan[], +): string { + if (spans.length === 0) { + return `No spans found for conversation ${conversationId} in the last 30 days.`; + } + + const turns = extractTurns(spans); + const totalTokens = spans.reduce( + (sum, s) => sum + numeric(s["gen_ai.usage.total_tokens"]), + 0, + ); + const projects = [...new Set(spans.map((s) => s.project))].sort(); + const start = Math.min(...spans.map((s) => s["precise.start_ts"])); + const end = Math.max(...spans.map((s) => s["precise.finish_ts"])); + + const header = [ + `AI Conversation: ${conversationId}`, + "", + ` Org: ${org}`, + ` Projects: ${projects.join(", ") || "—"}`, + ` Started: ${formatEpoch(start)}`, + ` Ended: ${formatEpoch(end)}`, + ` Turns: ${turns.length}`, + ` Spans: ${spans.length}`, + ` Tokens: ${totalTokens}`, + "", + ]; + + return [...header, ...turns.map(formatTurnHuman)].join("\n"); +} + +export type TranscriptResult = { + conversationId: string; + org: string; + turns: ConversationTurn[]; + totalTokens: number; + spanCount: number; + projects: string[]; + startTimestamp: number; + endTimestamp: number; +}; + +export function buildTranscriptResult( + conversationId: string, + org: string, + spans: AIConversationSpan[], +): TranscriptResult { + const turns = extractTurns(spans); + return { + conversationId, + org, + turns, + totalTokens: spans.reduce( + (sum, s) => sum + numeric(s["gen_ai.usage.total_tokens"]), + 0, + ), + spanCount: spans.length, + projects: [...new Set(spans.map((s) => s.project))].sort(), + startTimestamp: Math.min(...spans.map((s) => s["precise.start_ts"])), + endTimestamp: Math.max(...spans.map((s) => s["precise.finish_ts"])), + }; +} diff --git a/src/types/ai-conversations.ts b/src/types/ai-conversations.ts new file mode 100644 index 000000000..d2f2738ba --- /dev/null +++ b/src/types/ai-conversations.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +export const ConversationListItemSchema = z.object({ + conversationId: z.string(), + flow: z.array(z.string()), + errors: z.number(), + llmCalls: z.number(), + toolCalls: z.number(), + totalTokens: z.number(), + totalCost: z.number(), + startTimestamp: z.number(), + endTimestamp: z.number(), + traceCount: z.number(), + traceIds: z.array(z.string()), + firstInput: z.string().nullable(), + lastOutput: z.string().nullable(), + user: z + .object({ + id: z.string().nullable(), + email: z.string().nullable(), + username: z.string().nullable(), + ip_address: z.string().nullable(), + }) + .nullable() + .optional(), + toolNames: z.array(z.string()), + toolErrors: z.number(), +}); + +export type ConversationListItem = z.infer; + +const NullableString = z.string().nullable().optional(); +const NullableStringOrNumber = z + .union([z.string(), z.number()]) + .nullable() + .optional(); + +export const AIConversationSpanSchema = z + .object({ + "gen_ai.conversation.id": z.string(), + span_id: z.string(), + trace: z.string(), + parent_span: z.string().nullable().optional(), + "precise.start_ts": z.number(), + "precise.finish_ts": z.number(), + project: z.string(), + "project.id": z.union([z.string(), z.number()]), + "span.name": NullableString, + "span.status": NullableString, + "span.op": NullableString, + "span.description": NullableString, + "span.duration": z.number().optional(), + transaction: NullableString, + is_transaction: z.boolean().optional(), + "gen_ai.cost.total_tokens": NullableStringOrNumber, + "gen_ai.operation.type": NullableString, + "gen_ai.input.messages": NullableString, + "gen_ai.output.messages": NullableString, + "gen_ai.system_instructions": NullableString, + "gen_ai.tool.definitions": NullableString, + "gen_ai.request.messages": NullableString, + "gen_ai.response.object": NullableString, + "gen_ai.response.text": NullableString, + "gen_ai.tool.name": NullableString, + "gen_ai.tool.call.arguments": NullableString, + "gen_ai.tool.input": NullableString, + "gen_ai.usage.total_tokens": NullableStringOrNumber, + "gen_ai.request.model": NullableString, + "gen_ai.response.model": NullableString, + "gen_ai.agent.name": NullableString, + "user.email": NullableString, + }) + .passthrough(); + +export type AIConversationSpan = z.infer; From 3021223da71b4c1d25c069d764b59680213306ce Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 08:26:24 -0400 Subject: [PATCH 02/17] fix: address Warden findings and consolidate view formatter - Forward start/end params for absolute date ranges (GGA-VPD) - Guard against Infinity timestamps on empty spans (5RV-D34) - Consolidate view formatter into formatTranscriptResult - Fix tsc strict null check on span index lookup Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/ai-conversations/list.ts | 25 ++-- src/commands/ai-conversations/view.ts | 90 +----------- src/lib/api/ai-conversations.ts | 48 +++++-- src/lib/formatters/ai-conversations.ts | 190 +++++++++++++++---------- 4 files changed, 165 insertions(+), 188 deletions(-) diff --git a/src/commands/ai-conversations/list.ts b/src/commands/ai-conversations/list.ts index b9d237f51..5e845f230 100644 --- a/src/commands/ai-conversations/list.ts +++ b/src/commands/ai-conversations/list.ts @@ -76,7 +76,7 @@ function formatListHuman(result: ConversationListResult): string { function jsonTransform( result: ConversationListResult, - fields?: string[], + fields?: string[] ): unknown { const items = fields && fields.length > 0 @@ -88,7 +88,9 @@ function jsonTransform( hasMore: result.hasMore, hasPrev: !!result.hasPrev, }; - if (result.nextCursor) envelope.nextCursor = result.nextCursor; + if (result.nextCursor) { + envelope.nextCursor = result.nextCursor; + } return envelope; } @@ -106,7 +108,7 @@ export const listCommand = buildListCommand("ai-conversations", { }, output: { human: formatListHuman, - jsonTransform: jsonTransform, + jsonTransform, schema: ConversationListItemSchema, }, parameters: { @@ -148,20 +150,19 @@ export const listCommand = buildListCommand("ai-conversations", { const resolved = await resolveOrg({ org: target, cwd }); if (!resolved) { throw new Error( - `Could not determine organization. Pass it explicitly: sentry ${COMMAND_NAME} `, + `Could not determine organization. Pass it explicitly: sentry ${COMMAND_NAME} ` ); } const org = resolved.org; - const contextKey = buildPaginationContextKey( - "ai-conversations", - org, - { q: flags.query, period: serializeTimeRange(flags.period) }, - ); + const contextKey = buildPaginationContextKey("ai-conversations", org, { + q: flags.query, + period: serializeTimeRange(flags.period), + }); const { cursor, direction } = resolveCursor( flags.cursor, PAGINATION_KEY, - contextKey, + contextKey ); const timeParams = timeRangeToApiParams(flags.period); @@ -176,8 +177,8 @@ export const listCommand = buildListCommand("ai-conversations", { query: flags.query, limit: flags.limit, cursor, - statsPeriod: timeParams.statsPeriod, - }), + ...timeParams, + }) ); advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); diff --git a/src/commands/ai-conversations/view.ts b/src/commands/ai-conversations/view.ts index d1bce17a1..1ec4443c8 100644 --- a/src/commands/ai-conversations/view.ts +++ b/src/commands/ai-conversations/view.ts @@ -9,7 +9,7 @@ import { getConversationSpans } from "../../lib/api/ai-conversations.js"; import { buildCommand } from "../../lib/command.js"; import { buildTranscriptResult, - formatTranscript, + formatTranscriptResult, type TranscriptResult, } from "../../lib/formatters/ai-conversations.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -19,94 +19,12 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; -import { resolveOrg } from "../../lib/resolve-target.js"; type ViewFlags = { readonly json: boolean; readonly fresh: boolean; }; -function formatViewHuman(result: TranscriptResult): string { - if (result.spanCount === 0) { - return `No spans found for conversation ${result.conversationId} in the last 30 days.`; - } - - const lines: string[] = []; - lines.push(`AI Conversation: ${result.conversationId}`); - lines.push(""); - lines.push(` Org: ${result.org}`); - lines.push(` Projects: ${result.projects.join(", ") || "—"}`); - lines.push( - ` Started: ${new Date(result.startTimestamp * 1000).toISOString()}`, - ); - lines.push( - ` Ended: ${new Date(result.endTimestamp * 1000).toISOString()}`, - ); - lines.push(` Turns: ${result.turns.length}`); - lines.push(` Spans: ${result.spanCount}`); - lines.push(` Tokens: ${result.totalTokens}`); - lines.push(""); - - for (const turn of result.turns) { - const meta = [ - turn.model, - turn.agentName, - turn.totalTokens > 0 ? `${turn.totalTokens} tokens` : null, - turn.durationMs < 1000 - ? `${turn.durationMs}ms` - : `${(turn.durationMs / 1000).toFixed(1)}s`, - ] - .filter(Boolean) - .join(" | "); - - lines.push( - `── Turn ${turn.turn} — ${new Date(turn.started * 1000).toISOString()}`, - ); - if (meta) lines.push(` ${meta}`); - lines.push(""); - - if (turn.userContent) { - lines.push(" [user]"); - const content = - turn.userContent.length > 600 - ? `${turn.userContent.slice(0, 599)}…` - : turn.userContent; - for (const line of content.split("\n")) { - lines.push(` ${line}`); - } - lines.push(""); - } - - if (turn.assistantContent) { - lines.push(" [assistant]"); - const content = - turn.assistantContent.length > 600 - ? `${turn.assistantContent.slice(0, 599)}…` - : turn.assistantContent; - for (const line of content.split("\n")) { - lines.push(` ${line}`); - } - lines.push(""); - } - - if (turn.toolCalls.length > 0) { - lines.push(" [tools]"); - for (const tc of turn.toolCalls) { - const dur = - tc.durationMs < 1000 - ? `${tc.durationMs}ms` - : `${(tc.durationMs / 1000).toFixed(1)}s`; - const status = - tc.status && tc.status !== "ok" ? ` (${tc.status})` : ""; - lines.push(` • ${tc.name} — ${dur}${status}`); - } - lines.push(""); - } - } - - return lines.join("\n"); -} - export const viewCommand = buildCommand({ docs: { brief: "View an AI conversation transcript", @@ -117,7 +35,7 @@ export const viewCommand = buildCommand({ " sentry ai-conversations view my-org conv-123 --json\n", }, output: { - human: formatViewHuman, + human: formatTranscriptResult, }, parameters: { positional: { @@ -144,7 +62,7 @@ export const viewCommand = buildCommand({ this: SentryContext, flags: ViewFlags, org: string, - conversationId: string, + conversationId: string ) { applyFreshFlag(flags); @@ -153,7 +71,7 @@ export const viewCommand = buildCommand({ message: "Fetching conversation spans...", json: flags.json, }, - () => getConversationSpans(org, conversationId), + () => getConversationSpans(org, conversationId) ); const result = buildTranscriptResult(conversationId, org, spans); diff --git a/src/lib/api/ai-conversations.ts b/src/lib/api/ai-conversations.ts index d85e7bb90..69f7b3f99 100644 --- a/src/lib/api/ai-conversations.ts +++ b/src/lib/api/ai-conversations.ts @@ -6,10 +6,10 @@ */ import { - AIConversationSpanSchema, - ConversationListItemSchema, type AIConversationSpan, + AIConversationSpanSchema, type ConversationListItem, + ConversationListItemSchema, } from "../../types/ai-conversations.js"; import { resolveOrgRegion } from "../region.js"; @@ -27,23 +27,39 @@ export async function listConversations( limit?: number; cursor?: string; statsPeriod?: string; + start?: string; + end?: string; project?: string; - } = {}, + } = {} ): Promise> { const regionUrl = await resolveOrgRegion(orgSlug); const params: Record = { per_page: String(options.limit ?? 10), }; - if (options.statsPeriod) params.statsPeriod = options.statsPeriod; - if (options.cursor) params.cursor = options.cursor; - if (options.query) params.query = options.query; - if (options.project) params.project = options.project; + if (options.statsPeriod) { + params.statsPeriod = options.statsPeriod; + } + if (options.start) { + params.start = options.start; + } + if (options.end) { + params.end = options.end; + } + if (options.cursor) { + params.cursor = options.cursor; + } + if (options.query) { + params.query = options.query; + } + if (options.project) { + params.project = options.project; + } const { data, headers } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/ai-conversations/`, - { params }, + { params } ); const items = data.map((item) => ConversationListItemSchema.parse(item)); @@ -59,7 +75,7 @@ export async function getConversationSpans( statsPeriod?: string; project?: string; perPage?: number; - } = {}, + } = {} ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); @@ -67,24 +83,30 @@ export async function getConversationSpans( per_page: String(options.perPage ?? 1000), statsPeriod: options.statsPeriod ?? "30d", }; - if (options.project) params.project = options.project; + if (options.project) { + params.project = options.project; + } const spans: AIConversationSpan[] = []; let cursor: string | undefined; for (let page = 0; page < 10; page++) { - if (cursor) params.cursor = cursor; + if (cursor) { + params.cursor = cursor; + } const { data, headers } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/ai-conversations/${encodeURIComponent(conversationId)}/`, - { params }, + { params } ); spans.push(...data.map((s) => AIConversationSpanSchema.parse(s))); const parsed = parseLinkHeader(headers.get("link") ?? null); cursor = parsed.nextCursor; - if (!cursor) break; + if (!cursor) { + break; + } } return spans; diff --git a/src/lib/formatters/ai-conversations.ts b/src/lib/formatters/ai-conversations.ts index 4fa8bee0d..48057b505 100644 --- a/src/lib/formatters/ai-conversations.ts +++ b/src/lib/formatters/ai-conversations.ts @@ -15,12 +15,16 @@ import type { // --------------------------------------------------------------------------- function truncate(value: string, max = 60): string { - if (value.length <= max) return value; + if (value.length <= max) { + return value; + } return `${value.slice(0, max - 1)}…`; } function formatTimestamp(ms: number): string { - if (ms === 0) return "—"; + if (ms === 0) { + return "—"; + } return new Date(ms).toLocaleString(); } @@ -66,7 +70,9 @@ type ConversationTurn = { }; function numeric(value: string | number | null | undefined): number { - if (typeof value === "number") return value; + if (typeof value === "number") { + return value; + } if (typeof value === "string") { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; @@ -76,17 +82,26 @@ function numeric(value: string | number | null | undefined): number { function getOperationType(span: AIConversationSpan): string | undefined { const explicit = span["gen_ai.operation.type"]; - if (explicit) return explicit; + if (explicit) { + return explicit; + } const spanName = span["span.name"]; - if (!spanName?.startsWith("gen_ai.")) return undefined; - if (spanName === "gen_ai.execute_tool") return "tool"; + if (!spanName?.startsWith("gen_ai.")) { + return; + } + if (spanName === "gen_ai.execute_tool") { + return "tool"; + } if ( spanName === "gen_ai.invoke_agent" || spanName === "gen_ai.create_agent" - ) + ) { return "agent"; - if (spanName === "gen_ai.handoff") return "handoff"; + } + if (spanName === "gen_ai.handoff") { + return "handoff"; + } return "ai_client"; } @@ -99,15 +114,23 @@ function parseJson(value: string): unknown { } function stringifyContent(value: unknown): string | null { - if (typeof value === "string") return value; + if (typeof value === "string") { + return value; + } if (Array.isArray(value)) { const parts = value .map((part) => { - if (typeof part === "string") return part; + if (typeof part === "string") { + return part; + } if (part && typeof part === "object") { const r = part as Record; - if (typeof r.text === "string") return r.text; - if (typeof r.content === "string") return r.content; + if (typeof r.text === "string") { + return r.text; + } + if (typeof r.content === "string") { + return r.content; + } } return null; }) @@ -116,16 +139,20 @@ function stringifyContent(value: unknown): string | null { } if (value && typeof value === "object") { const r = value as Record; - if (typeof r.text === "string") return r.text; - if (typeof r.content === "string") return r.content; - if (typeof r.message === "string") return r.message; + if (typeof r.text === "string") { + return r.text; + } + if (typeof r.content === "string") { + return r.content; + } + if (typeof r.message === "string") { + return r.message; + } } - return value == null ? null : JSON.stringify(value); + return value === null ? null : JSON.stringify(value); } -function collectMessages( - value: unknown, -): { role?: string; content: string }[] { +function collectMessages(value: unknown): { role?: string; content: string }[] { const source = value && typeof value === "object" && @@ -141,11 +168,17 @@ function collectMessages( return source .map((msg) => { - if (typeof msg === "string") return { content: msg }; - if (!msg || typeof msg !== "object") return null; + if (typeof msg === "string") { + return { content: msg }; + } + if (!msg || typeof msg !== "object") { + return null; + } const r = msg as Record; const content = stringifyContent(r.content ?? r.text ?? r); - if (!content) return null; + if (!content) { + return null; + } return { role: typeof r.role === "string" ? r.role : undefined, content, @@ -157,8 +190,12 @@ function collectMessages( function extractUserContent(span: AIConversationSpan): string | null { const raw = span["gen_ai.input.messages"] ?? span["gen_ai.request.messages"] ?? null; - if (!raw) return null; - if (raw === "[Filtered]") return raw; + if (!raw) { + return null; + } + if (raw === "[Filtered]") { + return raw; + } const messages = collectMessages(parseJson(raw)); const userMsg = messages.findLast((m) => m.role === "user"); return userMsg?.content ?? messages.at(-1)?.content ?? null; @@ -167,31 +204,33 @@ function extractUserContent(span: AIConversationSpan): string | null { function extractAssistantContent(span: AIConversationSpan): string | null { const outputMessages = span["gen_ai.output.messages"]; if (outputMessages) { - if (outputMessages === "[Filtered]") return outputMessages; + if (outputMessages === "[Filtered]") { + return outputMessages; + } const messages = collectMessages(parseJson(outputMessages)); const assistantMsg = messages.findLast((m) => m.role === "assistant"); const content = assistantMsg?.content ?? messages.at(-1)?.content; - if (content) return content; + if (content) { + return content; + } } - return ( - span["gen_ai.response.text"] ?? span["gen_ai.response.object"] ?? null - ); + return span["gen_ai.response.text"] ?? span["gen_ai.response.object"] ?? null; } export function extractTurns(spans: AIConversationSpan[]): ConversationTurn[] { const sorted = [...spans].sort( - (a, b) => a["precise.start_ts"] - b["precise.start_ts"], + (a, b) => a["precise.start_ts"] - b["precise.start_ts"] ); const aiClientSpans = sorted.filter( - (s) => getOperationType(s) === "ai_client", + (s) => getOperationType(s) === "ai_client" ); const toolSpans = sorted.filter((s) => getOperationType(s) === "tool"); return aiClientSpans.map((span, index) => { - const nextTs = - index < aiClientSpans.length - 1 - ? aiClientSpans[index + 1]!["precise.start_ts"] - : Number.POSITIVE_INFINITY; + const nextSpan = aiClientSpans[index + 1]; + const nextTs = nextSpan + ? nextSpan["precise.start_ts"] + : Number.POSITIVE_INFINITY; const toolCalls = toolSpans .filter((ts) => { const t = ts["precise.start_ts"]; @@ -202,7 +241,7 @@ export function extractTurns(spans: AIConversationSpan[]): ConversationTurn[] { spanId: ts.span_id, timestamp: ts["precise.start_ts"], durationMs: Math.round( - (ts["precise.finish_ts"] - ts["precise.start_ts"]) * 1000, + (ts["precise.finish_ts"] - ts["precise.start_ts"]) * 1000 ), status: ts["span.status"], })); @@ -214,7 +253,7 @@ export function extractTurns(spans: AIConversationSpan[]): ConversationTurn[] { started: span["precise.start_ts"], ended: span["precise.finish_ts"], durationMs: Math.round( - (span["precise.finish_ts"] - span["precise.start_ts"]) * 1000, + (span["precise.finish_ts"] - span["precise.start_ts"]) * 1000 ), userContent: extractUserContent(span), assistantContent: extractAssistantContent(span), @@ -232,7 +271,9 @@ export function extractTurns(spans: AIConversationSpan[]): ConversationTurn[] { // --------------------------------------------------------------------------- function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; + if (ms < 1000) { + return `${ms}ms`; + } return `${(ms / 1000).toFixed(ms % 1000 === 0 ? 0 : 1)}s`; } @@ -252,7 +293,9 @@ function formatTurnHuman(turn: ConversationTurn): string { const lines: string[] = []; lines.push(`── Turn ${turn.turn} — ${formatEpoch(turn.started)}`); - if (meta) lines.push(` ${meta}`); + if (meta) { + lines.push(` ${meta}`); + } lines.push(""); if (turn.userContent) { @@ -283,40 +326,6 @@ function formatTurnHuman(turn: ConversationTurn): string { return lines.join("\n"); } -export function formatTranscript( - conversationId: string, - org: string, - spans: AIConversationSpan[], -): string { - if (spans.length === 0) { - return `No spans found for conversation ${conversationId} in the last 30 days.`; - } - - const turns = extractTurns(spans); - const totalTokens = spans.reduce( - (sum, s) => sum + numeric(s["gen_ai.usage.total_tokens"]), - 0, - ); - const projects = [...new Set(spans.map((s) => s.project))].sort(); - const start = Math.min(...spans.map((s) => s["precise.start_ts"])); - const end = Math.max(...spans.map((s) => s["precise.finish_ts"])); - - const header = [ - `AI Conversation: ${conversationId}`, - "", - ` Org: ${org}`, - ` Projects: ${projects.join(", ") || "—"}`, - ` Started: ${formatEpoch(start)}`, - ` Ended: ${formatEpoch(end)}`, - ` Turns: ${turns.length}`, - ` Spans: ${spans.length}`, - ` Tokens: ${totalTokens}`, - "", - ]; - - return [...header, ...turns.map(formatTurnHuman)].join("\n"); -} - export type TranscriptResult = { conversationId: string; org: string; @@ -328,10 +337,31 @@ export type TranscriptResult = { endTimestamp: number; }; +export function formatTranscriptResult(result: TranscriptResult): string { + if (result.spanCount === 0) { + return `No spans found for conversation ${result.conversationId} in the last 30 days.`; + } + + const header = [ + `AI Conversation: ${result.conversationId}`, + "", + ` Org: ${result.org}`, + ` Projects: ${result.projects.join(", ") || "—"}`, + ` Started: ${formatEpoch(result.startTimestamp)}`, + ` Ended: ${formatEpoch(result.endTimestamp)}`, + ` Turns: ${result.turns.length}`, + ` Spans: ${result.spanCount}`, + ` Tokens: ${result.totalTokens}`, + "", + ]; + + return [...header, ...result.turns.map(formatTurnHuman)].join("\n"); +} + export function buildTranscriptResult( conversationId: string, org: string, - spans: AIConversationSpan[], + spans: AIConversationSpan[] ): TranscriptResult { const turns = extractTurns(spans); return { @@ -340,11 +370,17 @@ export function buildTranscriptResult( turns, totalTokens: spans.reduce( (sum, s) => sum + numeric(s["gen_ai.usage.total_tokens"]), - 0, + 0 ), spanCount: spans.length, projects: [...new Set(spans.map((s) => s.project))].sort(), - startTimestamp: Math.min(...spans.map((s) => s["precise.start_ts"])), - endTimestamp: Math.max(...spans.map((s) => s["precise.finish_ts"])), + startTimestamp: + spans.length > 0 + ? Math.min(...spans.map((s) => s["precise.start_ts"])) + : 0, + endTimestamp: + spans.length > 0 + ? Math.max(...spans.map((s) => s["precise.finish_ts"])) + : 0, }; } From 8dba9149b7d515b37b8c5e5f42f636bc6814d461 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 08:27:05 -0400 Subject: [PATCH 03/17] fix: remove unused aiConversationsListCommand import Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 438f9a046..a14c2b483 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,7 +6,6 @@ import { UnsatisfiedPositionalError, } from "@stricli/core"; import { aiConversationsRoute } from "./commands/ai-conversations/index.js"; -import { listCommand as aiConversationsListCommand } from "./commands/ai-conversations/list.js"; import { apiCommand } from "./commands/api.js"; import { authRoute } from "./commands/auth/index.js"; import { whoamiCommand } from "./commands/auth/whoami.js"; From 1d5324af0bbac9e26d4077f0c6c3a4a5f07c1cbc Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 08:34:47 -0400 Subject: [PATCH 04/17] fix: add docs fragment and fix SDK codegen for hyphenated routes - Add docs/src/fragments/commands/ai-conversations.md (required by CI) - Fix generate-sdk.ts to quote hyphenated route names in generated TS Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fragments/commands/ai-conversations.md | 33 +++++++++++++++++++ script/generate-sdk.ts | 6 ++-- 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 docs/src/fragments/commands/ai-conversations.md diff --git a/docs/src/fragments/commands/ai-conversations.md b/docs/src/fragments/commands/ai-conversations.md new file mode 100644 index 000000000..724f2ab76 --- /dev/null +++ b/docs/src/fragments/commands/ai-conversations.md @@ -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 +``` diff --git a/script/generate-sdk.ts b/script/generate-sdk.ts index 5988bb6b3..58d9a1c44 100644 --- a/script/generate-sdk.ts +++ b/script/generate-sdk.ts @@ -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"); @@ -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"); From d21177ef001e2508880e00607ab82475b3889433 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 24 May 2026 12:35:28 +0000 Subject: [PATCH 05/17] chore: regenerate docs --- docs/src/content/docs/contributing.md | 1 + plugins/sentry-cli/skills/sentry-cli/SKILL.md | 9 ++ .../sentry-cli/references/ai-conversations.md | 82 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/ai-conversations.md diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index f6fad5c42..cac73932a 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -51,6 +51,7 @@ cli/ │ ├── app.ts # Stricli application setup │ ├── context.ts # Dependency injection context │ ├── commands/ # CLI commands +│ │ ├── ai-conversations/# list, view │ │ ├── auth/ # login, logout, refresh, status, token, whoami │ │ ├── cli/ # defaults, feedback, fix, import, setup, upgrade │ │ ├── dashboard/ # list, view, create, add, edit, delete, revisions, restore diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 942fa9876..9410acd26 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -338,6 +338,15 @@ Make an authenticated API request → Full flags and examples: `references/api.md` +### Ai-conversations + +List and view AI conversations + +- `sentry ai-conversations list ` — List recent AI conversations +- `sentry ai-conversations view ` — View an AI conversation transcript + +→ Full flags and examples: `references/ai-conversations.md` + ### CLI CLI-related commands diff --git a/plugins/sentry-cli/skills/sentry-cli/references/ai-conversations.md b/plugins/sentry-cli/skills/sentry-cli/references/ai-conversations.md new file mode 100644 index 000000000..da60bef58 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/ai-conversations.md @@ -0,0 +1,82 @@ +--- +name: sentry-cli-ai-conversations +version: 0.35.0-dev.0 +description: List and view AI conversations +requires: + bins: ["sentry"] + auth: true +--- + +# Ai-conversations Commands + +List and view AI conversations + +### `sentry ai-conversations list ` + +List recent AI conversations + +**Flags:** +- `-n, --limit - Number of conversations (1-1000) - (default: "25")` +- `-q, --query - Search query` +- `-t, --period - Time range: "7d", "2026-04-01..2026-05-01", ">=2026-04-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)` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `conversationId` | string | | +| `flow` | array | | +| `errors` | number | | +| `llmCalls` | number | | +| `toolCalls` | number | | +| `totalTokens` | number | | +| `totalCost` | number | | +| `startTimestamp` | number | | +| `endTimestamp` | number | | +| `traceCount` | number | | +| `traceIds` | array | | +| `firstInput` | string \| null | | +| `lastOutput` | string \| null | | +| `user` | object \| null | | +| `toolNames` | array | | +| `toolErrors` | number | | + +**Examples:** + +```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 +``` + +### `sentry ai-conversations view ` + +View an AI conversation transcript + +**Flags:** +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` + +**Examples:** + +```bash +# View full transcript +sentry ai-conversations view my-org conv-123 + +# JSON output +sentry ai-conversations view my-org conv-123 --json +``` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. From 3b23040458e82c2eecf80080ba2c9b5df953699e Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 08:50:00 -0400 Subject: [PATCH 06/17] fix: address review findings across all agents - Use schema option in apiRequestToRegion for proper Zod validation with Sentry context instead of bare .parse() (silent-failure-hunter) - Use MAX_PAGINATION_PAGES instead of hardcoded 10 (silent-failure-hunter) - Log warning when pagination limit exhausted (silent-failure-hunter) - Use reduce instead of Math.min/max spread for large arrays (silent-failure-hunter) - Guard formatEpoch against NaN/zero timestamps (silent-failure-hunter) - Re-export types from src/types/index.ts barrel (type-design-analyzer) - Export ConversationTurn and ToolCall types (type-design-analyzer) - Use barrel imports in commands (code-reviewer) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/ai-conversations/list.ts | 2 +- src/commands/ai-conversations/view.ts | 2 +- src/lib/api/ai-conversations.ts | 28 ++++++++++++++++++-------- src/lib/formatters/ai-conversations.ts | 17 ++++++++++++---- src/types/index.ts | 9 +++++++++ 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/commands/ai-conversations/list.ts b/src/commands/ai-conversations/list.ts index 5e845f230..02974d591 100644 --- a/src/commands/ai-conversations/list.ts +++ b/src/commands/ai-conversations/list.ts @@ -5,7 +5,7 @@ */ import type { SentryContext } from "../../context.js"; -import { listConversations } from "../../lib/api/ai-conversations.js"; +import { listConversations } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { advancePaginationState, diff --git a/src/commands/ai-conversations/view.ts b/src/commands/ai-conversations/view.ts index 1ec4443c8..4a6e262c5 100644 --- a/src/commands/ai-conversations/view.ts +++ b/src/commands/ai-conversations/view.ts @@ -5,7 +5,7 @@ */ import type { SentryContext } from "../../context.js"; -import { getConversationSpans } from "../../lib/api/ai-conversations.js"; +import { getConversationSpans } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { buildTranscriptResult, diff --git a/src/lib/api/ai-conversations.ts b/src/lib/api/ai-conversations.ts index 69f7b3f99..b4f61ffa5 100644 --- a/src/lib/api/ai-conversations.ts +++ b/src/lib/api/ai-conversations.ts @@ -5,6 +5,8 @@ * from the Sentry Explore conversations endpoints. */ +import { z } from "zod"; + import { type AIConversationSpan, AIConversationSpanSchema, @@ -12,14 +14,18 @@ import { ConversationListItemSchema, } from "../../types/ai-conversations.js"; +import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; import { apiRequestToRegion, + MAX_PAGINATION_PAGES, type PaginatedResponse, parseLinkHeader, } from "./infrastructure.js"; +const log = logger.withTag("api.ai-conversations"); + export async function listConversations( orgSlug: string, options: { @@ -56,16 +62,15 @@ export async function listConversations( params.project = options.project; } - const { data, headers } = await apiRequestToRegion( + const { data, headers } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/ai-conversations/`, - { params } + { params, schema: z.array(ConversationListItemSchema) } ); - const items = data.map((item) => ConversationListItemSchema.parse(item)); const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); - return { data: items, nextCursor }; + return { data, nextCursor }; } export async function getConversationSpans( @@ -78,6 +83,7 @@ export async function getConversationSpans( } = {} ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); + const pageSchema = z.array(AIConversationSpanSchema); const params: Record = { per_page: String(options.perPage ?? 1000), @@ -90,18 +96,18 @@ export async function getConversationSpans( const spans: AIConversationSpan[] = []; let cursor: string | undefined; - for (let page = 0; page < 10; page++) { + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { if (cursor) { params.cursor = cursor; } - const { data, headers } = await apiRequestToRegion( + const { data, headers } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/ai-conversations/${encodeURIComponent(conversationId)}/`, - { params } + { params, schema: pageSchema } ); - spans.push(...data.map((s) => AIConversationSpanSchema.parse(s))); + spans.push(...data); const parsed = parseLinkHeader(headers.get("link") ?? null); cursor = parsed.nextCursor; if (!cursor) { @@ -109,5 +115,11 @@ export async function getConversationSpans( } } + if (cursor) { + log.warn( + `Pagination limit reached (${MAX_PAGINATION_PAGES} pages, ${spans.length} spans). Conversation transcript may be incomplete.` + ); + } + return spans; } diff --git a/src/lib/formatters/ai-conversations.ts b/src/lib/formatters/ai-conversations.ts index 48057b505..9fd9ab2a7 100644 --- a/src/lib/formatters/ai-conversations.ts +++ b/src/lib/formatters/ai-conversations.ts @@ -45,7 +45,7 @@ export function formatConversationTable(items: ConversationListItem[]): string { // Transcript parsing (ported from sentry-mcp get-ai-conversation-details) // --------------------------------------------------------------------------- -type ToolCall = { +export type ToolCall = { name: string; spanId: string; timestamp: number; @@ -53,7 +53,7 @@ type ToolCall = { status?: string | null; }; -type ConversationTurn = { +export type ConversationTurn = { turn: number; spanId: string; traceId: string; @@ -278,6 +278,9 @@ function formatDuration(ms: number): string { } function formatEpoch(ts: number): string { + if (!Number.isFinite(ts) || ts === 0) { + return "—"; + } return new Date(ts * 1000).toISOString(); } @@ -376,11 +379,17 @@ export function buildTranscriptResult( projects: [...new Set(spans.map((s) => s.project))].sort(), startTimestamp: spans.length > 0 - ? Math.min(...spans.map((s) => s["precise.start_ts"])) + ? spans.reduce( + (min, s) => Math.min(min, s["precise.start_ts"]), + Infinity + ) : 0, endTimestamp: spans.length > 0 - ? Math.max(...spans.map((s) => s["precise.finish_ts"])) + ? spans.reduce( + (max, s) => Math.max(max, s["precise.finish_ts"]), + -Infinity + ) : 0, }; } diff --git a/src/types/index.ts b/src/types/index.ts index 79d91207b..6328a0397 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,15 @@ * Re-exports all types from domain-specific modules. */ +// AI Conversations types +export type { + AIConversationSpan, + ConversationListItem, +} from "./ai-conversations.js"; +export { + AIConversationSpanSchema, + ConversationListItemSchema, +} from "./ai-conversations.js"; // DSN types export type { DetectedDsn, DsnSource, ParsedDsn } from "../lib/dsn/types.js"; // Configuration types From 9435e03488b6013936b926387f62f11317bcd11a Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 09:04:16 -0400 Subject: [PATCH 07/17] fix: address remaining review findings and lint errors - Fix lint: use Number.POSITIVE/NEGATIVE_INFINITY, reorder imports - Fix formatTimestamp treating unix seconds as milliseconds (showed 1970 dates) - Preserve --query filter in pagination hints - Surface truncation warning when transcript exceeds pagination limit - Make view command org optional with resolveOrg fallback - Register ai-conversations commands in ORG_ONLY_COMMANDS for completions Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/ai-conversations/list.ts | 3 +++ src/commands/ai-conversations/view.ts | 31 ++++++++++++++++++++++---- src/lib/api/ai-conversations.ts | 7 +++--- src/lib/complete.ts | 2 ++ src/lib/formatters/ai-conversations.ts | 19 +++++++++++----- src/types/index.ts | 4 ++-- 6 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/commands/ai-conversations/list.ts b/src/commands/ai-conversations/list.ts index 02974d591..24aa2a38c 100644 --- a/src/commands/ai-conversations/list.ts +++ b/src/commands/ai-conversations/list.ts @@ -194,6 +194,9 @@ export const listCommand = buildListCommand("ai-conversations", { }); const parts: string[] = []; + if (flags.query) { + parts.push(`-q "${flags.query}"`); + } appendPeriodHint(parts, flags.period, DEFAULT_PERIOD); const flagSuffix = parts.length > 0 ? ` ${parts.join(" ")}` : ""; diff --git a/src/commands/ai-conversations/view.ts b/src/commands/ai-conversations/view.ts index 4a6e262c5..d613ab268 100644 --- a/src/commands/ai-conversations/view.ts +++ b/src/commands/ai-conversations/view.ts @@ -7,6 +7,7 @@ import type { SentryContext } from "../../context.js"; import { getConversationSpans } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; +import { ContextError } from "../../lib/errors.js"; import { buildTranscriptResult, formatTranscriptResult, @@ -19,6 +20,7 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; +import { resolveOrg } from "../../lib/resolve-target.js"; type ViewFlags = { readonly json: boolean; @@ -43,8 +45,9 @@ export const viewCommand = buildCommand({ parameters: [ { placeholder: "org", - brief: "Organization slug", + brief: "Organization slug (optional if auto-detected)", parse: String, + optional: true, }, { placeholder: "conversation-id", @@ -61,12 +64,31 @@ export const viewCommand = buildCommand({ async *func( this: SentryContext, flags: ViewFlags, - org: string, - conversationId: string + orgOrConversationId: string, + maybeConversationId?: string ) { applyFreshFlag(flags); + const { cwd } = this; - const spans = await withProgress( + let org: string; + let conversationId: string; + + if (maybeConversationId) { + org = orgOrConversationId; + conversationId = maybeConversationId; + } else { + const resolved = await resolveOrg({ cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry ai-conversations view " + ); + } + org = resolved.org; + conversationId = orgOrConversationId; + } + + const { spans, truncated } = await withProgress( { message: "Fetching conversation spans...", json: flags.json, @@ -75,6 +97,7 @@ export const viewCommand = buildCommand({ ); const result = buildTranscriptResult(conversationId, org, spans); + result.truncated = truncated; yield new CommandOutput(result); }, }); diff --git a/src/lib/api/ai-conversations.ts b/src/lib/api/ai-conversations.ts index b4f61ffa5..442a1f69a 100644 --- a/src/lib/api/ai-conversations.ts +++ b/src/lib/api/ai-conversations.ts @@ -81,7 +81,7 @@ export async function getConversationSpans( project?: string; perPage?: number; } = {} -): Promise { +): Promise<{ spans: AIConversationSpan[]; truncated: boolean }> { const regionUrl = await resolveOrgRegion(orgSlug); const pageSchema = z.array(AIConversationSpanSchema); @@ -115,11 +115,12 @@ export async function getConversationSpans( } } - if (cursor) { + const truncated = !!cursor; + if (truncated) { log.warn( `Pagination limit reached (${MAX_PAGINATION_PAGES} pages, ${spans.length} spans). Conversation transcript may be incomplete.` ); } - return spans; + return { spans, truncated }; } diff --git a/src/lib/complete.ts b/src/lib/complete.ts index d383b1fd2..1b37cebb2 100644 --- a/src/lib/complete.ts +++ b/src/lib/complete.ts @@ -125,6 +125,8 @@ export const ORG_PROJECT_COMMANDS = new Set([ * @internal Exported for testing only. */ export const ORG_ONLY_COMMANDS = new Set([ + "ai-conversations list", + "ai-conversations view", "org view", "release list", "release view", diff --git a/src/lib/formatters/ai-conversations.ts b/src/lib/formatters/ai-conversations.ts index 9fd9ab2a7..f494f2002 100644 --- a/src/lib/formatters/ai-conversations.ts +++ b/src/lib/formatters/ai-conversations.ts @@ -21,11 +21,11 @@ function truncate(value: string, max = 60): string { return `${value.slice(0, max - 1)}…`; } -function formatTimestamp(ms: number): string { - if (ms === 0) { +function formatTimestamp(epochSeconds: number): string { + if (epochSeconds === 0) { return "—"; } - return new Date(ms).toLocaleString(); + return new Date(epochSeconds * 1000).toLocaleString(); } export function formatConversationTable(items: ConversationListItem[]): string { @@ -338,6 +338,7 @@ export type TranscriptResult = { projects: string[]; startTimestamp: number; endTimestamp: number; + truncated?: boolean; }; export function formatTranscriptResult(result: TranscriptResult): string { @@ -358,7 +359,13 @@ export function formatTranscriptResult(result: TranscriptResult): string { "", ]; - return [...header, ...result.turns.map(formatTurnHuman)].join("\n"); + const sections = [...header, ...result.turns.map(formatTurnHuman)]; + if (result.truncated) { + sections.push( + "⚠ Transcript truncated — the conversation exceeds the pagination limit." + ); + } + return sections.join("\n"); } export function buildTranscriptResult( @@ -381,14 +388,14 @@ export function buildTranscriptResult( spans.length > 0 ? spans.reduce( (min, s) => Math.min(min, s["precise.start_ts"]), - Infinity + Number.POSITIVE_INFINITY ) : 0, endTimestamp: spans.length > 0 ? spans.reduce( (max, s) => Math.max(max, s["precise.finish_ts"]), - -Infinity + Number.NEGATIVE_INFINITY ) : 0, }; diff --git a/src/types/index.ts b/src/types/index.ts index 6328a0397..a6b0d421d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,8 @@ * Re-exports all types from domain-specific modules. */ +// DSN types +export type { DetectedDsn, DsnSource, ParsedDsn } from "../lib/dsn/types.js"; // AI Conversations types export type { AIConversationSpan, @@ -14,8 +16,6 @@ export { AIConversationSpanSchema, ConversationListItemSchema, } from "./ai-conversations.js"; -// DSN types -export type { DetectedDsn, DsnSource, ParsedDsn } from "../lib/dsn/types.js"; // Configuration types export type { CachedProject, From c43a9f1eef0cf12f21f79b5933cbdd6ca41b8abd Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 09:13:20 -0400 Subject: [PATCH 08/17] fix: add ai-conversations to groupsWithDefaultCommand in completions test Co-Authored-By: Claude Opus 4.6 (1M context) --- test/lib/completions.property.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/lib/completions.property.test.ts b/test/lib/completions.property.test.ts index 17dd14286..78f2adc49 100644 --- a/test/lib/completions.property.test.ts +++ b/test/lib/completions.property.test.ts @@ -184,6 +184,7 @@ describe("proposeCompletions: Stricli integration", () => { // potential positional arg for the default command and proposes flags // instead. These groups are tested separately below. const groupsWithDefaultCommand = new Set([ + "ai-conversations", "auth", "issue", "event", From db6e5e1dd4d2a996b7150f25c8dd1fd61dc983b6 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 09:22:47 -0400 Subject: [PATCH 09/17] fix: address latest Warden findings - Filter out messages with null content instead of JSON-stringifying them - Align DEFAULT_PERIOD (14d) with LIST_PERIOD_FLAG default (7d) - Widen view span lookup from 30d to 90d to cover older conversations Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/ai-conversations/list.ts | 2 +- src/lib/api/ai-conversations.ts | 2 +- src/lib/formatters/ai-conversations.ts | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/commands/ai-conversations/list.ts b/src/commands/ai-conversations/list.ts index 24aa2a38c..ce2c5c28c 100644 --- a/src/commands/ai-conversations/list.ts +++ b/src/commands/ai-conversations/list.ts @@ -58,7 +58,7 @@ type ConversationListResult = { const COMMAND_NAME = "ai-conversations list"; const PAGINATION_KEY = "ai-conversations-list"; -const DEFAULT_PERIOD = "14d"; +const DEFAULT_PERIOD = "7d"; function parseLimit(value: string): number { return validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT); diff --git a/src/lib/api/ai-conversations.ts b/src/lib/api/ai-conversations.ts index 442a1f69a..a717b7ae5 100644 --- a/src/lib/api/ai-conversations.ts +++ b/src/lib/api/ai-conversations.ts @@ -87,7 +87,7 @@ export async function getConversationSpans( const params: Record = { per_page: String(options.perPage ?? 1000), - statsPeriod: options.statsPeriod ?? "30d", + statsPeriod: options.statsPeriod ?? "90d", }; if (options.project) { params.project = options.project; diff --git a/src/lib/formatters/ai-conversations.ts b/src/lib/formatters/ai-conversations.ts index f494f2002..5f8e6aabb 100644 --- a/src/lib/formatters/ai-conversations.ts +++ b/src/lib/formatters/ai-conversations.ts @@ -175,7 +175,11 @@ function collectMessages(value: unknown): { role?: string; content: string }[] { return null; } const r = msg as Record; - const content = stringifyContent(r.content ?? r.text ?? r); + const raw = r.content ?? r.text; + if (raw === null || raw === undefined) { + return null; + } + const content = stringifyContent(raw); if (!content) { return null; } From 588cca8754e0a1bfff6be780d1639ea147687b5d Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 09:33:57 -0400 Subject: [PATCH 10/17] fix: revert view span period back to 30d 90d causes performance issues (sentry#7253568394). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/api/ai-conversations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/api/ai-conversations.ts b/src/lib/api/ai-conversations.ts index a717b7ae5..442a1f69a 100644 --- a/src/lib/api/ai-conversations.ts +++ b/src/lib/api/ai-conversations.ts @@ -87,7 +87,7 @@ export async function getConversationSpans( const params: Record = { per_page: String(options.perPage ?? 1000), - statsPeriod: options.statsPeriod ?? "90d", + statsPeriod: options.statsPeriod ?? "30d", }; if (options.project) { params.project = options.project; From b8050b40325a9bad627a4a8e1723e353f9a14f39 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 09:37:20 -0400 Subject: [PATCH 11/17] fix: validate conversation-id and sanitize terminal output - Validate conversation-id is present before API call (zero-arg case) - Sanitize user/assistant content and tool names to prevent terminal injection from stored AI responses containing ANSI/control characters Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/ai-conversations/view.ts | 6 +++++- src/lib/formatters/ai-conversations.ts | 15 ++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/commands/ai-conversations/view.ts b/src/commands/ai-conversations/view.ts index d613ab268..1b80ff25e 100644 --- a/src/commands/ai-conversations/view.ts +++ b/src/commands/ai-conversations/view.ts @@ -76,7 +76,7 @@ export const viewCommand = buildCommand({ if (maybeConversationId) { org = orgOrConversationId; conversationId = maybeConversationId; - } else { + } else if (orgOrConversationId) { const resolved = await resolveOrg({ cwd }); if (!resolved) { throw new ContextError( @@ -86,6 +86,10 @@ export const viewCommand = buildCommand({ } org = resolved.org; conversationId = orgOrConversationId; + } else { + throw new Error( + "Missing conversation ID. Usage: sentry ai-conversations view [org] " + ); } const { spans, truncated } = await withProgress( diff --git a/src/lib/formatters/ai-conversations.ts b/src/lib/formatters/ai-conversations.ts index 5f8e6aabb..4228d0b71 100644 --- a/src/lib/formatters/ai-conversations.ts +++ b/src/lib/formatters/ai-conversations.ts @@ -9,6 +9,7 @@ import type { AIConversationSpan, ConversationListItem, } from "../../types/ai-conversations.js"; +import { sanitize } from "./local.js"; // --------------------------------------------------------------------------- // List formatter @@ -30,8 +31,8 @@ function formatTimestamp(epochSeconds: number): string { export function formatConversationTable(items: ConversationListItem[]): string { const rows = items.map((c) => { - const input = c.firstInput ? truncate(c.firstInput) : "—"; - const user = c.user?.email ?? c.user?.username ?? "—"; + const input = c.firstInput ? sanitize(truncate(c.firstInput)) : "—"; + const user = sanitize(c.user?.email ?? c.user?.username ?? "—"); const time = formatTimestamp(c.startTimestamp); return ` ${truncate(c.conversationId, 40)} ${time} ${String(c.totalTokens).padStart(8)} ${String(c.toolCalls).padStart(5)} ${String(c.errors).padStart(4)} ${user} ${input}`; }); @@ -307,7 +308,7 @@ function formatTurnHuman(turn: ConversationTurn): string { if (turn.userContent) { lines.push(" [user]"); - for (const line of truncate(turn.userContent, 600).split("\n")) { + for (const line of sanitize(truncate(turn.userContent, 600)).split("\n")) { lines.push(` ${line}`); } lines.push(""); @@ -315,7 +316,9 @@ function formatTurnHuman(turn: ConversationTurn): string { if (turn.assistantContent) { lines.push(" [assistant]"); - for (const line of truncate(turn.assistantContent, 600).split("\n")) { + for (const line of sanitize(truncate(turn.assistantContent, 600)).split( + "\n" + )) { lines.push(` ${line}`); } lines.push(""); @@ -325,7 +328,9 @@ function formatTurnHuman(turn: ConversationTurn): string { lines.push(" [tools]"); for (const tc of turn.toolCalls) { const status = tc.status && tc.status !== "ok" ? ` (${tc.status})` : ""; - lines.push(` • ${tc.name} — ${formatDuration(tc.durationMs)}${status}`); + lines.push( + ` • ${sanitize(tc.name)} — ${formatDuration(tc.durationMs)}${status}` + ); } lines.push(""); } From ddcb856b71829cc8816390d55c694551df7d56e0 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 09:48:24 -0400 Subject: [PATCH 12/17] refactor: rename ai-conversations to conversation Follow CLI convention of singular resource names (issue, trace, replay). Command is now `sentry conversation list` / `sentry conversation view`. Renames all files, imports, pagination keys, usage hints, docs fragments, completions sets, and AGENTS.md architecture tree. Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 1 + docs/src/content/docs/contributing.md | 2 +- .../{ai-conversations.md => conversation.md} | 0 plugins/sentry-cli/skills/sentry-cli/SKILL.md | 8 ++-- .../{ai-conversations.md => conversation.md} | 37 ++----------------- src/app.ts | 4 +- .../index.ts | 4 +- .../list.ts | 28 +++++++------- .../view.ts | 12 +++--- src/lib/api-client.ts | 4 +- .../{ai-conversations.ts => conversations.ts} | 4 +- src/lib/complete.ts | 4 +- .../{ai-conversations.ts => conversation.ts} | 2 +- .../{ai-conversations.ts => conversation.ts} | 0 src/types/index.ts | 18 ++++----- test/lib/completions.property.test.ts | 2 +- 16 files changed, 51 insertions(+), 79 deletions(-) rename docs/src/fragments/commands/{ai-conversations.md => conversation.md} (100%) rename plugins/sentry-cli/skills/sentry-cli/references/{ai-conversations.md => conversation.md} (66%) rename src/commands/{ai-conversations => conversation}/index.ts (88%) rename src/commands/{ai-conversations => conversation}/list.ts (84%) rename src/commands/{ai-conversations => conversation}/view.ts (87%) rename src/lib/api/{ai-conversations.ts => conversations.ts} (97%) rename src/lib/formatters/{ai-conversations.ts => conversation.ts} (99%) rename src/types/{ai-conversations.ts => conversation.ts} (100%) diff --git a/AGENTS.md b/AGENTS.md index 8491718f4..ca4ba0c36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,7 @@ cli/ │ ├── commands/ # CLI commands │ │ ├── auth/ # login, logout, refresh, status, token, whoami │ │ ├── cli/ # defaults, feedback, fix, setup, upgrade +│ │ ├── conversation/ # list, view (AI conversations) │ │ ├── dashboard/ # list, view, create, widget (add, edit, delete) │ │ ├── event/ # list, view │ │ ├── issue/ # list, view, events, explain, plan, resolve, unresolve, merge diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index cac73932a..4aa11e600 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -51,9 +51,9 @@ cli/ │ ├── app.ts # Stricli application setup │ ├── context.ts # Dependency injection context │ ├── commands/ # CLI commands -│ │ ├── ai-conversations/# list, view │ │ ├── auth/ # login, logout, refresh, status, token, whoami │ │ ├── cli/ # defaults, feedback, fix, import, setup, upgrade +│ │ ├── conversation/# list, view │ │ ├── dashboard/ # list, view, create, add, edit, delete, revisions, restore │ │ ├── event/ # view, list │ │ ├── issue/ # list, events, explain, plan, view, resolve, unresolve, archive, merge diff --git a/docs/src/fragments/commands/ai-conversations.md b/docs/src/fragments/commands/conversation.md similarity index 100% rename from docs/src/fragments/commands/ai-conversations.md rename to docs/src/fragments/commands/conversation.md diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 9410acd26..35fddd557 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -338,14 +338,14 @@ Make an authenticated API request → Full flags and examples: `references/api.md` -### Ai-conversations +### Conversation List and view AI conversations -- `sentry ai-conversations list ` — List recent AI conversations -- `sentry ai-conversations view ` — View an AI conversation transcript +- `sentry conversation list ` — List recent AI conversations +- `sentry conversation view ` — View an AI conversation transcript -→ Full flags and examples: `references/ai-conversations.md` +→ Full flags and examples: `references/conversation.md` ### CLI diff --git a/plugins/sentry-cli/skills/sentry-cli/references/ai-conversations.md b/plugins/sentry-cli/skills/sentry-cli/references/conversation.md similarity index 66% rename from plugins/sentry-cli/skills/sentry-cli/references/ai-conversations.md rename to plugins/sentry-cli/skills/sentry-cli/references/conversation.md index da60bef58..eeb19255f 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/ai-conversations.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/conversation.md @@ -1,5 +1,5 @@ --- -name: sentry-cli-ai-conversations +name: sentry-cli-conversation version: 0.35.0-dev.0 description: List and view AI conversations requires: @@ -7,11 +7,11 @@ requires: auth: true --- -# Ai-conversations Commands +# Conversation Commands List and view AI conversations -### `sentry ai-conversations list ` +### `sentry conversation list ` List recent AI conversations @@ -43,40 +43,11 @@ List recent AI conversations | `toolNames` | array | | | `toolErrors` | number | | -**Examples:** - -```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 -``` - -### `sentry ai-conversations view ` +### `sentry conversation view ` View an AI conversation transcript **Flags:** - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` -**Examples:** - -```bash -# View full transcript -sentry ai-conversations view my-org conv-123 - -# JSON output -sentry ai-conversations view my-org conv-123 --json -``` - All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/app.ts b/src/app.ts index a14c2b483..9423c824e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,11 +5,11 @@ 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"; import { cliRoute } from "./commands/cli/index.js"; +import { conversationRoute } from "./commands/conversation/index.js"; import { dashboardRoute } from "./commands/dashboard/index.js"; import { listCommand as dashboardListCommand } from "./commands/dashboard/list.js"; import { eventRoute } from "./commands/event/index.js"; @@ -84,7 +84,7 @@ const PLURAL_TO_SINGULAR: Record = { /** Top-level route map containing all CLI commands */ export const routes = buildRouteMap({ routes: { - "ai-conversations": aiConversationsRoute, + conversation: conversationRoute, help: helpCommand, auth: authRoute, cli: cliRoute, diff --git a/src/commands/ai-conversations/index.ts b/src/commands/conversation/index.ts similarity index 88% rename from src/commands/ai-conversations/index.ts rename to src/commands/conversation/index.ts index ed021a969..1747880ad 100644 --- a/src/commands/ai-conversations/index.ts +++ b/src/commands/conversation/index.ts @@ -1,5 +1,5 @@ /** - * sentry ai-conversations + * sentry conversation * * List and view AI conversations from Sentry Explore. */ @@ -8,7 +8,7 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { listCommand } from "./list.js"; import { viewCommand } from "./view.js"; -export const aiConversationsRoute = buildRouteMap({ +export const conversationRoute = buildRouteMap({ routes: { list: listCommand, view: viewCommand, diff --git a/src/commands/ai-conversations/list.ts b/src/commands/conversation/list.ts similarity index 84% rename from src/commands/ai-conversations/list.ts rename to src/commands/conversation/list.ts index ce2c5c28c..ddf0b8342 100644 --- a/src/commands/ai-conversations/list.ts +++ b/src/commands/conversation/list.ts @@ -1,5 +1,5 @@ /** - * sentry ai-conversations list + * sentry conversation list * * List recent AI conversations from Sentry projects. */ @@ -13,7 +13,7 @@ import { hasPreviousPage, resolveCursor, } from "../../lib/db/pagination.js"; -import { formatConversationTable } from "../../lib/formatters/ai-conversations.js"; +import { formatConversationTable } from "../../lib/formatters/conversation.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { @@ -36,7 +36,7 @@ import { import { type ConversationListItem, ConversationListItemSchema, -} from "../../types/ai-conversations.js"; +} from "../../types/conversation.js"; type ListFlags = { readonly limit: number; @@ -56,8 +56,8 @@ type ConversationListResult = { org: string; }; -const COMMAND_NAME = "ai-conversations list"; -const PAGINATION_KEY = "ai-conversations-list"; +const COMMAND_NAME = "conversation list"; +const PAGINATION_KEY = "conversation-list"; const DEFAULT_PERIOD = "7d"; function parseLimit(value: string): number { @@ -94,17 +94,17 @@ function jsonTransform( return envelope; } -export const listCommand = buildListCommand("ai-conversations", { +export const listCommand = buildListCommand("conversation", { 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', + " sentry conversation list # List last 10 conversations\n" + + " sentry conversation list my-org # Explicit org\n" + + " sentry conversation list --limit 50 # Show more\n" + + " sentry conversation list --period 24h # Last 24 hours\n" + + ' sentry conversation list -q "has:errors" # Filter\n', }, output: { human: formatListHuman, @@ -155,7 +155,7 @@ export const listCommand = buildListCommand("ai-conversations", { } const org = resolved.org; - const contextKey = buildPaginationContextKey("ai-conversations", org, { + const contextKey = buildPaginationContextKey("conversation", org, { q: flags.query, period: serializeTimeRange(flags.period), }); @@ -204,8 +204,8 @@ export const listCommand = buildListCommand("ai-conversations", { hint: paginationHint({ hasMore, hasPrev: !!hasPrev, - nextHint: `sentry ai-conversations list ${org} -c next${flagSuffix}`, - prevHint: `sentry ai-conversations list ${org} -c prev${flagSuffix}`, + nextHint: `sentry conversation list ${org} -c next${flagSuffix}`, + prevHint: `sentry conversation list ${org} -c prev${flagSuffix}`, }), }; }, diff --git a/src/commands/ai-conversations/view.ts b/src/commands/conversation/view.ts similarity index 87% rename from src/commands/ai-conversations/view.ts rename to src/commands/conversation/view.ts index 1b80ff25e..0101ea4c9 100644 --- a/src/commands/ai-conversations/view.ts +++ b/src/commands/conversation/view.ts @@ -1,5 +1,5 @@ /** - * sentry ai-conversations view + * sentry conversation view * * View the transcript of a specific AI conversation. */ @@ -12,7 +12,7 @@ import { buildTranscriptResult, formatTranscriptResult, type TranscriptResult, -} from "../../lib/formatters/ai-conversations.js"; +} from "../../lib/formatters/conversation.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, @@ -33,8 +33,8 @@ export const viewCommand = buildCommand({ 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", + " sentry conversation view my-org conv-123\n" + + " sentry conversation view my-org conv-123 --json\n", }, output: { human: formatTranscriptResult, @@ -81,14 +81,14 @@ export const viewCommand = buildCommand({ if (!resolved) { throw new ContextError( "Organization", - "sentry ai-conversations view " + "sentry conversation view " ); } org = resolved.org; conversationId = orgOrConversationId; } else { throw new Error( - "Missing conversation ID. Usage: sentry ai-conversations view [org] " + "Missing conversation ID. Usage: sentry conversation view [org] " ); } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 8629e2490..d469d5b77 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -16,7 +16,7 @@ * - traces: trace details and transactions * - logs: log listing, detailed fetch, trace-logs * - seer: Seer AI root cause analysis and planning - * - ai-conversations: AI conversation listing and detail + * - conversations: AI conversation listing and detail * - trials: product trial management * - users: current user info */ @@ -24,7 +24,7 @@ export { getConversationSpans, listConversations, -} from "./api/ai-conversations.js"; +} from "./api/conversations.js"; export { createDashboard, getDashboard, diff --git a/src/lib/api/ai-conversations.ts b/src/lib/api/conversations.ts similarity index 97% rename from src/lib/api/ai-conversations.ts rename to src/lib/api/conversations.ts index 442a1f69a..fbfce4d88 100644 --- a/src/lib/api/ai-conversations.ts +++ b/src/lib/api/conversations.ts @@ -12,7 +12,7 @@ import { AIConversationSpanSchema, type ConversationListItem, ConversationListItemSchema, -} from "../../types/ai-conversations.js"; +} from "../../types/conversation.js"; import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; @@ -24,7 +24,7 @@ import { parseLinkHeader, } from "./infrastructure.js"; -const log = logger.withTag("api.ai-conversations"); +const log = logger.withTag("api.conversations"); export async function listConversations( orgSlug: string, diff --git a/src/lib/complete.ts b/src/lib/complete.ts index 1b37cebb2..2d04e4a8d 100644 --- a/src/lib/complete.ts +++ b/src/lib/complete.ts @@ -125,8 +125,8 @@ export const ORG_PROJECT_COMMANDS = new Set([ * @internal Exported for testing only. */ export const ORG_ONLY_COMMANDS = new Set([ - "ai-conversations list", - "ai-conversations view", + "conversation list", + "conversation view", "org view", "release list", "release view", diff --git a/src/lib/formatters/ai-conversations.ts b/src/lib/formatters/conversation.ts similarity index 99% rename from src/lib/formatters/ai-conversations.ts rename to src/lib/formatters/conversation.ts index 4228d0b71..83bd6908c 100644 --- a/src/lib/formatters/ai-conversations.ts +++ b/src/lib/formatters/conversation.ts @@ -8,7 +8,7 @@ import type { AIConversationSpan, ConversationListItem, -} from "../../types/ai-conversations.js"; +} from "../../types/conversation.js"; import { sanitize } from "./local.js"; // --------------------------------------------------------------------------- diff --git a/src/types/ai-conversations.ts b/src/types/conversation.ts similarity index 100% rename from src/types/ai-conversations.ts rename to src/types/conversation.ts diff --git a/src/types/index.ts b/src/types/index.ts index a6b0d421d..20cea960d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,15 +7,6 @@ // DSN types export type { DetectedDsn, DsnSource, ParsedDsn } from "../lib/dsn/types.js"; -// AI Conversations types -export type { - AIConversationSpan, - ConversationListItem, -} from "./ai-conversations.js"; -export { - AIConversationSpanSchema, - ConversationListItemSchema, -} from "./ai-conversations.js"; // Configuration types export type { CachedProject, @@ -28,6 +19,15 @@ export { ProjectAliasesSchema, SentryConfigSchema, } from "./config.js"; +// AI Conversations types +export type { + AIConversationSpan, + ConversationListItem, +} from "./conversation.js"; +export { + AIConversationSpanSchema, + ConversationListItemSchema, +} from "./conversation.js"; // Dashboard types export type { DashboardDetail, diff --git a/test/lib/completions.property.test.ts b/test/lib/completions.property.test.ts index 78f2adc49..5d3157b2c 100644 --- a/test/lib/completions.property.test.ts +++ b/test/lib/completions.property.test.ts @@ -184,7 +184,7 @@ describe("proposeCompletions: Stricli integration", () => { // potential positional arg for the default command and proposes flags // instead. These groups are tested separately below. const groupsWithDefaultCommand = new Set([ - "ai-conversations", + "conversation", "auth", "issue", "event", From 8af3d199b2d69b77168ced7eeb0e0eee77b173c9 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 10:00:18 -0400 Subject: [PATCH 13/17] fix: address remaining review findings and add tests - Fix sanitize() removing newlines before split (multi-line content) - Sanitize conversationId in list table output - Mark conversation-id positional as optional for Stricli compatibility - Add formatter tests covering table, turns, transcript, and edge cases Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/conversation/view.ts | 1 + src/lib/formatters/conversation.ts | 12 +- test/lib/formatters/conversation.test.ts | 249 +++++++++++++++++++++++ 3 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 test/lib/formatters/conversation.test.ts diff --git a/src/commands/conversation/view.ts b/src/commands/conversation/view.ts index 0101ea4c9..eb1c657f5 100644 --- a/src/commands/conversation/view.ts +++ b/src/commands/conversation/view.ts @@ -53,6 +53,7 @@ export const viewCommand = buildCommand({ placeholder: "conversation-id", brief: "AI conversation ID", parse: String, + optional: true, }, ], }, diff --git a/src/lib/formatters/conversation.ts b/src/lib/formatters/conversation.ts index 83bd6908c..ec27726d0 100644 --- a/src/lib/formatters/conversation.ts +++ b/src/lib/formatters/conversation.ts @@ -34,7 +34,7 @@ export function formatConversationTable(items: ConversationListItem[]): string { const input = c.firstInput ? sanitize(truncate(c.firstInput)) : "—"; const user = sanitize(c.user?.email ?? c.user?.username ?? "—"); const time = formatTimestamp(c.startTimestamp); - return ` ${truncate(c.conversationId, 40)} ${time} ${String(c.totalTokens).padStart(8)} ${String(c.toolCalls).padStart(5)} ${String(c.errors).padStart(4)} ${user} ${input}`; + return ` ${sanitize(truncate(c.conversationId, 40))} ${time} ${String(c.totalTokens).padStart(8)} ${String(c.toolCalls).padStart(5)} ${String(c.errors).padStart(4)} ${user} ${input}`; }); const header = @@ -308,18 +308,16 @@ function formatTurnHuman(turn: ConversationTurn): string { if (turn.userContent) { lines.push(" [user]"); - for (const line of sanitize(truncate(turn.userContent, 600)).split("\n")) { - lines.push(` ${line}`); + for (const line of truncate(turn.userContent, 600).split("\n")) { + lines.push(` ${sanitize(line)}`); } lines.push(""); } if (turn.assistantContent) { lines.push(" [assistant]"); - for (const line of sanitize(truncate(turn.assistantContent, 600)).split( - "\n" - )) { - lines.push(` ${line}`); + for (const line of truncate(turn.assistantContent, 600).split("\n")) { + lines.push(` ${sanitize(line)}`); } lines.push(""); } diff --git a/test/lib/formatters/conversation.test.ts b/test/lib/formatters/conversation.test.ts new file mode 100644 index 000000000..27edf2428 --- /dev/null +++ b/test/lib/formatters/conversation.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, test } from "vitest"; +import { + buildTranscriptResult, + extractTurns, + formatConversationTable, + formatTranscriptResult, + type TranscriptResult, +} from "../../../src/lib/formatters/conversation.js"; +import type { + AIConversationSpan, + ConversationListItem, +} from "../../../src/types/conversation.js"; + +function makeListItem( + overrides: Partial = {} +): ConversationListItem { + return { + conversationId: "conv-abc-123", + startTimestamp: 1_716_500_000, + totalTokens: 500, + toolCalls: 3, + errors: 0, + firstInput: "Hello world", + user: { email: "test@example.com" }, + ...overrides, + }; +} + +function makeSpan( + overrides: Partial = {} +): AIConversationSpan { + return { + span_id: "aabb112233445566", + trace: "00112233445566778899aabbccddeeff", + project: "my-project", + "span.name": "gen_ai.invoke_agent", + "span.status": "ok", + "precise.start_ts": 1_716_500_000, + "precise.finish_ts": 1_716_500_010, + "gen_ai.operation.type": "ai_client", + "gen_ai.input.messages": '{"messages":[{"role":"user","content":"hi"}]}', + "gen_ai.output.messages": + '{"messages":[{"role":"assistant","content":"hello"}]}', + "gen_ai.usage.total_tokens": "100", + "gen_ai.request.model": "gpt-4", + "gen_ai.response.model": "gpt-4", + ...overrides, + }; +} + +describe("formatConversationTable", () => { + test("renders a table with conversation data", () => { + const items = [makeListItem()]; + const result = formatConversationTable(items); + expect(result).toContain("conv-abc-123"); + expect(result).toContain("test@example.com"); + expect(result).toContain("Hello world"); + expect(result).toContain("500"); + }); + + test("handles missing user and input", () => { + const items = [makeListItem({ user: undefined, firstInput: undefined })]; + const result = formatConversationTable(items); + expect(result).toContain("—"); + }); + + test("truncates long conversation IDs", () => { + const longId = "a".repeat(60); + const items = [makeListItem({ conversationId: longId })]; + const result = formatConversationTable(items); + expect(result).not.toContain(longId); + expect(result).toContain("…"); + }); + + test("formats timestamps correctly (not 1970)", () => { + const items = [makeListItem({ startTimestamp: 1_716_500_000 })]; + const result = formatConversationTable(items); + expect(result).not.toContain("1970"); + }); + + test("shows dash for zero timestamp", () => { + const items = [makeListItem({ startTimestamp: 0 })]; + const result = formatConversationTable(items); + expect(result).toContain("—"); + }); +}); + +describe("extractTurns", () => { + test("extracts turns from ai_client spans", () => { + const spans = [makeSpan()]; + const turns = extractTurns(spans); + expect(turns).toHaveLength(1); + expect(turns[0].turn).toBe(1); + expect(turns[0].userContent).toBe("hi"); + expect(turns[0].assistantContent).toBe("hello"); + expect(turns[0].model).toBe("gpt-4"); + }); + + test("associates tool calls with the correct turn", () => { + const aiSpan = makeSpan({ + "precise.start_ts": 100, + "precise.finish_ts": 110, + }); + const toolSpan = makeSpan({ + span_id: "tool111122223333", + "gen_ai.operation.type": "tool", + "span.name": "gen_ai.execute_tool", + "gen_ai.tool.name": "search", + "precise.start_ts": 105, + "precise.finish_ts": 106, + }); + const turns = extractTurns([aiSpan, toolSpan]); + expect(turns).toHaveLength(1); + expect(turns[0].toolCalls).toHaveLength(1); + expect(turns[0].toolCalls[0].name).toBe("search"); + }); + + test("returns empty array for no spans", () => { + expect(extractTurns([])).toEqual([]); + }); + + test("sorts spans by start timestamp", () => { + const span1 = makeSpan({ + span_id: "aaaa111122223333", + "precise.start_ts": 200, + "precise.finish_ts": 210, + }); + const span2 = makeSpan({ + span_id: "bbbb111122223333", + "precise.start_ts": 100, + "precise.finish_ts": 110, + }); + const turns = extractTurns([span1, span2]); + expect(turns[0].started).toBe(100); + expect(turns[1].started).toBe(200); + }); + + test("handles filtered content", () => { + const span = makeSpan({ + "gen_ai.input.messages": "[Filtered]", + "gen_ai.output.messages": "[Filtered]", + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBe("[Filtered]"); + expect(turns[0].assistantContent).toBe("[Filtered]"); + }); + + test("handles null content messages gracefully", () => { + const span = makeSpan({ + "gen_ai.input.messages": JSON.stringify({ + messages: [{ role: "assistant", content: null }], + }), + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBeNull(); + }); +}); + +describe("buildTranscriptResult", () => { + test("builds result with span data", () => { + const spans = [makeSpan()]; + const result = buildTranscriptResult("conv-123", "my-org", spans); + expect(result.conversationId).toBe("conv-123"); + expect(result.org).toBe("my-org"); + expect(result.spanCount).toBe(1); + expect(result.totalTokens).toBe(100); + expect(result.projects).toEqual(["my-project"]); + expect(result.startTimestamp).toBe(1_716_500_000); + expect(result.endTimestamp).toBe(1_716_500_010); + }); + + test("returns zero timestamps for empty spans", () => { + const result = buildTranscriptResult("conv-123", "my-org", []); + expect(result.startTimestamp).toBe(0); + expect(result.endTimestamp).toBe(0); + expect(result.spanCount).toBe(0); + }); + + test("deduplicates and sorts projects", () => { + const spans = [ + makeSpan({ project: "b-project" }), + makeSpan({ span_id: "cc11223344556677", project: "a-project" }), + makeSpan({ span_id: "dd11223344556677", project: "b-project" }), + ]; + const result = buildTranscriptResult("conv-123", "my-org", spans); + expect(result.projects).toEqual(["a-project", "b-project"]); + }); +}); + +describe("formatTranscriptResult", () => { + test("shows empty message when no spans found", () => { + const result: TranscriptResult = { + conversationId: "conv-123", + org: "my-org", + turns: [], + totalTokens: 0, + spanCount: 0, + projects: [], + startTimestamp: 0, + endTimestamp: 0, + }; + const output = formatTranscriptResult(result); + expect(output).toContain("No spans found"); + expect(output).toContain("conv-123"); + }); + + test("renders transcript header and turns", () => { + const spans = [makeSpan()]; + const transcript = buildTranscriptResult("conv-123", "my-org", spans); + const output = formatTranscriptResult(transcript); + expect(output).toContain("AI Conversation: conv-123"); + expect(output).toContain("my-org"); + expect(output).toContain("my-project"); + expect(output).toContain("[user]"); + expect(output).toContain("[assistant]"); + expect(output).toContain("Turn 1"); + }); + + test("shows truncation warning", () => { + const result: TranscriptResult = { + conversationId: "conv-123", + org: "my-org", + turns: [], + totalTokens: 0, + spanCount: 1, + projects: [], + startTimestamp: 100, + endTimestamp: 200, + truncated: true, + }; + const output = formatTranscriptResult(result); + expect(output).toContain("truncated"); + }); + + test("preserves newlines in multi-line content", () => { + const span = makeSpan({ + "gen_ai.output.messages": JSON.stringify({ + messages: [ + { role: "assistant", content: "line one\nline two\nline three" }, + ], + }), + }); + const transcript = buildTranscriptResult("conv-123", "my-org", [span]); + const output = formatTranscriptResult(transcript); + expect(output).toContain("line one"); + expect(output).toContain("line two"); + expect(output).toContain("line three"); + }); +}); From d1587113e3240f27a4c62fc1608f59009f7295e7 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 10:22:56 -0400 Subject: [PATCH 14/17] test: add tests for conversation commands and API module - list command: org resolution, query/time params, pagination hints, empty results (14 tests) - view command: positional parsing, resolveOrg fallback, truncation, errors (11 tests) - API module: listConversations params/pagination, getConversationSpans multi-page/truncation (20 tests) Co-Authored-By: Claude Opus 4.6 (1M context) --- test/commands/conversation/list.test.ts | 460 ++++++++++++++++++++++++ test/commands/conversation/view.test.ts | 357 ++++++++++++++++++ test/lib/api/conversations.test.ts | 366 +++++++++++++++++++ 3 files changed, 1183 insertions(+) create mode 100644 test/commands/conversation/list.test.ts create mode 100644 test/commands/conversation/view.test.ts create mode 100644 test/lib/api/conversations.test.ts diff --git a/test/commands/conversation/list.test.ts b/test/commands/conversation/list.test.ts new file mode 100644 index 000000000..7d8738e6e --- /dev/null +++ b/test/commands/conversation/list.test.ts @@ -0,0 +1,460 @@ +/** + * Conversation List Command Tests + * + * Tests for the `sentry conversation list` command func() body, covering: + * - Organization resolution from positional arg + * - Organization resolution via resolveOrg fallback + * - Error when org cannot be resolved + * - Yielding CommandOutput with conversation data + * - Query filter passthrough + * - Time params passthrough + * - Pagination hints with -q flag preserved + * - Empty result handling + * + * Uses spyOn mocking to avoid real HTTP calls or database access. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { listCommand } from "../../../src/commands/conversation/list.js"; + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; + +vi.mock("../../../src/lib/db/auth.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as dbAuth from "../../../src/lib/db/auth.js"; + +vi.mock("../../../src/lib/polling.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as polling from "../../../src/lib/polling.js"; + +vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +vi.mock("../../../src/lib/db/pagination.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as paginationDb from "../../../src/lib/db/pagination.js"; +import { parsePeriod } from "../../../src/lib/time-range.js"; +import type { ConversationListItem } from "../../../src/types/conversation.js"; + +// ============================================================================ +// Helpers +// ============================================================================ + +const ORG = "test-org"; + +function createMockContext() { + const stdoutWrite = vi.fn(() => true); + const stderrWrite = vi.fn(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd: "/tmp", + }, + stdoutWrite, + stderrWrite, + }; +} + +/** No-op setMessage callback for withProgress mock */ +function noop() { + // no-op for test +} + +/** Passthrough mock for `withProgress` — bypasses spinner, calls fn directly */ +function mockWithProgress( + _opts: unknown, + fn: (setMessage: () => void) => unknown +) { + return fn(noop); +} + +function makeConversation( + overrides: Partial = {} +): ConversationListItem { + return { + conversationId: "conv-abc-123", + flow: ["agent"], + errors: 0, + llmCalls: 5, + toolCalls: 3, + totalTokens: 500, + totalCost: 0.01, + startTimestamp: 1_716_500_000, + endTimestamp: 1_716_500_060, + traceCount: 1, + traceIds: ["aaaa1111bbbb2222cccc3333dddd4444"], + firstInput: "Hello world", + lastOutput: "Goodbye", + user: { + id: "1", + email: "test@example.com", + username: "testuser", + ip_address: null, + }, + toolNames: ["search"], + toolErrors: 0, + ...overrides, + }; +} + +const sampleConversations: ConversationListItem[] = [ + makeConversation(), + makeConversation({ + conversationId: "conv-def-456", + firstInput: "Second conversation", + totalTokens: 1200, + }), +]; + +const JSON_FLAGS = { + limit: 25, + json: true, + fresh: false, + period: parsePeriod("7d"), +} as const; + +const HUMAN_FLAGS = { + limit: 25, + json: false, + fresh: false, + period: parsePeriod("7d"), +} as const; + +// ============================================================================ +// Auth setup +// ============================================================================ + +let getAuthConfigSpy: ReturnType; + +beforeEach(() => { + getAuthConfigSpy = vi.spyOn(dbAuth, "getAuthConfig").mockReturnValue({ + token: "sntrys_test", + source: "oauth" as const, + }); +}); + +afterEach(() => { + getAuthConfigSpy.mockRestore(); +}); + +// ============================================================================ +// Tests +// ============================================================================ + +describe("listCommand.func", () => { + let listConversationsSpy: ReturnType; + let resolveOrgSpy: ReturnType; + let withProgressSpy: ReturnType; + let resolveCursorSpy: ReturnType; + let advancePaginationStateSpy: ReturnType; + let hasPreviousPageSpy: ReturnType; + + beforeEach(() => { + listConversationsSpy = vi.spyOn(apiClient, "listConversations"); + resolveOrgSpy = vi.spyOn(resolveTarget, "resolveOrg"); + withProgressSpy = vi + .spyOn(polling, "withProgress") + .mockImplementation(mockWithProgress); + resolveCursorSpy = vi.spyOn(paginationDb, "resolveCursor").mockReturnValue({ + cursor: undefined, + direction: "next" as const, + }); + advancePaginationStateSpy = vi + .spyOn(paginationDb, "advancePaginationState") + .mockReturnValue(undefined); + hasPreviousPageSpy = vi + .spyOn(paginationDb, "hasPreviousPage") + .mockReturnValue(false); + }); + + afterEach(() => { + listConversationsSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + withProgressSpy.mockRestore(); + resolveCursorSpy.mockRestore(); + advancePaginationStateSpy.mockRestore(); + hasPreviousPageSpy.mockRestore(); + }); + + test("resolves org from positional arg", async () => { + listConversationsSpy.mockResolvedValue({ + data: sampleConversations, + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, JSON_FLAGS, ORG); + + // resolveOrg receives the positional org directly + expect(resolveOrgSpy).toHaveBeenCalledWith( + expect.objectContaining({ org: ORG }) + ); + // listConversations called with the resolved org + expect(listConversationsSpy).toHaveBeenCalledWith(ORG, expect.any(Object)); + }); + + test("resolves org via resolveOrg when no positional", async () => { + resolveOrgSpy.mockResolvedValue({ org: "auto-org" }); + listConversationsSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, JSON_FLAGS, undefined); + + expect(resolveOrgSpy).toHaveBeenCalledWith( + expect.objectContaining({ org: undefined }) + ); + expect(listConversationsSpy).toHaveBeenCalledWith( + "auto-org", + expect.any(Object) + ); + }); + + test("throws error when org cannot be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + + await expect(func.call(context, HUMAN_FLAGS, undefined)).rejects.toThrow( + "Could not determine organization" + ); + }); + + test("yields CommandOutput with conversation data (JSON)", async () => { + listConversationsSpy.mockResolvedValue({ + data: sampleConversations, + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, JSON_FLAGS, ORG); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed.data).toHaveLength(2); + expect(parsed.data[0].conversationId).toBe("conv-abc-123"); + }); + + test("yields human output with conversation table", async () => { + listConversationsSpy.mockResolvedValue({ + data: sampleConversations, + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, HUMAN_FLAGS, ORG); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("conv-abc-123"); + expect(output).toContain(ORG); + }); + + test("passes query filter to API", async () => { + listConversationsSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { ...JSON_FLAGS, query: "has:errors" }, ORG); + + expect(listConversationsSpy).toHaveBeenCalledWith( + ORG, + expect.objectContaining({ query: "has:errors" }) + ); + }); + + test("passes time params to API", async () => { + listConversationsSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { ...JSON_FLAGS, period: parsePeriod("24h") }, + ORG + ); + + expect(listConversationsSpy).toHaveBeenCalledWith( + ORG, + expect.objectContaining({ statsPeriod: "24h" }) + ); + }); + + test("passes limit to API", async () => { + listConversationsSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { ...JSON_FLAGS, limit: 50 }, ORG); + + expect(listConversationsSpy).toHaveBeenCalledWith( + ORG, + expect.objectContaining({ limit: 50 }) + ); + }); + + test("returns pagination hints with -q flag preserved", async () => { + listConversationsSpy.mockResolvedValue({ + data: sampleConversations, + nextCursor: "next-cursor-abc", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { ...HUMAN_FLAGS, query: "has:errors" }, ORG); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + // The hint should include the -q flag for navigation commands + expect(output).toContain("-c next"); + expect(output).toContain('-q "has:errors"'); + }); + + test("handles empty results (human mode)", async () => { + listConversationsSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, HUMAN_FLAGS, ORG); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No AI conversations found"); + }); + + test("handles empty results with hasMore (page boundary)", async () => { + listConversationsSpy.mockResolvedValue({ + data: [], + nextCursor: "some-cursor", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, HUMAN_FLAGS, ORG); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No conversations on this page"); + }); + + test("JSON output includes hasMore=true when nextCursor exists", async () => { + listConversationsSpy.mockResolvedValue({ + data: sampleConversations, + nextCursor: "cursor-123", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, JSON_FLAGS, ORG); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(true); + expect(parsed.nextCursor).toBe("cursor-123"); + }); + + test("JSON output includes hasMore=false when no nextCursor", async () => { + listConversationsSpy.mockResolvedValue({ + data: sampleConversations, + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, JSON_FLAGS, ORG); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(false); + }); + + test("advances pagination state after fetch", async () => { + listConversationsSpy.mockResolvedValue({ + data: sampleConversations, + nextCursor: "next-cursor", + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, JSON_FLAGS, ORG); + + expect(advancePaginationStateSpy).toHaveBeenCalledWith( + "conversation-list", + expect.any(String), + "next", + "next-cursor" + ); + }); +}); diff --git a/test/commands/conversation/view.test.ts b/test/commands/conversation/view.test.ts new file mode 100644 index 000000000..350cd94ea --- /dev/null +++ b/test/commands/conversation/view.test.ts @@ -0,0 +1,357 @@ +/** + * Conversation View Command Tests + * + * Tests for the `sentry conversation view` command func() body, covering: + * - Resolving org + conversationId from two positionals + * - Resolving org via resolveOrg with single positional (conversation-id only) + * - Throwing error when no args provided + * - Throwing ContextError when org cannot be resolved + * - Yielding CommandOutput with transcript result + * - Setting truncated flag from API response + * + * Uses spyOn mocking to avoid real HTTP calls or database access. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { viewCommand } from "../../../src/commands/conversation/view.js"; + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; + +vi.mock("../../../src/lib/db/auth.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as dbAuth from "../../../src/lib/db/auth.js"; + +vi.mock("../../../src/lib/polling.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as polling from "../../../src/lib/polling.js"; + +vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v) : v, + ]) + ); +}); + +import { ContextError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import type { AIConversationSpan } from "../../../src/types/conversation.js"; + +// ============================================================================ +// Helpers +// ============================================================================ + +const ORG = "test-org"; +const CONVERSATION_ID = "conv-abc-123"; + +function createMockContext() { + const stdoutWrite = vi.fn(() => true); + const stderrWrite = vi.fn(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd: "/tmp", + }, + stdoutWrite, + stderrWrite, + }; +} + +/** No-op setMessage callback for withProgress mock */ +function noop() { + // no-op for test +} + +/** Passthrough mock for `withProgress` — bypasses spinner, calls fn directly */ +function mockWithProgress( + _opts: unknown, + fn: (setMessage: () => void) => unknown +) { + return fn(noop); +} + +function makeSpan( + overrides: Partial = {} +): AIConversationSpan { + return { + "gen_ai.conversation.id": CONVERSATION_ID, + span_id: "aabb112233445566", + trace: "00112233445566778899aabbccddeeff", + project: "my-project", + "project.id": 42, + "span.name": "gen_ai.invoke_agent", + "span.status": "ok", + "precise.start_ts": 1_716_500_000, + "precise.finish_ts": 1_716_500_010, + "gen_ai.operation.type": "ai_client", + "gen_ai.input.messages": '{"messages":[{"role":"user","content":"hi"}]}', + "gen_ai.output.messages": + '{"messages":[{"role":"assistant","content":"hello"}]}', + "gen_ai.usage.total_tokens": "100", + "gen_ai.request.model": "gpt-4", + "gen_ai.response.model": "gpt-4", + ...overrides, + }; +} + +const sampleSpans: AIConversationSpan[] = [ + makeSpan(), + makeSpan({ + span_id: "ccdd112233445566", + "precise.start_ts": 1_716_500_020, + "precise.finish_ts": 1_716_500_030, + "gen_ai.input.messages": + '{"messages":[{"role":"user","content":"what is 2+2?"}]}', + "gen_ai.output.messages": + '{"messages":[{"role":"assistant","content":"4"}]}', + }), +]; + +const JSON_FLAGS = { json: true, fresh: false } as const; +const HUMAN_FLAGS = { json: false, fresh: false } as const; + +// ============================================================================ +// Auth setup +// ============================================================================ + +let getAuthConfigSpy: ReturnType; + +beforeEach(() => { + getAuthConfigSpy = vi.spyOn(dbAuth, "getAuthConfig").mockReturnValue({ + token: "sntrys_test", + source: "oauth" as const, + }); +}); + +afterEach(() => { + getAuthConfigSpy.mockRestore(); +}); + +// ============================================================================ +// Tests +// ============================================================================ + +describe("viewCommand.func", () => { + let getConversationSpansSpy: ReturnType; + let resolveOrgSpy: ReturnType; + let withProgressSpy: ReturnType; + + beforeEach(() => { + getConversationSpansSpy = vi.spyOn(apiClient, "getConversationSpans"); + resolveOrgSpy = vi.spyOn(resolveTarget, "resolveOrg"); + withProgressSpy = vi + .spyOn(polling, "withProgress") + .mockImplementation(mockWithProgress); + }); + + afterEach(() => { + getConversationSpansSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + withProgressSpy.mockRestore(); + }); + + test("resolves org + conversationId from two positionals", async () => { + getConversationSpansSpy.mockResolvedValue({ + spans: sampleSpans, + truncated: false, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, JSON_FLAGS, ORG, CONVERSATION_ID); + + // Should NOT call resolveOrg when org is given explicitly + expect(resolveOrgSpy).not.toHaveBeenCalled(); + // Should call getConversationSpans with the explicit org and conversation ID + expect(getConversationSpansSpy).toHaveBeenCalledWith(ORG, CONVERSATION_ID); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.conversationId).toBe(CONVERSATION_ID); + }); + + test("resolves org via resolveOrg with single positional (conversation-id only)", async () => { + resolveOrgSpy.mockResolvedValue({ org: "auto-org" }); + getConversationSpansSpy.mockResolvedValue({ + spans: sampleSpans, + truncated: false, + }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + // Only conversation ID provided — org should be auto-resolved + await func.call(context, JSON_FLAGS, CONVERSATION_ID); + + expect(resolveOrgSpy).toHaveBeenCalled(); + expect(getConversationSpansSpy).toHaveBeenCalledWith( + "auto-org", + CONVERSATION_ID + ); + }); + + test("throws error when no args provided", async () => { + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, HUMAN_FLAGS, undefined as unknown as string) + ).rejects.toThrow("Missing conversation ID"); + }); + + test("throws ContextError when org cannot be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, HUMAN_FLAGS, CONVERSATION_ID) + ).rejects.toThrow(ContextError); + }); + + test("ContextError mentions Organization", async () => { + resolveOrgSpy.mockResolvedValue(null); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + try { + await func.call(context, HUMAN_FLAGS, CONVERSATION_ID); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("organization"); + } + }); + + test("yields CommandOutput with transcript result (JSON)", async () => { + getConversationSpansSpy.mockResolvedValue({ + spans: sampleSpans, + truncated: false, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, JSON_FLAGS, ORG, CONVERSATION_ID); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.conversationId).toBe(CONVERSATION_ID); + expect(parsed.org).toBe(ORG); + expect(parsed.spanCount).toBe(2); + expect(parsed.turns).toBeDefined(); + expect(Array.isArray(parsed.turns)).toBe(true); + }); + + test("yields human output with transcript", async () => { + getConversationSpansSpy.mockResolvedValue({ + spans: sampleSpans, + truncated: false, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, HUMAN_FLAGS, ORG, CONVERSATION_ID); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain(CONVERSATION_ID); + expect(output).toContain(ORG); + }); + + test("sets truncated flag from API response (true)", async () => { + getConversationSpansSpy.mockResolvedValue({ + spans: sampleSpans, + truncated: true, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, JSON_FLAGS, ORG, CONVERSATION_ID); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.truncated).toBe(true); + }); + + test("sets truncated flag from API response (false)", async () => { + getConversationSpansSpy.mockResolvedValue({ + spans: sampleSpans, + truncated: false, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, JSON_FLAGS, ORG, CONVERSATION_ID); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + // When truncated is false, buildTranscriptResult does not set it, + // and the command sets result.truncated = false + expect(parsed.truncated).toBeFalsy(); + }); + + test("shows truncation warning in human output", async () => { + getConversationSpansSpy.mockResolvedValue({ + spans: sampleSpans, + truncated: true, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, HUMAN_FLAGS, ORG, CONVERSATION_ID); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("truncated"); + }); + + test("handles empty spans", async () => { + getConversationSpansSpy.mockResolvedValue({ + spans: [], + truncated: false, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, HUMAN_FLAGS, ORG, CONVERSATION_ID); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No spans found"); + }); +}); diff --git a/test/lib/api/conversations.test.ts b/test/lib/api/conversations.test.ts new file mode 100644 index 000000000..a4d2b6bc1 --- /dev/null +++ b/test/lib/api/conversations.test.ts @@ -0,0 +1,366 @@ +/** + * Conversations API Tests + * + * Tests for `listConversations` and `getConversationSpans` in + * src/lib/api/conversations.ts, covering: + * - listConversations sends correct params + * - listConversations parses link header for pagination + * - getConversationSpans paginates through multiple pages + * - getConversationSpans returns truncated=true when pagination limit reached + * - getConversationSpans returns truncated=false when all pages fetched + * + * Mocks fetch at the global level like other API test files (traces, replays). + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + getConversationSpans, + listConversations, +} from "../../../src/lib/api/conversations.js"; +import { MAX_PAGINATION_PAGES } from "../../../src/lib/api/infrastructure.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +// ============================================================================ +// Helpers +// ============================================================================ + +const ORG = "test-org"; + +/** Helper to mock fetch with a single OK response */ +function mockOk( + body: unknown, + headers: Record = {} +): { getCapturedUrl: () => string; getCapturedMethod: () => string } { + let capturedUrl = ""; + let capturedMethod = ""; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + capturedMethod = req.method; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json", ...headers }, + }); + }); + + return { + getCapturedUrl: () => capturedUrl, + getCapturedMethod: () => capturedMethod, + }; +} + +/** + * Helper to mock sequential fetch responses for multi-page tests. + * Each call to fetch returns the next response in the queue. + */ +function mockSequential( + responses: Array<{ body: unknown; headers?: Record }> +): { getCapturedUrls: () => string[] } { + const capturedUrls: string[] = []; + let callIndex = 0; + + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrls.push(req.url); + + const resp = responses[callIndex]!; + callIndex += 1; + + return new Response(JSON.stringify(resp.body), { + status: 200, + headers: { "Content-Type": "application/json", ...resp.headers }, + }); + }); + + return { getCapturedUrls: () => capturedUrls }; +} + +/** Build a Link header with next cursor */ +function linkHeader(cursor: string, results = "true"): Record { + return { + Link: `; rel="next"; results="${results}"; cursor="${cursor}"`, + }; +} + +// ============================================================================ +// Setup +// ============================================================================ + +useTestConfigDir("conversations-api-test-"); + +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +// ============================================================================ +// listConversations +// ============================================================================ + +describe("listConversations", () => { + test("hits /organizations/{org}/ai-conversations/ with GET", async () => { + const { getCapturedUrl, getCapturedMethod } = mockOk([]); + + await listConversations(ORG); + + expect(getCapturedMethod()).toBe("GET"); + expect(getCapturedUrl()).toContain( + `/api/0/organizations/${ORG}/ai-conversations/` + ); + }); + + test("sends per_page=10 by default", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG); + + expect(getCapturedUrl()).toContain("per_page=10"); + }); + + test("passes custom limit as per_page", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { limit: 50 }); + + expect(getCapturedUrl()).toContain("per_page=50"); + }); + + test("passes query param", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { query: "has:errors" }); + + expect(decodeURIComponent(getCapturedUrl())).toContain("query=has:errors"); + }); + + test("passes statsPeriod param", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { statsPeriod: "24h" }); + + expect(getCapturedUrl()).toContain("statsPeriod=24h"); + }); + + test("passes start and end params", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { + start: "2024-01-01T00:00:00", + end: "2024-01-31T23:59:59", + }); + + const url = getCapturedUrl(); + expect(url).toContain("start="); + expect(url).toContain("end="); + }); + + test("passes cursor param", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { cursor: "1735689600000:0:0" }); + + expect(getCapturedUrl()).toContain( + `cursor=${encodeURIComponent("1735689600000:0:0")}` + ); + }); + + test("passes project param", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { project: "my-project" }); + + expect(getCapturedUrl()).toContain("project=my-project"); + }); + + test("does not include undefined optional params", async () => { + const { getCapturedUrl } = mockOk([]); + + await listConversations(ORG, { limit: 10 }); + + const url = getCapturedUrl(); + expect(url).not.toContain("query="); + expect(url).not.toContain("statsPeriod="); + expect(url).not.toContain("start="); + expect(url).not.toContain("end="); + expect(url).not.toContain("cursor="); + expect(url).not.toContain("project="); + }); + + test("parses link header for next cursor", async () => { + mockOk([], linkHeader("1735689600000:0:0")); + + const result = await listConversations(ORG); + + expect(result.nextCursor).toBe("1735689600000:0:0"); + }); + + test("returns undefined nextCursor when no more pages", async () => { + mockOk([]); + + const result = await listConversations(ORG); + + expect(result.nextCursor).toBeUndefined(); + }); + + test("returns data from response", async () => { + const conversations = [ + { + conversationId: "conv-abc", + flow: [], + errors: 0, + llmCalls: 1, + toolCalls: 0, + totalTokens: 100, + totalCost: 0, + startTimestamp: 1_716_500_000, + endTimestamp: 1_716_500_060, + traceCount: 1, + traceIds: [], + firstInput: "hello", + lastOutput: "hi", + toolNames: [], + toolErrors: 0, + }, + ]; + mockOk(conversations); + + const result = await listConversations(ORG); + + expect(result.data).toHaveLength(1); + expect(result.data[0].conversationId).toBe("conv-abc"); + }); +}); + +// ============================================================================ +// getConversationSpans +// ============================================================================ + +describe("getConversationSpans", () => { + const CONV_ID = "conv-abc-123"; + + /** Minimal valid span for schema validation */ + function makeSpan(id: string) { + return { + "gen_ai.conversation.id": CONV_ID, + span_id: id, + trace: "00112233445566778899aabbccddeeff", + project: "my-project", + "project.id": 42, + "span.name": "gen_ai.invoke_agent", + "span.status": "ok", + "precise.start_ts": 1_716_500_000, + "precise.finish_ts": 1_716_500_010, + "gen_ai.operation.type": "ai_client", + }; + } + + test("hits /organizations/{org}/ai-conversations/{conversationId}/ with GET", async () => { + const { getCapturedUrl, getCapturedMethod } = mockOk([]); + + await getConversationSpans(ORG, CONV_ID); + + expect(getCapturedMethod()).toBe("GET"); + expect(getCapturedUrl()).toContain( + `/api/0/organizations/${ORG}/ai-conversations/${CONV_ID}/` + ); + }); + + test("sends default per_page=1000 and statsPeriod=30d", async () => { + const { getCapturedUrl } = mockOk([]); + + await getConversationSpans(ORG, CONV_ID); + + const url = getCapturedUrl(); + expect(url).toContain("per_page=1000"); + expect(url).toContain("statsPeriod=30d"); + }); + + test("returns spans from a single page", async () => { + const spans = [makeSpan("span-1-aabb1122")]; + mockOk(spans); + + const result = await getConversationSpans(ORG, CONV_ID); + + expect(result.spans).toHaveLength(1); + expect(result.spans[0].span_id).toBe("span-1-aabb1122"); + expect(result.truncated).toBe(false); + }); + + test("paginates through multiple pages", async () => { + const { getCapturedUrls } = mockSequential([ + { + body: [makeSpan("span-page-1-aa11")], + headers: linkHeader("page-2-cursor"), + }, + { + body: [makeSpan("span-page-2-bb22")], + // No Link header → last page + }, + ]); + + const result = await getConversationSpans(ORG, CONV_ID); + + expect(result.spans).toHaveLength(2); + expect(result.spans[0].span_id).toBe("span-page-1-aa11"); + expect(result.spans[1].span_id).toBe("span-page-2-bb22"); + expect(result.truncated).toBe(false); + expect(getCapturedUrls()).toHaveLength(2); + // Second request should include cursor + expect(getCapturedUrls()[1]).toContain("cursor=page-2-cursor"); + }); + + test("returns truncated=true when pagination limit reached", async () => { + // Create responses that always have more pages + const responses = Array.from({ length: MAX_PAGINATION_PAGES }, (_, i) => ({ + body: [makeSpan(`span-${i}-aabb1122`)], + headers: linkHeader("always-more-cursor"), + })); + + mockSequential(responses); + + const result = await getConversationSpans(ORG, CONV_ID); + + expect(result.truncated).toBe(true); + expect(result.spans).toHaveLength(MAX_PAGINATION_PAGES); + }); + + test("returns truncated=false when all pages fetched before limit", async () => { + mockOk([makeSpan("only-page-span1")]); + + const result = await getConversationSpans(ORG, CONV_ID); + + expect(result.truncated).toBe(false); + }); + + test("uses custom options when provided", async () => { + const { getCapturedUrl } = mockOk([]); + + await getConversationSpans(ORG, CONV_ID, { + statsPeriod: "7d", + project: "my-project", + perPage: 500, + }); + + const url = getCapturedUrl(); + expect(url).toContain("per_page=500"); + expect(url).toContain("statsPeriod=7d"); + expect(url).toContain("project=my-project"); + }); + + test("encodes conversationId in URL", async () => { + const { getCapturedUrl } = mockOk([]); + const specialId = "conv/with special"; + + await getConversationSpans(ORG, specialId); + + expect(getCapturedUrl()).toContain( + `/ai-conversations/${encodeURIComponent(specialId)}/` + ); + }); +}); From 37a68cf6a856cd74d659e45e08b487f6b8d9eb48 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 10:34:35 -0400 Subject: [PATCH 15/17] fix: sanitize model/agent/status fields, add formatter tests - Sanitize turn.model, turn.agentName, and tc.status in terminal output - Extract appendContentBlock to reduce cognitive complexity - Add 19 more formatter tests covering content extraction edge cases, tool calls, metadata rendering, and operation type detection Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/formatters/conversation.ts | 32 ++-- test/lib/formatters/conversation.test.ts | 211 +++++++++++++++++++++++ 2 files changed, 229 insertions(+), 14 deletions(-) diff --git a/src/lib/formatters/conversation.ts b/src/lib/formatters/conversation.ts index ec27726d0..3566a32ac 100644 --- a/src/lib/formatters/conversation.ts +++ b/src/lib/formatters/conversation.ts @@ -289,10 +289,22 @@ function formatEpoch(ts: number): string { return new Date(ts * 1000).toISOString(); } +function appendContentBlock( + lines: string[], + label: string, + content: string +): void { + lines.push(` [${label}]`); + for (const line of truncate(content, 600).split("\n")) { + lines.push(` ${sanitize(line)}`); + } + lines.push(""); +} + function formatTurnHuman(turn: ConversationTurn): string { const meta = [ - turn.model, - turn.agentName, + turn.model ? sanitize(turn.model) : null, + turn.agentName ? sanitize(turn.agentName) : null, turn.totalTokens > 0 ? `${turn.totalTokens} tokens` : null, formatDuration(turn.durationMs), ] @@ -307,25 +319,17 @@ function formatTurnHuman(turn: ConversationTurn): string { lines.push(""); if (turn.userContent) { - lines.push(" [user]"); - for (const line of truncate(turn.userContent, 600).split("\n")) { - lines.push(` ${sanitize(line)}`); - } - lines.push(""); + appendContentBlock(lines, "user", turn.userContent); } - if (turn.assistantContent) { - lines.push(" [assistant]"); - for (const line of truncate(turn.assistantContent, 600).split("\n")) { - lines.push(` ${sanitize(line)}`); - } - lines.push(""); + appendContentBlock(lines, "assistant", turn.assistantContent); } if (turn.toolCalls.length > 0) { lines.push(" [tools]"); for (const tc of turn.toolCalls) { - const status = tc.status && tc.status !== "ok" ? ` (${tc.status})` : ""; + const status = + tc.status && tc.status !== "ok" ? ` (${sanitize(tc.status)})` : ""; lines.push( ` • ${sanitize(tc.name)} — ${formatDuration(tc.durationMs)}${status}` ); diff --git a/test/lib/formatters/conversation.test.ts b/test/lib/formatters/conversation.test.ts index 27edf2428..a776ede6f 100644 --- a/test/lib/formatters/conversation.test.ts +++ b/test/lib/formatters/conversation.test.ts @@ -246,4 +246,215 @@ describe("formatTranscriptResult", () => { expect(output).toContain("line two"); expect(output).toContain("line three"); }); + + test("renders tool calls in turns", () => { + const aiSpan = makeSpan({ + "precise.start_ts": 100, + "precise.finish_ts": 110, + }); + const toolSpan = makeSpan({ + span_id: "tool111122223333", + "gen_ai.operation.type": "tool", + "span.name": "gen_ai.execute_tool", + "gen_ai.tool.name": "web_search", + "precise.start_ts": 105, + "precise.finish_ts": 106, + "span.status": "ok", + }); + const transcript = buildTranscriptResult("conv-123", "my-org", [ + aiSpan, + toolSpan, + ]); + const output = formatTranscriptResult(transcript); + expect(output).toContain("[tools]"); + expect(output).toContain("web_search"); + }); + + test("renders tool call with non-ok status", () => { + const aiSpan = makeSpan({ + "precise.start_ts": 100, + "precise.finish_ts": 110, + }); + const toolSpan = makeSpan({ + span_id: "tool111122223333", + "gen_ai.operation.type": "tool", + "span.name": "gen_ai.execute_tool", + "gen_ai.tool.name": "db_query", + "precise.start_ts": 105, + "precise.finish_ts": 106, + "span.status": "internal_error", + }); + const transcript = buildTranscriptResult("conv-123", "my-org", [ + aiSpan, + toolSpan, + ]); + const output = formatTranscriptResult(transcript); + expect(output).toContain("(internal_error)"); + }); + + test("renders model and agent name in turn metadata", () => { + const span = makeSpan({ + "gen_ai.response.model": "claude-3", + "gen_ai.agent.name": "my-agent", + }); + const transcript = buildTranscriptResult("conv-123", "my-org", [span]); + const output = formatTranscriptResult(transcript); + expect(output).toContain("claude-3"); + expect(output).toContain("my-agent"); + }); + + test("renders token count in metadata", () => { + const span = makeSpan({ "gen_ai.usage.total_tokens": "1500" }); + const transcript = buildTranscriptResult("conv-123", "my-org", [span]); + const output = formatTranscriptResult(transcript); + expect(output).toContain("1500 tokens"); + }); +}); + +describe("extractTurns: content extraction edge cases", () => { + test("extracts from gen_ai.response.text fallback", () => { + const span = makeSpan({ + "gen_ai.output.messages": undefined, + "gen_ai.response.text": "fallback text", + }); + const turns = extractTurns([span]); + expect(turns[0].assistantContent).toBe("fallback text"); + }); + + test("extracts from gen_ai.response.object fallback", () => { + const span = makeSpan({ + "gen_ai.output.messages": undefined, + "gen_ai.response.text": undefined, + "gen_ai.response.object": '{"result": true}', + }); + const turns = extractTurns([span]); + expect(turns[0].assistantContent).toBe('{"result": true}'); + }); + + test("handles plain string input messages", () => { + const span = makeSpan({ + "gen_ai.input.messages": "plain string input", + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBe("plain string input"); + }); + + test("handles array of string messages", () => { + const span = makeSpan({ + "gen_ai.input.messages": JSON.stringify(["msg1", "msg2"]), + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBe("msg2"); + }); + + test("handles message with text field instead of content", () => { + const span = makeSpan({ + "gen_ai.input.messages": JSON.stringify({ + messages: [{ role: "user", text: "from text field" }], + }), + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBe("from text field"); + }); + + test("handles array content parts with text", () => { + const span = makeSpan({ + "gen_ai.output.messages": JSON.stringify({ + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "part one" }, { text: "part two" }], + }, + ], + }), + }); + const turns = extractTurns([span]); + expect(turns[0].assistantContent).toContain("part one"); + expect(turns[0].assistantContent).toContain("part two"); + }); + + test("handles no input or output messages", () => { + const span = makeSpan({ + "gen_ai.input.messages": undefined, + "gen_ai.request.messages": undefined, + "gen_ai.output.messages": undefined, + "gen_ai.response.text": undefined, + "gen_ai.response.object": undefined, + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBeNull(); + expect(turns[0].assistantContent).toBeNull(); + }); + + test("extracts from gen_ai.request.messages fallback", () => { + const span = makeSpan({ + "gen_ai.input.messages": undefined, + "gen_ai.request.messages": JSON.stringify({ + messages: [{ role: "user", content: "from request" }], + }), + }); + const turns = extractTurns([span]); + expect(turns[0].userContent).toBe("from request"); + }); + + test("handles numeric token values", () => { + const span = makeSpan({ + "gen_ai.usage.total_tokens": 42, + }); + const turns = extractTurns([span]); + expect(turns[0].totalTokens).toBe(42); + }); + + test("handles missing token values", () => { + const span = makeSpan({ + "gen_ai.usage.total_tokens": undefined, + }); + const turns = extractTurns([span]); + expect(turns[0].totalTokens).toBe(0); + }); + + test("detects operation type from span name when explicit type missing", () => { + const span = makeSpan({ + "gen_ai.operation.type": undefined, + "span.name": "gen_ai.execute_tool", + }); + const turns = extractTurns([span]); + expect(turns).toHaveLength(0); + }); + + test("detects agent operation type from span name", () => { + const span = makeSpan({ + "gen_ai.operation.type": undefined, + "span.name": "gen_ai.invoke_agent", + }); + const turns = extractTurns([span]); + expect(turns).toHaveLength(0); + }); + + test("ignores non-gen_ai span names", () => { + const span = makeSpan({ + "gen_ai.operation.type": undefined, + "span.name": "http.client", + }); + const turns = extractTurns([span]); + expect(turns).toHaveLength(0); + }); + + test("detects handoff operation type", () => { + const span = makeSpan({ + "gen_ai.operation.type": undefined, + "span.name": "gen_ai.handoff", + }); + const turns = extractTurns([span]); + expect(turns).toHaveLength(0); + }); + + test("falls back to ai_client for unknown gen_ai span names", () => { + const span = makeSpan({ + "gen_ai.operation.type": undefined, + "span.name": "gen_ai.something_new", + }); + const turns = extractTurns([span]); + expect(turns).toHaveLength(1); + }); }); From 9adf71261be1e47694b2057a1ac7bd716d160796 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 24 May 2026 10:50:23 -0400 Subject: [PATCH 16/17] fix: sanitize transcript header fields and update docs command name - Sanitize conversationId, org, and projects in formatTranscriptResult - Sanitize org in list command human output - Update docs fragment to use `conversation` instead of old `ai-conversations` - Fix inaccurate "List last 10" comment (default limit is 25) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/src/fragments/commands/conversation.md | 16 ++++++++-------- src/commands/conversation/list.ts | 5 +++-- src/lib/formatters/conversation.ts | 8 ++++---- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/src/fragments/commands/conversation.md b/docs/src/fragments/commands/conversation.md index 724f2ab76..3224b9054 100644 --- a/docs/src/fragments/commands/conversation.md +++ b/docs/src/fragments/commands/conversation.md @@ -6,28 +6,28 @@ ### List conversations ```bash -# List last 10 AI conversations -sentry ai-conversations list +# List recent AI conversations +sentry conversation list # Explicit organization -sentry ai-conversations list my-org +sentry conversation list my-org # Show more, last 24 hours -sentry ai-conversations list --limit 50 --period 24h +sentry conversation list --limit 50 --period 24h # Filter conversations -sentry ai-conversations list -q "has:errors" +sentry conversation list -q "has:errors" # Paginate through results -sentry ai-conversations list my-org -c next +sentry conversation list my-org -c next ``` ### View a conversation transcript ```bash # View full transcript -sentry ai-conversations view my-org conv-123 +sentry conversation view my-org conv-123 # JSON output -sentry ai-conversations view my-org conv-123 --json +sentry conversation view my-org conv-123 --json ``` diff --git a/src/commands/conversation/list.ts b/src/commands/conversation/list.ts index ddf0b8342..ab3076a15 100644 --- a/src/commands/conversation/list.ts +++ b/src/commands/conversation/list.ts @@ -15,6 +15,7 @@ import { } from "../../lib/db/pagination.js"; import { formatConversationTable } from "../../lib/formatters/conversation.js"; import { filterFields } from "../../lib/formatters/json.js"; +import { sanitize } from "../../lib/formatters/local.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { buildListCommand, @@ -71,7 +72,7 @@ function formatListHuman(result: ConversationListResult): string { ? "No conversations on this page." : "No AI conversations found."; } - return `AI conversations in ${org}:\n\n${formatConversationTable(conversations)}`; + return `AI conversations in ${sanitize(org)}:\n\n${formatConversationTable(conversations)}`; } function jsonTransform( @@ -100,7 +101,7 @@ export const listCommand = buildListCommand("conversation", { fullDescription: "List recent AI conversations from a Sentry organization.\n\n" + "Examples:\n" + - " sentry conversation list # List last 10 conversations\n" + + " sentry conversation list # List recent conversations\n" + " sentry conversation list my-org # Explicit org\n" + " sentry conversation list --limit 50 # Show more\n" + " sentry conversation list --period 24h # Last 24 hours\n" + diff --git a/src/lib/formatters/conversation.ts b/src/lib/formatters/conversation.ts index 3566a32ac..cff7c798c 100644 --- a/src/lib/formatters/conversation.ts +++ b/src/lib/formatters/conversation.ts @@ -354,14 +354,14 @@ export type TranscriptResult = { export function formatTranscriptResult(result: TranscriptResult): string { if (result.spanCount === 0) { - return `No spans found for conversation ${result.conversationId} in the last 30 days.`; + return `No spans found for conversation ${sanitize(result.conversationId)} in the last 30 days.`; } const header = [ - `AI Conversation: ${result.conversationId}`, + `AI Conversation: ${sanitize(result.conversationId)}`, "", - ` Org: ${result.org}`, - ` Projects: ${result.projects.join(", ") || "—"}`, + ` Org: ${sanitize(result.org)}`, + ` Projects: ${result.projects.map(sanitize).join(", ") || "—"}`, ` Started: ${formatEpoch(result.startTimestamp)}`, ` Ended: ${formatEpoch(result.endTimestamp)}`, ` Turns: ${result.turns.length}`, From eb249d0f47cc30a321aba7d38bf4a054fdc26649 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 24 May 2026 14:51:02 +0000 Subject: [PATCH 17/17] chore: regenerate docs --- .../sentry-cli/references/conversation.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/conversation.md b/plugins/sentry-cli/skills/sentry-cli/references/conversation.md index eeb19255f..e7c9bd031 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/conversation.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/conversation.md @@ -43,6 +43,25 @@ List recent AI conversations | `toolNames` | array | | | `toolErrors` | number | | +**Examples:** + +```bash +# List recent AI conversations +sentry conversation list + +# Explicit organization +sentry conversation list my-org + +# Show more, last 24 hours +sentry conversation list --limit 50 --period 24h + +# Filter conversations +sentry conversation list -q "has:errors" + +# Paginate through results +sentry conversation list my-org -c next +``` + ### `sentry conversation view ` View an AI conversation transcript @@ -50,4 +69,14 @@ View an AI conversation transcript **Flags:** - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +**Examples:** + +```bash +# View full transcript +sentry conversation view my-org conv-123 + +# JSON output +sentry conversation view my-org conv-123 --json +``` + All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.