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 { 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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue