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:
Paul Holstein 2026-02-22 14:56:57 -05:00
parent 114ea36df9
commit bbd6441b9e
2 changed files with 71 additions and 16 deletions

View file

@ -4,7 +4,7 @@ import { getPricing } from '@renderer/utils/sessionAnalyzer';
import { DollarSign } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
import { ReportSection } from '../ReportSection';
import { ReportSection, sectionId } from '../ReportSection';
import type { ModelTokenStats, ReportCostAnalysis } from '@renderer/types/sessionReport';
import type { ModelPricing } from '@renderer/types/sessionReport';
@ -182,18 +182,35 @@ export const CostSection = ({
<tbody>
{modelEntries.map(([model, cost]) => {
const stats = tokensByModel[model];
const isExpanded = expandedModel === model && !!stats;
const pricing = getPricing(model);
// Don't allow expansion for the synthetic aggregated row — getPricing
// 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 (
<Fragment key={model}>
<tr
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">
<span className="mr-1.5 inline-block w-3 text-text-muted">
{stats ? (isExpanded ? '\u25BC' : '\u25B6') : ''}
</span>
{isAggregateRow ? (
<span className="mr-1.5 inline-block w-3 text-text-muted">{'\u2192'}</span>
) : (
<span className="mr-1.5 inline-block w-3 text-text-muted">
{stats ? (isExpanded ? '\u25BC' : '\u25B6') : ''}
</span>
)}
{model}
</td>
<td className="py-1.5 pr-4 text-right text-text-secondary">
@ -210,7 +227,7 @@ export const CostSection = ({
</td>
<td className="py-1.5 pr-4 text-right font-medium text-text">{fmt(cost)}</td>
</tr>
{isExpanded && stats && (
{isExpanded && stats && pricing && (
<tr>
<td colSpan={6} className="px-4 pb-3 pt-1">
<CostBreakdownCard stats={stats} pricing={pricing} />

View file

@ -214,15 +214,16 @@ function extractNumberBefore(text: string, keyword: string): number | null {
* Returns [passed, failed] or null if no match.
*/
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 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)
const passing = extractNumberBefore(text, ' passing');
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;
}
@ -457,7 +458,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport {
// --- Token usage, cache economics, and cost ---
// Skip sidechain messages to avoid double-counting (subagent costs are
// 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 u = m.usage;
const inpTok = u.input_tokens ?? 0;
@ -891,19 +892,30 @@ export function analyzeSession(detail: SessionDetail): SessionReport {
// --- Subagent metrics from detail.processes ---
const subagentEntries: SubagentEntry[] = detail.processes.map((proc: Process) => {
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 {
description: desc,
subagentType: proc.subagentType ?? 'unknown',
model,
model: subagentModel,
totalTokens: proc.metrics.totalTokens,
totalDurationMs: proc.durationMs,
totalToolUseCount: proc.messages.reduce(
(sum: number, pm: ParsedMessage) => sum + pm.toolCalls.length,
0
),
costUsd: proc.metrics.costUsd ?? 0,
modelMismatch: detectModelMismatch(desc, model),
costUsd: computedCost,
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 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 ---
const costPerCommitVal =
commitCount > 0 ? Math.round((totalCost / commitCount) * 10000) / 10000 : null;