Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/cli/src/commands/agent-conversation/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,18 @@ export const viewCommand = buildCommand({
}
const org = resolved.org;

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

const result = buildTranscriptResult(conversationId, org, spans, title);
const result = buildTranscriptResult(conversationId, org, spans, {
stats,
title,
});
result.truncated = truncated;
yield new CommandOutput<TranscriptResult>(result);
},
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/lib/api/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type AgentConversationSpan,
type ConversationListItem,
ConversationListItemSchema,
type ConversationStats,
} from "../../types/conversation.js";

import { logger } from "../logger.js";
Expand Down Expand Up @@ -126,6 +127,7 @@ export async function getConversationSpans(
} = {},
): Promise<{
spans: AgentConversationSpan[];
stats?: ConversationStats;
truncated: boolean;
title: string | null;
}> {
Expand All @@ -140,6 +142,7 @@ export async function getConversationSpans(
}

const spans: AgentConversationSpan[] = [];
let stats: ConversationStats | undefined;
let title: string | null = null;
let cursor: string | undefined;

Expand All @@ -156,6 +159,7 @@ export async function getConversationSpans(
);

if (page === 0) {
stats = data.stats;
title = data.title;
}
spans.push(...data.spans);
Expand All @@ -173,5 +177,5 @@ export async function getConversationSpans(
);
}

return { spans, truncated, title };
return { spans, stats, truncated, title };
}
63 changes: 58 additions & 5 deletions packages/cli/src/lib/formatters/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type {
AgentConversationSpan,
ConversationListItem,
ConversationStats,
} from "../../types/conversation.js";
import {
colorTag,
Expand Down Expand Up @@ -37,10 +38,20 @@ function formatTimestamp(epochSeconds: number): string {
return new Date(epochSeconds * 1000).toLocaleString();
}

const USD_FORMAT = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 6,
});

function formatCost(cost: number): string {
return USD_FORMAT.format(cost);
}

/**
* Minimum terminal width (columns) to show the full conversation table.
* Below this, Tools/Errs/User are dropped so the core ID/Title/Started/
* Tokens/First Input columns don't truncate aggressively.
* Tokens/Cost/First Input columns don't truncate aggressively.
*/
const WIDE_TABLE_MIN_TERM_WIDTH = 100;

Expand Down Expand Up @@ -71,6 +82,12 @@ const TOKENS_COLUMN: Column<ConversationListItem> = {
align: "right",
};

const COST_COLUMN: Column<ConversationListItem> = {
header: "Cost",
value: (c) => formatCost(c.totalCost),
align: "right",
};

const TOOLS_COLUMN: Column<ConversationListItem> = {
header: "Tools",
value: (c) => String(c.toolCalls),
Expand Down Expand Up @@ -109,6 +126,7 @@ function selectConversationColumns(): Column<ConversationListItem>[] {
TITLE_COLUMN,
STARTED_COLUMN,
TOKENS_COLUMN,
COST_COLUMN,
TOOLS_COLUMN,
ERRS_COLUMN,
USER_COLUMN,
Expand All @@ -120,6 +138,7 @@ function selectConversationColumns(): Column<ConversationListItem>[] {
TITLE_COLUMN,
STARTED_COLUMN,
TOKENS_COLUMN,
COST_COLUMN,
FIRST_INPUT_COLUMN,
];
}
Expand Down Expand Up @@ -440,7 +459,13 @@ export type TranscriptResult = {
/** Stored conversation title, or null when none has been titled yet. */
title: string | null;
turns: ConversationTurn[];
errors?: number;
llmCalls?: number;
toolCalls?: number;
toolErrors?: number;
totalCost?: number;
totalTokens: number;
traceIds: string[];
spanCount: number;
projects: string[];
startTimestamp: number;
Expand Down Expand Up @@ -468,6 +493,21 @@ export function formatTranscriptResult(result: TranscriptResult): string {
["Spans", String(result.spanCount)],
["Tokens", String(result.totalTokens)],
];
if (result.totalCost !== undefined) {
rows.push(["Cost", formatCost(result.totalCost)]);
}
if (result.llmCalls !== undefined) {
rows.push(["LLM Calls", String(result.llmCalls)]);
}
if (result.toolCalls !== undefined) {
rows.push(["Tool Calls", String(result.toolCalls)]);
}
if (result.errors !== undefined) {
rows.push(["Errors", String(result.errors)]);
}
if (result.toolErrors !== undefined) {
rows.push(["Tool Errors", String(result.toolErrors)]);
}
if (result.title) {
rows.unshift(["Title", escapeMarkdownCell(result.title)]);
}
Expand All @@ -494,17 +534,30 @@ export function buildTranscriptResult(
conversationId: string,
org: string,
spans: AgentConversationSpan[],
title: string | null = null,
details: { stats?: ConversationStats; title?: string | null } = {},
): TranscriptResult {
const { stats, title = null } = details;
const turns = extractTurns(spans);
const summary = stats
? {
errors: stats.errors,
llmCalls: stats.llmCalls,
toolCalls: stats.toolCalls,
toolErrors: stats.toolErrors,
totalCost: stats.totalCost,
}
: {};
return {
conversationId,
org,
title,
turns,
// Sum tokens per turn (ai_client spans only). Summing every span would
// double-count parent invoke_agent spans that aggregate child usage.
totalTokens: turns.reduce((sum, t) => sum + t.totalTokens, 0),
...summary,
// Prefer server totals. They cover the whole conversation without
// double-counting parent agent spans.
totalTokens:
stats?.totalTokens ?? turns.reduce((sum, t) => sum + t.totalTokens, 0),
traceIds: [...new Set(spans.map((s) => s.trace))].sort(),
spanCount: spans.length,
projects: [...new Set(spans.map((s) => s.project))].sort(),
startTimestamp:
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/types/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ export type AgentConversationSpan = InferOutput<
typeof AgentConversationSpanSchema
>;

export const ConversationStatsSchema = looseObject({
errors: number(),
llmCalls: number(),
toolCalls: number(),
toolErrors: number(),
totalCost: number(),
totalTokens: number(),
});

export type ConversationStats = InferOutput<typeof ConversationStatsSchema>;

/**
* Conversation details envelope returned by
* `GET /organizations/{org}/agents/conversations/{conversationId}/`.
Expand All @@ -109,6 +120,8 @@ export const AgentConversationDetailsSchema = looseObject({
conversationId: string(),
title: nullable(string()),
spans: array(AgentConversationSpanSchema),
/** Added after the original envelope; optional for older self-hosted servers. */
stats: optional(ConversationStatsSchema),
});

export type AgentConversationDetails = InferOutput<
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ export type {
AgentConversationDetails,
AgentConversationSpan,
ConversationListItem,
ConversationStats,
} from "./conversation.js";
export {
AgentConversationDetailsSchema,
AgentConversationSpanSchema,
ConversationListItemSchema,
ConversationStatsSchema,
} from "./conversation.js";
// Dashboard types
export type {
Expand Down
19 changes: 18 additions & 1 deletion packages/cli/test/commands/agent-conversation/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,14 @@ describe("viewCommand.func", () => {
test("yields CommandOutput with transcript result (JSON)", async () => {
getConversationSpansSpy.mockResolvedValue({
spans: sampleSpans,
stats: {
errors: 1,
llmCalls: 2,
toolCalls: 3,
toolErrors: 1,
totalCost: 0.0042,
totalTokens: 150,
},
truncated: false,
title: null,
});
Expand All @@ -330,7 +338,16 @@ describe("viewCommand.func", () => {
const parsed = JSON.parse(output);
expect(parsed.conversationId).toBe(CONVERSATION_ID);
expect(parsed.org).toBe(ORG);
expect(parsed.spanCount).toBe(2);
expect(parsed).toMatchObject({
errors: 1,
llmCalls: 2,
toolCalls: 3,
toolErrors: 1,
totalCost: 0.0042,
totalTokens: 150,
traceIds: ["00112233445566778899aabbccddeeff"],
spanCount: 2,
});
expect(parsed.turns).toBeDefined();
expect(Array.isArray(parsed.turns)).toBe(true);
});
Expand Down
18 changes: 16 additions & 2 deletions packages/cli/test/lib/api/conversations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
listConversations,
} from "../../../src/lib/api/conversations.js";
import { MAX_PAGINATION_PAGES } from "../../../src/lib/api/infrastructure.js";
import type { ConversationStats } from "../../../src/types/conversation.js";
import { mockFetch, useTestConfigDir } from "../../helpers.js";

// ============================================================================
Expand Down Expand Up @@ -311,11 +312,13 @@ describe("getConversationSpans", () => {
function makeEnvelope(
spans: ReturnType<typeof makeSpan>[],
title: string | null = "Test conversation",
stats?: ConversationStats,
) {
return {
conversationId: CONV_ID,
title,
spans,
...(stats ? { stats } : {}),
};
}

Expand All @@ -340,15 +343,26 @@ describe("getConversationSpans", () => {
expect(url).toContain("statsPeriod=30d");
});

test("returns spans from a single page envelope", async () => {
test("returns spans and stats from a single page envelope", async () => {
const spans = [makeSpan("span-1-aabb1122")];
mockOk(makeEnvelope(spans, "Refund flow"));
mockOk(
makeEnvelope(spans, "Refund flow", {
errors: 1,
llmCalls: 1,
toolCalls: 2,
toolErrors: 1,
totalCost: 0.0042,
totalTokens: 100,
}),
);

const result = await getConversationSpans(ORG, CONV_ID);

expect(result.spans).toHaveLength(1);
expect(result.spans[0].span_id).toBe("span-1-aabb1122");
expect(result.title).toBe("Refund flow");
expect(result.stats?.totalCost).toBe(0.0042);
expect(result.stats?.totalTokens).toBe(100);
expect(result.truncated).toBe(false);
});

Expand Down
Loading
Loading