Skip to content
Open
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
33 changes: 11 additions & 22 deletions vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
calculateEstimatedCost as _calculateEstimatedCost,
createEmptyContextRefs as _createEmptyContextRefs,
getTotalTokensFromModelUsage as _getTotalTokensFromModelUsage,
reconstructJsonlStateAsync as _reconstructJsonlStateAsync,
} from './tokenEstimation';
import { SessionDiscovery } from './sessionDiscovery';
import { CacheManager } from './cacheManager';
Expand Down Expand Up @@ -733,8 +734,10 @@ class CopilotTokenTracker implements vscode.Disposable {
this.analysisPanel.webview.html = this.getUsageAnalysisHtml(this.analysisPanel.webview, analysisStats);
}
} else {
// Pre-populate the cache even when panel isn't open, so first open is fast
await this.calculateUsageAnalysisStats(false);
// Skip pre-warming usage analysis when the panel isn't open.
// calculateUsageAnalysisStats triggers workspace customization scans
// and JSONL reconstruction which can starve the extension host event loop
// on startup, amplifying the crash-loop risk.
}

// If the maturity panel is open, update its content.
Expand Down Expand Up @@ -2586,16 +2589,9 @@ class CopilotTokenTracker implements vscode.Disposable {
}

if (isDeltaBased) {
// Delta-based format: reconstruct full state first, then extract details
let sessionState: any = {};
for (const line of lines) {
try {
const delta = JSON.parse(line);
sessionState = this.applyDelta(sessionState, delta);
} catch {
// Skip invalid lines
}
}
// Delta-based format: reconstruct full state asynchronously to avoid
// blocking the extension host event loop on large files.
const { sessionState } = await _reconstructJsonlStateAsync(lines);

// Extract session metadata from reconstructed state
if (sessionState.creationDate) {
Expand Down Expand Up @@ -3102,16 +3098,9 @@ class CopilotTokenTracker implements vscode.Disposable {
}

if (isDeltaBased) {
// Delta-based format: reconstruct full state first, then extract turns
let sessionState: any = {};
for (const line of lines) {
try {
const delta = JSON.parse(line);
sessionState = this.applyDelta(sessionState, delta);
} catch {
// Skip invalid lines
}
}
// Delta-based format: reconstruct full state asynchronously to avoid
// blocking the extension host event loop on large files.
const { sessionState } = await _reconstructJsonlStateAsync(lines);

// Extract session-level info
let sessionMode: 'ask' | 'edit' | 'agent' | 'plan' | 'customAgent' = 'ask';
Expand Down
27 changes: 27 additions & 0 deletions vscode-extension/src/tokenEstimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,33 @@ export function estimateTokensFromJsonlSession(fileContent: string): { tokens: n
return { tokens: totalTokens + totalThinkingTokens, thinkingTokens: totalThinkingTokens, actualTokens: finalActualTokens };
}

/**
* Asynchronously reconstruct the full session state from delta-based JSONL lines.
* Yields to the event loop every `yieldInterval` lines to prevent starving the
* extension host's single-threaded event loop on large files.
*/
export async function reconstructJsonlStateAsync(lines: string[], yieldInterval = 500): Promise<{ sessionState: any; isDeltaBased: boolean }> {
let sessionState: any = {};
let isDeltaBased = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) { continue; }
try {
const delta = JSON.parse(line);
if (typeof delta.kind === 'number') {
isDeltaBased = true;
sessionState = applyDelta(sessionState, delta);
}
} catch {
// Skip invalid lines
}
if (isDeltaBased && i > 0 && i % yieldInterval === 0) {
await new Promise<void>(resolve => setTimeout(resolve, 0));
}
}
return { sessionState, isDeltaBased };
}

/**
* Extract per-request actual token usage from raw JSONL lines using regex.
* Handles cases where lines with result data fail JSON.parse due to bad escape characters.
Expand Down
53 changes: 39 additions & 14 deletions vscode-extension/src/usageAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,11 +556,11 @@ function applyModelTierClassification(
* Calculate model switching statistics for a session file.
* This method updates the analysis.modelSwitching field in place.
*/
export async function calculateModelSwitching(deps: Pick<UsageAnalysisDeps, 'warn' | 'modelPricing' | 'openCode' | 'continue_' | 'tokenEstimators'>, sessionFile: string, analysis: SessionUsageAnalysis): Promise<void> {
export async function calculateModelSwitching(deps: Pick<UsageAnalysisDeps, 'warn' | 'modelPricing' | 'openCode' | 'continue_' | 'tokenEstimators'>, sessionFile: string, analysis: SessionUsageAnalysis, preloadedContent?: string): Promise<void> {
try {
// Use non-cached method to avoid circular dependency
// (getSessionFileDataCached -> analyzeSessionUsage -> getModelUsageFromSessionCached -> getSessionFileDataCached)
const modelUsage = await getModelUsageFromSession(deps, sessionFile);
const modelUsage = await getModelUsageFromSession(deps, sessionFile, preloadedContent);
const modelCount = modelUsage ? Object.keys(modelUsage).length : 0;

// Skip if modelUsage is undefined or empty (not a valid session file)
Expand Down Expand Up @@ -593,7 +593,7 @@ export async function calculateModelSwitching(deps: Pick<UsageAnalysisDeps, 'war
analysis.modelSwitching.hasMixedTiers = standardModels.length > 0 && premiumModels.length > 0;

// Count requests per tier and model switches by examining request sequence
const fileContent = await fs.promises.readFile(sessionFile, 'utf8');
const fileContent = preloadedContent ?? await fs.promises.readFile(sessionFile, 'utf8');
// Check if this is a UUID-only file (new Copilot CLI format)
if (isUuidPointerFile(fileContent)) {
return;
Expand Down Expand Up @@ -719,9 +719,9 @@ export async function calculateModelSwitching(deps: Pick<UsageAnalysisDeps, 'war
* - Conversation patterns (multi-turn sessions)
* - Agent type usage
*/
export async function trackEnhancedMetrics(deps: Pick<UsageAnalysisDeps, 'warn'>, sessionFile: string, analysis: SessionUsageAnalysis): Promise<void> {
export async function trackEnhancedMetrics(deps: Pick<UsageAnalysisDeps, 'warn'>, sessionFile: string, analysis: SessionUsageAnalysis, preloadedContent?: string): Promise<void> {
try {
const fileContent = await fs.promises.readFile(sessionFile, 'utf8');
const fileContent = preloadedContent ?? await fs.promises.readFile(sessionFile, 'utf8');

// Check if this is a UUID-only file (new Copilot CLI format)
if (isUuidPointerFile(fileContent)) {
Expand Down Expand Up @@ -1280,8 +1280,33 @@ export async function analyzeSessionUsage(deps: UsageAnalysisDeps, sessionFile:
}
}

// Calculate model switching for delta-based JSONL files
await calculateModelSwitching(deps, sessionFile, analysis);
// Compute model switching inline from the already-reconstructed state
// to avoid re-reading and re-parsing the file in calculateModelSwitching.
{
const models: string[] = [];
for (const req of requests) {
if (!req || !req.requestId) { continue; }
let reqModel = 'gpt-4o';
if (req.modelId) {
reqModel = req.modelId.replace(/^copilot\//, '');
} else if (req.result?.metadata?.modelId) {
reqModel = req.result.metadata.modelId.replace(/^copilot\//, '');
} else if (req.result?.details) {
reqModel = getModelFromRequest(req, deps.modelPricing);
}
Comment on lines +1286 to +1296
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the delta-based JSONL branch, the inline model-switching logic defaults to 'gpt-4o' when a request lacks modelId/result.metadata.modelId. Reconstructed requests can legitimately omit modelId (the logviewer path already falls back to sessionState.inputState.selectedModel), so this can misattribute models and under/over-count switches/tiers for sessions that used a different selected model. Consider deriving a defaultModel from sessionState.inputState?.selectedModel (identifier/metadata.id) and using it as the fallback before hard-coding a default.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

models.push(reqModel);
}
const uniqueModels = [...new Set(models)];
analysis.modelSwitching.uniqueModels = uniqueModels;
analysis.modelSwitching.modelCount = uniqueModels.length;
analysis.modelSwitching.totalRequests = models.length;
let switchCount = 0;
for (let mi = 1; mi < models.length; mi++) {
if (models[mi] !== models[mi - 1]) { switchCount++; }
}
analysis.modelSwitching.switchCount = switchCount;
applyModelTierClassification(deps, uniqueModels, models, analysis);
}

// Derive conversation patterns from mode usage before returning
deriveConversationPatterns(analysis);
Expand Down Expand Up @@ -1439,7 +1464,7 @@ export async function analyzeSessionUsage(deps: UsageAnalysisDeps, sessionFile:
}
}
// Calculate model switching for JSONL files before returning
await calculateModelSwitching(deps, sessionFile, analysis);
await calculateModelSwitching(deps, sessionFile, analysis, fileContent);

// Derive conversation patterns from mode usage before returning
deriveConversationPatterns(analysis);
Expand Down Expand Up @@ -1531,16 +1556,16 @@ export async function analyzeSessionUsage(deps: UsageAnalysisDeps, sessionFile:
}
}
}

// Calculate model switching statistics from session (pass preloaded content to avoid re-reading)
await calculateModelSwitching(deps, sessionFile, analysis, fileContent);

// Track new metrics: edit scope, apply usage, session duration, conversation patterns, agent types
await trackEnhancedMetrics(deps, sessionFile, analysis, fileContent);
} catch (error) {
deps.warn(`Error analyzing session usage from ${sessionFile}: ${error}`);
}

// Calculate model switching statistics from session
await calculateModelSwitching(deps, sessionFile, analysis);

// Track new metrics: edit scope, apply usage, session duration, conversation patterns, agent types
await trackEnhancedMetrics(deps, sessionFile, analysis);

return analysis;
}

Expand Down
Loading