fix(report): compute subagent costs, filter synthetic model, fix test parsing
- Compute subagent cost from token breakdown instead of relying on unpopulated proc.metrics.costUsd; extract actual model from subagent messages for accurate pricing and mismatch detection - Add aggregated "Subagents (combined)" row to cost table with arrow navigation to the Subagents report section (no misleading breakdown) - Filter <synthetic> model from token/cost tracking to eliminate zero rows - Fix parseTestSummary to treat missing pass/fail count as 0 so clean all-passing test runs are not dropped from trajectory analysis Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
114ea36df9
commit
bbd6441b9e
2 changed files with 71 additions and 16 deletions
|
|
@ -4,7 +4,7 @@ import { getPricing } from '@renderer/utils/sessionAnalyzer';
|
||||||
import { DollarSign } from 'lucide-react';
|
import { DollarSign } from 'lucide-react';
|
||||||
|
|
||||||
import { AssessmentBadge } from '../AssessmentBadge';
|
import { AssessmentBadge } from '../AssessmentBadge';
|
||||||
import { ReportSection } from '../ReportSection';
|
import { ReportSection, sectionId } from '../ReportSection';
|
||||||
|
|
||||||
import type { ModelTokenStats, ReportCostAnalysis } from '@renderer/types/sessionReport';
|
import type { ModelTokenStats, ReportCostAnalysis } from '@renderer/types/sessionReport';
|
||||||
import type { ModelPricing } from '@renderer/types/sessionReport';
|
import type { ModelPricing } from '@renderer/types/sessionReport';
|
||||||
|
|
@ -182,18 +182,35 @@ export const CostSection = ({
|
||||||
<tbody>
|
<tbody>
|
||||||
{modelEntries.map(([model, cost]) => {
|
{modelEntries.map(([model, cost]) => {
|
||||||
const stats = tokensByModel[model];
|
const stats = tokensByModel[model];
|
||||||
const isExpanded = expandedModel === model && !!stats;
|
// Don't allow expansion for the synthetic aggregated row — getPricing
|
||||||
const pricing = getPricing(model);
|
// would return wrong default rates for a non-model label.
|
||||||
|
const isAggregateRow = model === 'Subagents (combined)';
|
||||||
|
const isExpanded = expandedModel === model && !!stats && !isAggregateRow;
|
||||||
|
const pricing = isAggregateRow ? null : getPricing(model);
|
||||||
return (
|
return (
|
||||||
<Fragment key={model}>
|
<Fragment key={model}>
|
||||||
<tr
|
<tr
|
||||||
className={`border-border/50 border-b ${stats ? 'hover:bg-surface-raised/50 cursor-pointer' : ''}`}
|
className={`border-border/50 border-b ${stats ? 'hover:bg-surface-raised/50 cursor-pointer' : ''}`}
|
||||||
onClick={() => stats && setExpandedModel(isExpanded ? null : model)}
|
onClick={() => {
|
||||||
|
if (isAggregateRow) {
|
||||||
|
const el = document.getElementById(sectionId('Subagents'));
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
el.dispatchEvent(new CustomEvent('report-section-expand'));
|
||||||
|
}
|
||||||
|
} else if (stats) {
|
||||||
|
setExpandedModel(isExpanded ? null : model);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<td className="py-1.5 pr-4 text-text">
|
<td className="py-1.5 pr-4 text-text">
|
||||||
<span className="mr-1.5 inline-block w-3 text-text-muted">
|
{isAggregateRow ? (
|
||||||
{stats ? (isExpanded ? '\u25BC' : '\u25B6') : ''}
|
<span className="mr-1.5 inline-block w-3 text-text-muted">{'\u2192'}</span>
|
||||||
</span>
|
) : (
|
||||||
|
<span className="mr-1.5 inline-block w-3 text-text-muted">
|
||||||
|
{stats ? (isExpanded ? '\u25BC' : '\u25B6') : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{model}
|
{model}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-4 text-right text-text-secondary">
|
<td className="py-1.5 pr-4 text-right text-text-secondary">
|
||||||
|
|
@ -210,7 +227,7 @@ export const CostSection = ({
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-4 text-right font-medium text-text">{fmt(cost)}</td>
|
<td className="py-1.5 pr-4 text-right font-medium text-text">{fmt(cost)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExpanded && stats && (
|
{isExpanded && stats && pricing && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-4 pb-3 pt-1">
|
<td colSpan={6} className="px-4 pb-3 pt-1">
|
||||||
<CostBreakdownCard stats={stats} pricing={pricing} />
|
<CostBreakdownCard stats={stats} pricing={pricing} />
|
||||||
|
|
|
||||||
|
|
@ -214,15 +214,16 @@ function extractNumberBefore(text: string, keyword: string): number | null {
|
||||||
* Returns [passed, failed] or null if no match.
|
* Returns [passed, failed] or null if no match.
|
||||||
*/
|
*/
|
||||||
function parseTestSummary(text: string): [number, number] | null {
|
function parseTestSummary(text: string): [number, number] | null {
|
||||||
// Try "passed"/"failed" keywords
|
// Try "passed"/"failed" keywords — treat missing count as 0
|
||||||
|
// (runners often omit "0 failed" when all tests pass)
|
||||||
const passed = extractNumberBefore(text, ' passed');
|
const passed = extractNumberBefore(text, ' passed');
|
||||||
const failed = extractNumberBefore(text, ' failed');
|
const failed = extractNumberBefore(text, ' failed');
|
||||||
if (passed != null && failed != null) return [passed, failed];
|
if (passed != null || failed != null) return [passed ?? 0, failed ?? 0];
|
||||||
|
|
||||||
// Try "passing"/"failing" keywords (mocha-style)
|
// Try "passing"/"failing" keywords (mocha-style)
|
||||||
const passing = extractNumberBefore(text, ' passing');
|
const passing = extractNumberBefore(text, ' passing');
|
||||||
const failing = extractNumberBefore(text, ' failing');
|
const failing = extractNumberBefore(text, ' failing');
|
||||||
if (passing != null && failing != null) return [passing, failing];
|
if (passing != null || failing != null) return [passing ?? 0, failing ?? 0];
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -457,7 +458,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport {
|
||||||
// --- Token usage, cache economics, and cost ---
|
// --- Token usage, cache economics, and cost ---
|
||||||
// Skip sidechain messages to avoid double-counting (subagent costs are
|
// Skip sidechain messages to avoid double-counting (subagent costs are
|
||||||
// accounted for separately via processSubagentCost).
|
// accounted for separately via processSubagentCost).
|
||||||
if (m.usage && m.model && !m.isSidechain) {
|
if (m.usage && m.model && !m.isSidechain && m.model !== '<synthetic>') {
|
||||||
const model = m.model;
|
const model = m.model;
|
||||||
const u = m.usage;
|
const u = m.usage;
|
||||||
const inpTok = u.input_tokens ?? 0;
|
const inpTok = u.input_tokens ?? 0;
|
||||||
|
|
@ -891,19 +892,30 @@ export function analyzeSession(detail: SessionDetail): SessionReport {
|
||||||
// --- Subagent metrics from detail.processes ---
|
// --- Subagent metrics from detail.processes ---
|
||||||
const subagentEntries: SubagentEntry[] = detail.processes.map((proc: Process) => {
|
const subagentEntries: SubagentEntry[] = detail.processes.map((proc: Process) => {
|
||||||
const desc = proc.description ?? 'unknown';
|
const desc = proc.description ?? 'unknown';
|
||||||
const model = 'default (inherits parent)';
|
// Extract actual model from subagent messages (first assistant message with a model field)
|
||||||
|
const subagentModel =
|
||||||
|
proc.messages.find((m: ParsedMessage) => m.type === 'assistant' && m.model)?.model ??
|
||||||
|
'default (inherits parent)';
|
||||||
|
// Compute cost from subagent token breakdown (proc.metrics.costUsd is not populated upstream)
|
||||||
|
const computedCost = costUsd(
|
||||||
|
subagentModel,
|
||||||
|
proc.metrics.inputTokens,
|
||||||
|
proc.metrics.outputTokens,
|
||||||
|
proc.metrics.cacheReadTokens,
|
||||||
|
proc.metrics.cacheCreationTokens
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
description: desc,
|
description: desc,
|
||||||
subagentType: proc.subagentType ?? 'unknown',
|
subagentType: proc.subagentType ?? 'unknown',
|
||||||
model,
|
model: subagentModel,
|
||||||
totalTokens: proc.metrics.totalTokens,
|
totalTokens: proc.metrics.totalTokens,
|
||||||
totalDurationMs: proc.durationMs,
|
totalDurationMs: proc.durationMs,
|
||||||
totalToolUseCount: proc.messages.reduce(
|
totalToolUseCount: proc.messages.reduce(
|
||||||
(sum: number, pm: ParsedMessage) => sum + pm.toolCalls.length,
|
(sum: number, pm: ParsedMessage) => sum + pm.toolCalls.length,
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
costUsd: proc.metrics.costUsd ?? 0,
|
costUsd: computedCost,
|
||||||
modelMismatch: detectModelMismatch(desc, model),
|
modelMismatch: detectModelMismatch(desc, subagentModel),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1114,6 +1126,32 @@ export function analyzeSession(detail: SessionDetail): SessionReport {
|
||||||
const processSubagentCost = subagentEntries.reduce((sum, a) => sum + a.costUsd, 0);
|
const processSubagentCost = subagentEntries.reduce((sum, a) => sum + a.costUsd, 0);
|
||||||
const totalCost = parentCost + processSubagentCost;
|
const totalCost = parentCost + processSubagentCost;
|
||||||
|
|
||||||
|
// Add aggregated subagent row to costByModel and byModel for the cost table
|
||||||
|
if (subagentEntries.length > 0 && processSubagentCost > 0) {
|
||||||
|
const subagentTokenStats: ModelTokenStats = {
|
||||||
|
apiCalls: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreation: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
costUsd: 0,
|
||||||
|
};
|
||||||
|
for (const proc of detail.processes) {
|
||||||
|
subagentTokenStats.inputTokens += proc.metrics.inputTokens;
|
||||||
|
subagentTokenStats.outputTokens += proc.metrics.outputTokens;
|
||||||
|
subagentTokenStats.cacheCreation += proc.metrics.cacheCreationTokens;
|
||||||
|
subagentTokenStats.cacheRead += proc.metrics.cacheReadTokens;
|
||||||
|
// Count assistant messages with usage as API calls
|
||||||
|
subagentTokenStats.apiCalls += proc.messages.filter(
|
||||||
|
(m: ParsedMessage) => m.type === 'assistant' && m.usage
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
subagentTokenStats.costUsd = Math.round(processSubagentCost * 10000) / 10000;
|
||||||
|
const subagentLabel = 'Subagents (combined)';
|
||||||
|
byModel[subagentLabel] = subagentTokenStats;
|
||||||
|
modelStats.set(subagentLabel, subagentTokenStats);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Assessment computations ---
|
// --- Assessment computations ---
|
||||||
const costPerCommitVal =
|
const costPerCommitVal =
|
||||||
commitCount > 0 ? Math.round((totalCost / commitCount) * 10000) / 10000 : null;
|
commitCount > 0 ? Math.round((totalCost / commitCount) * 10000) / 10000 : null;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue