diff --git a/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts b/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts index dc98db11ceb4b2..663563a339f2a1 100644 --- a/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts +++ b/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts @@ -223,6 +223,36 @@ export function translateSpan( } } + if (operationName === GenAiOperationName.CHAT) { + const inputTokens = span.attributes[GenAiAttr.USAGE_INPUT_TOKENS] as number | undefined; + const outputTokens = span.attributes[GenAiAttr.USAGE_OUTPUT_TOKENS] as number | undefined; + const model = (span.attributes[GenAiAttr.RESPONSE_MODEL] as string | undefined) + ?? (span.attributes[GenAiAttr.REQUEST_MODEL] as string | undefined); + + if (typeof inputTokens === 'number' && typeof outputTokens === 'number') { + const data: Record = { + model: model ?? 'unknown', + inputTokens, + outputTokens, + }; + + // Optional fields — include when available + const cacheReadTokens = span.attributes[GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS] as number | undefined; + if (typeof cacheReadTokens === 'number') { + data.cacheReadTokens = cacheReadTokens; + } + const timeToFirstToken = span.attributes[CopilotChatAttr.TIME_TO_FIRST_TOKEN] as number | undefined; + if (typeof timeToFirstToken === 'number') { + data.timeToFirstTokenMs = timeToFirstToken; + } + if (span.endTime > span.startTime) { + data.duration = span.endTime - span.startTime; + } + + pushEvent(events, state, 'assistant.usage', data, /*ephemeral*/ true, subagentId); + } + } + return events; } @@ -342,6 +372,31 @@ export function translateDebugLogEntry( } break; } + + case 'llm_request': { + const inputTokens = entry.attrs.inputTokens; + const outputTokens = entry.attrs.outputTokens; + if (typeof inputTokens === 'number' && typeof outputTokens === 'number') { + const data: Record = { + model: typeof entry.attrs.model === 'string' ? entry.attrs.model : 'unknown', + inputTokens, + outputTokens, + }; + const cacheReadTokens = entry.attrs.cacheReadTokens ?? entry.attrs.cachedTokens; + if (typeof cacheReadTokens === 'number') { + data.cacheReadTokens = cacheReadTokens; + } + const ttft = entry.attrs.ttft; + if (typeof ttft === 'number') { + data.timeToFirstTokenMs = ttft; + } + if (typeof entry.dur === 'number' && entry.dur > 0) { + data.duration = entry.dur; + } + pushEventAt(events, state, ts, 'assistant.usage', data, /*ephemeral*/ true); + } + break; + } } return events; diff --git a/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts b/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts index fe25b7af80a19a..ca6dd07e956856 100644 --- a/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts +++ b/extensions/copilot/src/extension/chronicle/common/test/eventTranslator.spec.ts @@ -125,7 +125,7 @@ describe('translateSpan', () => { expect(events[0].data.error).toBeDefined(); }); - it('ignores non-relevant operation names', () => { + it('ignores chat spans without usage token attributes', () => { const state = createSessionTranslationState(); const span = makeSpan({ attributes: { @@ -137,6 +137,78 @@ describe('translateSpan', () => { expect(events).toHaveLength(0); }); + it('emits assistant.usage for chat spans with usage tokens', () => { + const state = createSessionTranslationState(); + state.started = true; + + const span = makeSpan({ + startTime: 1000, + endTime: 1500, + attributes: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.response.model': 'claude-sonnet-4-20250514', + 'gen_ai.usage.input_tokens': 12500, + 'gen_ai.usage.output_tokens': 800, + 'gen_ai.usage.cache_read.input_tokens': 5000, + 'copilot_chat.time_to_first_token': 230, + }, + }); + + const events = translateSpan(span, state); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('assistant.usage'); + expect(events[0].data.model).toBe('claude-sonnet-4-20250514'); + expect(events[0].data.inputTokens).toBe(12500); + expect(events[0].data.outputTokens).toBe(800); + expect(events[0].data.cacheReadTokens).toBe(5000); + expect(events[0].data.timeToFirstTokenMs).toBe(230); + expect(events[0].data.duration).toBe(500); + expect(events[0].ephemeral).toBe(true); + }); + + it('emits assistant.usage with request model as fallback', () => { + const state = createSessionTranslationState(); + state.started = true; + + const span = makeSpan({ + attributes: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': 'gpt-5.4', + 'gen_ai.usage.input_tokens': 100, + 'gen_ai.usage.output_tokens': 50, + }, + }); + + const events = translateSpan(span, state); + + expect(events).toHaveLength(1); + expect(events[0].data.model).toBe('gpt-5.4'); + // Optional fields should be absent when not in span + expect(events[0].data.cacheReadTokens).toBeUndefined(); + expect(events[0].data.timeToFirstTokenMs).toBeUndefined(); + }); + + it('emits assistant.usage with subagentId for sub-agent chat spans', () => { + const state = createSessionTranslationState(); + state.started = true; + + const span = makeSpan({ + attributes: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.response.model': 'claude-sonnet-4-20250514', + 'gen_ai.usage.input_tokens': 200, + 'gen_ai.usage.output_tokens': 100, + }, + }); + + const events = translateSpan(span, state, undefined, 'sub-agent-123'); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('assistant.usage'); + expect(events[0].agentId).toBe('sub-agent-123'); + }); + it('chains parentId across events', () => { const state = createSessionTranslationState(); const span1 = makeSpan({ @@ -493,6 +565,69 @@ describe('translateDebugLogEntry', () => { // Critical: assistant.message must chain to session.start, not the dropped user.message. expect(replyEvents[0].parentId).toBe(startEvents[0].id); }); + + it('emits assistant.usage for llm_request entry with token data', () => { + const state = createSessionTranslationState(); + state.started = true; + const entry = makeDebugEntry({ + type: 'llm_request', + name: 'llm_request', + dur: 450, + attrs: { + model: 'claude-sonnet-4-20250514', + inputTokens: 8000, + outputTokens: 500, + cachedTokens: 3000, + ttft: 120, + }, + }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('assistant.usage'); + expect(events[0].data.model).toBe('claude-sonnet-4-20250514'); + expect(events[0].data.inputTokens).toBe(8000); + expect(events[0].data.outputTokens).toBe(500); + expect(events[0].data.cacheReadTokens).toBe(3000); + expect(events[0].data.timeToFirstTokenMs).toBe(120); + expect(events[0].data.duration).toBe(450); + }); + + it('ignores llm_request entry without token data', () => { + const state = createSessionTranslationState(); + state.started = true; + const entry = makeDebugEntry({ + type: 'llm_request', + name: 'llm_request', + attrs: { model: 'gpt-5.4' }, + }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + expect(events).toHaveLength(0); + }); + + it('accepts cacheReadTokens as fallback field name', () => { + const state = createSessionTranslationState(); + state.started = true; + const entry = makeDebugEntry({ + type: 'llm_request', + name: 'llm_request', + dur: 200, + attrs: { + model: 'gpt-5.4', + inputTokens: 1000, + outputTokens: 200, + cacheReadTokens: 500, + }, + }); + + const events = translateDebugLogEntry(entry, 'sess-1', state); + + expect(events).toHaveLength(1); + expect(events[0].data.cacheReadTokens).toBe(500); + expect(events[0].data.timeToFirstTokenMs).toBeUndefined(); + }); }); describe('deriveTitleFromUserMessage', () => { it('returns the content unchanged when shorter than the limit', () => { diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts index 96b6725268d0f3..abfc0098b5a981 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts @@ -88,6 +88,9 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr /** Sessions currently initializing (prevent concurrent init). */ private readonly _initializingSessions = new Set(); + /** CHAT spans received before session was initialized, keyed by session ID. */ + private readonly _pendingChatSpans = new Map(); + // ── Shared state ───────────────────────────────────────────────────────────── /** Buffered events tagged with their chat session ID for correct routing. */ @@ -310,6 +313,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr this._translationStates.clear(); this._disabledSessions.clear(); this._initializingSessions.clear(); + this._pendingChatSpans.clear(); super.dispose(); } @@ -710,6 +714,17 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // init trigger would seed sessionSource/firstCloudWriteSessionSource and // telemetry from the sub-agent's AGENT_NAME instead of the parent's. if (!this._cloudSessions.has(sessionId) && !this._initializingSessions.has(sessionId)) { + if (operationName === GenAiOperationName.CHAT) { + // CHAT spans (LLM calls) complete before their parent invoke_agent. + // Buffer them so usage events aren't lost for the first turn. + let pending = this._pendingChatSpans.get(sessionId); + if (!pending) { + pending = []; + this._pendingChatSpans.set(sessionId, pending); + } + pending.push({ span, subagentId }); + return; + } if (operationName !== GenAiOperationName.INVOKE_AGENT || subagentId) { return; } @@ -726,6 +741,20 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr this._bufferEvents(sessionId, events); this._ensureFlushTimer(); } + + // Replay any CHAT spans that arrived before session initialization + if (operationName === GenAiOperationName.INVOKE_AGENT && !subagentId) { + const pendingChats = this._pendingChatSpans.get(sessionId); + if (pendingChats) { + this._pendingChatSpans.delete(sessionId); + for (const { span: chatSpan, subagentId: chatSubagentId } of pendingChats) { + const chatEvents = translateSpan(chatSpan, state, context, chatSubagentId); + if (chatEvents.length > 0) { + this._bufferEvents(sessionId, chatEvents); + } + } + } + } } catch { // Non-fatal — individual span processing failure } @@ -978,6 +1007,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr this._translationStates.delete(sessionId); this._disabledSessions.delete(sessionId); this._initializingSessions.delete(sessionId); + this._pendingChatSpans.delete(sessionId); // Keep _cloudSessions entry — the cloud session ID mapping is needed // for future delete operations (e.g. sidebar delete fires after dispose). }