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 f6fad5c42..4aa11e600 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -53,6 +53,7 @@ cli/ │ ├── commands/ # CLI commands │ │ ├── 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/conversation.md b/docs/src/fragments/commands/conversation.md new file mode 100644 index 000000000..3224b9054 --- /dev/null +++ b/docs/src/fragments/commands/conversation.md @@ -0,0 +1,33 @@ + + + +## Examples + +### List conversations + +```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 +``` + +### View a conversation transcript + +```bash +# View full transcript +sentry conversation view my-org conv-123 + +# JSON output +sentry conversation view my-org conv-123 --json +``` diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 942fa9876..35fddd557 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` +### Conversation + +List and view AI conversations + +- `sentry conversation list ` — List recent AI conversations +- `sentry conversation view ` — View an AI conversation transcript + +→ Full flags and examples: `references/conversation.md` + ### CLI CLI-related commands diff --git a/plugins/sentry-cli/skills/sentry-cli/references/conversation.md b/plugins/sentry-cli/skills/sentry-cli/references/conversation.md new file mode 100644 index 000000000..e7c9bd031 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/conversation.md @@ -0,0 +1,82 @@ +--- +name: sentry-cli-conversation +version: 0.35.0-dev.0 +description: List and view AI conversations +requires: + bins: ["sentry"] + auth: true +--- + +# Conversation Commands + +List and view AI conversations + +### `sentry conversation 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 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 + +**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. 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"); diff --git a/src/app.ts b/src/app.ts index 426b4fdc7..9423c824e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,6 +9,7 @@ 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"; @@ -83,6 +84,7 @@ const PLURAL_TO_SINGULAR: Record = { /** Top-level route map containing all CLI commands */ export const routes = buildRouteMap({ routes: { + conversation: conversationRoute, help: helpCommand, auth: authRoute, cli: cliRoute, diff --git a/src/commands/conversation/index.ts b/src/commands/conversation/index.ts new file mode 100644 index 000000000..1747880ad --- /dev/null +++ b/src/commands/conversation/index.ts @@ -0,0 +1,26 @@ +/** + * sentry conversation + * + * 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 conversationRoute = 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/conversation/list.ts b/src/commands/conversation/list.ts new file mode 100644 index 000000000..ab3076a15 --- /dev/null +++ b/src/commands/conversation/list.ts @@ -0,0 +1,213 @@ +/** + * sentry conversation list + * + * List recent AI conversations from Sentry projects. + */ + +import type { SentryContext } from "../../context.js"; +import { listConversations } from "../../lib/api-client.js"; +import { validateLimit } from "../../lib/arg-parsing.js"; +import { + advancePaginationState, + buildPaginationContextKey, + hasPreviousPage, + resolveCursor, +} 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, + 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/conversation.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 = "conversation list"; +const PAGINATION_KEY = "conversation-list"; +const DEFAULT_PERIOD = "7d"; + +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 ${sanitize(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("conversation", { + docs: { + brief: "List recent AI conversations", + fullDescription: + "List recent AI conversations from a Sentry organization.\n\n" + + "Examples:\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" + + ' sentry conversation list -q "has:errors" # Filter\n', + }, + output: { + human: formatListHuman, + jsonTransform, + schema: ConversationListItemSchema, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org", + brief: "Organization slug", + parse: String, + optional: true, + }, + ], + }, + flags: { + limit: { + kind: "parsed", + parse: parseLimit, + brief: `Number of conversations (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`, + default: String(LIST_DEFAULT_LIMIT), + }, + query: { + kind: "parsed", + parse: String, + brief: "Search query", + optional: true, + }, + period: LIST_PERIOD_FLAG, + }, + aliases: { + ...PERIOD_ALIASES, + n: "limit", + q: "query", + }, + }, + async *func(this: SentryContext, flags: ListFlags, target?: string) { + const { cwd } = this; + + const resolved = await resolveOrg({ org: target, cwd }); + if (!resolved) { + throw new Error( + `Could not determine organization. Pass it explicitly: sentry ${COMMAND_NAME} ` + ); + } + const org = resolved.org; + + const contextKey = buildPaginationContextKey("conversation", org, { + q: flags.query, + period: serializeTimeRange(flags.period), + }); + const { cursor, direction } = resolveCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); + + const timeParams = timeRangeToApiParams(flags.period); + + const { data: conversations, nextCursor } = await withProgress( + { + message: `Fetching conversations (up to ${flags.limit})...`, + json: flags.json, + }, + () => + listConversations(org, { + query: flags.query, + limit: flags.limit, + cursor, + ...timeParams, + }) + ); + + advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); + const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + const hasMore = !!nextCursor; + + yield new CommandOutput({ + conversations, + hasMore, + hasPrev, + nextCursor, + org, + }); + + const parts: string[] = []; + if (flags.query) { + parts.push(`-q "${flags.query}"`); + } + appendPeriodHint(parts, flags.period, DEFAULT_PERIOD); + const flagSuffix = parts.length > 0 ? ` ${parts.join(" ")}` : ""; + + return { + hint: paginationHint({ + hasMore, + hasPrev: !!hasPrev, + nextHint: `sentry conversation list ${org} -c next${flagSuffix}`, + prevHint: `sentry conversation list ${org} -c prev${flagSuffix}`, + }), + }; + }, +}); diff --git a/src/commands/conversation/view.ts b/src/commands/conversation/view.ts new file mode 100644 index 000000000..eb1c657f5 --- /dev/null +++ b/src/commands/conversation/view.ts @@ -0,0 +1,108 @@ +/** + * sentry conversation view + * + * View the transcript of a specific AI conversation. + */ + +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, + type TranscriptResult, +} from "../../lib/formatters/conversation.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; +}; + +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 conversation view my-org conv-123\n" + + " sentry conversation view my-org conv-123 --json\n", + }, + output: { + human: formatTranscriptResult, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org", + brief: "Organization slug (optional if auto-detected)", + parse: String, + optional: true, + }, + { + placeholder: "conversation-id", + brief: "AI conversation ID", + parse: String, + optional: true, + }, + ], + }, + flags: { + fresh: FRESH_FLAG, + }, + aliases: FRESH_ALIASES, + }, + async *func( + this: SentryContext, + flags: ViewFlags, + orgOrConversationId: string, + maybeConversationId?: string + ) { + applyFreshFlag(flags); + const { cwd } = this; + + let org: string; + let conversationId: string; + + if (maybeConversationId) { + org = orgOrConversationId; + conversationId = maybeConversationId; + } else if (orgOrConversationId) { + const resolved = await resolveOrg({ cwd }); + if (!resolved) { + throw new ContextError( + "Organization", + "sentry conversation view " + ); + } + org = resolved.org; + conversationId = orgOrConversationId; + } else { + throw new Error( + "Missing conversation ID. Usage: sentry conversation view [org] " + ); + } + + const { spans, truncated } = await withProgress( + { + message: "Fetching conversation spans...", + json: flags.json, + }, + () => getConversationSpans(org, conversationId) + ); + + const result = buildTranscriptResult(conversationId, org, spans); + result.truncated = truncated; + yield new CommandOutput(result); + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 02d37c76e..d469d5b77 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 + * - conversations: AI conversation listing and detail * - trials: product trial management * - users: current user info */ +export { + getConversationSpans, + listConversations, +} from "./api/conversations.js"; export { createDashboard, getDashboard, diff --git a/src/lib/api/conversations.ts b/src/lib/api/conversations.ts new file mode 100644 index 000000000..fbfce4d88 --- /dev/null +++ b/src/lib/api/conversations.ts @@ -0,0 +1,126 @@ +/** + * AI Conversations API functions + * + * Functions for listing and retrieving AI conversation data + * from the Sentry Explore conversations endpoints. + */ + +import { z } from "zod"; + +import { + type AIConversationSpan, + AIConversationSpanSchema, + type ConversationListItem, + ConversationListItemSchema, +} from "../../types/conversation.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.conversations"); + +export async function listConversations( + orgSlug: string, + options: { + query?: string; + 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.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, schema: z.array(ConversationListItemSchema) } + ); + + const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); + + return { data, nextCursor }; +} + +export async function getConversationSpans( + orgSlug: string, + conversationId: string, + options: { + statsPeriod?: string; + project?: string; + perPage?: number; + } = {} +): Promise<{ spans: AIConversationSpan[]; truncated: boolean }> { + const regionUrl = await resolveOrgRegion(orgSlug); + const pageSchema = z.array(AIConversationSpanSchema); + + 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 < MAX_PAGINATION_PAGES; page++) { + if (cursor) { + params.cursor = cursor; + } + + const { data, headers } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/ai-conversations/${encodeURIComponent(conversationId)}/`, + { params, schema: pageSchema } + ); + + spans.push(...data); + const parsed = parseLinkHeader(headers.get("link") ?? null); + cursor = parsed.nextCursor; + if (!cursor) { + break; + } + } + + const truncated = !!cursor; + if (truncated) { + log.warn( + `Pagination limit reached (${MAX_PAGINATION_PAGES} pages, ${spans.length} spans). Conversation transcript may be incomplete.` + ); + } + + return { spans, truncated }; +} diff --git a/src/lib/complete.ts b/src/lib/complete.ts index d383b1fd2..2d04e4a8d 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([ + "conversation list", + "conversation view", "org view", "release list", "release view", diff --git a/src/lib/formatters/conversation.ts b/src/lib/formatters/conversation.ts new file mode 100644 index 000000000..cff7c798c --- /dev/null +++ b/src/lib/formatters/conversation.ts @@ -0,0 +1,413 @@ +/** + * 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/conversation.js"; +import { sanitize } from "./local.js"; + +// --------------------------------------------------------------------------- +// List formatter +// --------------------------------------------------------------------------- + +function truncate(value: string, max = 60): string { + if (value.length <= max) { + return value; + } + return `${value.slice(0, max - 1)}…`; +} + +function formatTimestamp(epochSeconds: number): string { + if (epochSeconds === 0) { + return "—"; + } + return new Date(epochSeconds * 1000).toLocaleString(); +} + +export function formatConversationTable(items: ConversationListItem[]): string { + const rows = items.map((c) => { + const input = c.firstInput ? sanitize(truncate(c.firstInput)) : "—"; + const user = sanitize(c.user?.email ?? c.user?.username ?? "—"); + const time = formatTimestamp(c.startTimestamp); + 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 = + " ID Started Tokens Tools Errs User First Input"; + return [header, ...rows].join("\n"); +} + +// --------------------------------------------------------------------------- +// Transcript parsing (ported from sentry-mcp get-ai-conversation-details) +// --------------------------------------------------------------------------- + +export type ToolCall = { + name: string; + spanId: string; + timestamp: number; + durationMs: number; + status?: string | null; +}; + +export 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; + } + 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 raw = r.content ?? r.text; + if (raw === null || raw === undefined) { + return null; + } + const content = stringifyContent(raw); + 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 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"]; + 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 { + if (!Number.isFinite(ts) || ts === 0) { + return "—"; + } + 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 ? sanitize(turn.model) : null, + turn.agentName ? sanitize(turn.agentName) : null, + 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) { + appendContentBlock(lines, "user", turn.userContent); + } + if (turn.assistantContent) { + 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" ? ` (${sanitize(tc.status)})` : ""; + lines.push( + ` • ${sanitize(tc.name)} — ${formatDuration(tc.durationMs)}${status}` + ); + } + lines.push(""); + } + + return lines.join("\n"); +} + +export type TranscriptResult = { + conversationId: string; + org: string; + turns: ConversationTurn[]; + totalTokens: number; + spanCount: number; + projects: string[]; + startTimestamp: number; + endTimestamp: number; + truncated?: boolean; +}; + +export function formatTranscriptResult(result: TranscriptResult): string { + if (result.spanCount === 0) { + return `No spans found for conversation ${sanitize(result.conversationId)} in the last 30 days.`; + } + + const header = [ + `AI Conversation: ${sanitize(result.conversationId)}`, + "", + ` Org: ${sanitize(result.org)}`, + ` Projects: ${result.projects.map(sanitize).join(", ") || "—"}`, + ` Started: ${formatEpoch(result.startTimestamp)}`, + ` Ended: ${formatEpoch(result.endTimestamp)}`, + ` Turns: ${result.turns.length}`, + ` Spans: ${result.spanCount}`, + ` Tokens: ${result.totalTokens}`, + "", + ]; + + 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( + 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: + spans.length > 0 + ? spans.reduce( + (min, s) => Math.min(min, s["precise.start_ts"]), + Number.POSITIVE_INFINITY + ) + : 0, + endTimestamp: + spans.length > 0 + ? spans.reduce( + (max, s) => Math.max(max, s["precise.finish_ts"]), + Number.NEGATIVE_INFINITY + ) + : 0, + }; +} diff --git a/src/types/conversation.ts b/src/types/conversation.ts new file mode 100644 index 000000000..d2f2738ba --- /dev/null +++ b/src/types/conversation.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; diff --git a/src/types/index.ts b/src/types/index.ts index 79d91207b..20cea960d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,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/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)}/` + ); + }); +}); diff --git a/test/lib/completions.property.test.ts b/test/lib/completions.property.test.ts index 17dd14286..5d3157b2c 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([ + "conversation", "auth", "issue", "event", diff --git a/test/lib/formatters/conversation.test.ts b/test/lib/formatters/conversation.test.ts new file mode 100644 index 000000000..a776ede6f --- /dev/null +++ b/test/lib/formatters/conversation.test.ts @@ -0,0 +1,460 @@ +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"); + }); + + 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); + }); +});