@@ -135,6 +135,7 @@ import { MessageManager } from "../message-manager"
135135import { validateAndFixToolResultIds } from "./validateToolResultIds"
136136import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages"
137137import { prepareApiConversationMessage } from "./apiConversationHistory"
138+ import { shouldAddUserMessageToHistory } from "./messageCounting"
138139
139140const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
140141const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
@@ -322,6 +323,25 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
322323 consecutiveNoAssistantMessagesCount : number = 0
323324 toolUsage : ToolUsage = { }
324325
326+ // Conversation message counts, summarized once per Task Completed
327+ // installment instead of emitting a separate telemetry event per turn.
328+ messageCounts : { user : number ; assistant : number } = { user : 0 , assistant : 0 }
329+
330+ // Idle/shutdown telemetry flush: reports toolUsage/messageCounts for tasks that
331+ // go quiet or get torn down without the model ever calling attempt_completion
332+ // (or without the user accepting it), so long-running/abandoned tasks aren't
333+ // invisible to telemetry. Each flush reports only what changed since the previous
334+ // one, tracked via telemetryToolUsageBaseline/telemetryMessageCountsBaseline --
335+ // task.toolUsage/messageCounts themselves are never mutated by this, since they're
336+ // also read as running totals by the public TaskCompleted API event and the UI.
337+ // Checked on an interval rather than hooked into every say()/ask() call site.
338+ private static readonly IDLE_TELEMETRY_CHECK_INTERVAL_MS = 5 * 60 * 1000
339+ private static readonly IDLE_TELEMETRY_THRESHOLD_MS = 30 * 60 * 1000
340+ private idleTelemetryCheckInterval ?: NodeJS . Timeout
341+ private lastTelemetryFlushAt : number = Date . now ( )
342+ private telemetryToolUsageBaseline : ToolUsage = { }
343+ private telemetryMessageCountsBaseline : { user : number ; assistant : number } = { user : 0 , assistant : 0 }
344+
325345 // Checkpoints
326346 enableCheckpoints : boolean
327347 checkpointTimeout : number
@@ -597,6 +617,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
597617 { leading : true , trailing : true , maxWait : this . TOKEN_USAGE_EMIT_INTERVAL_MS } ,
598618 )
599619
620+ this . startIdleTelemetryCheck ( )
621+
600622 onCreated ?.( this )
601623
602624 if ( startTask ) {
@@ -2270,6 +2292,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
22702292 public dispose ( ) : void {
22712293 console . log ( `[Task#dispose] disposing task ${ this . taskId } .${ this . instanceId } ` )
22722294
2295+ // Stop the idle telemetry check and report any unflushed activity as a
2296+ // shutdown installment, so a task torn down mid-work (panel closed, task
2297+ // switched, extension deactivated) isn't invisible to telemetry.
2298+ try {
2299+ clearInterval ( this . idleTelemetryCheckInterval )
2300+ this . idleTelemetryCheckInterval = undefined
2301+ this . flushTelemetryInstallment ( "shutdown" )
2302+ } catch ( error ) {
2303+ console . error ( "Error flushing shutdown telemetry:" , error )
2304+ }
2305+
22732306 // Cancel any in-progress HTTP request
22742307 try {
22752308 this . cancelCurrentRequest ( )
@@ -2629,18 +2662,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
26292662 // Add environment details as its own text block, separate from tool
26302663 // results.
26312664 const finalUserContent = [ ...contentWithoutEnvDetails , { type : "text" as const , text : environmentDetails } ]
2632- // Only add user message to conversation history if:
2633- // 1. This is the first attempt (retryAttempt === 0), AND
2634- // 2. The original userContent was not empty (empty signals delegation resume where
2635- // the user message with tool_result and env details is already in history), OR
2636- // 3. The message was removed in a previous iteration (userMessageWasRemoved === true)
2637- // This prevents consecutive user messages while allowing re-add when needed
2665+ // See shouldAddUserMessageToHistory for the full add/skip rules (retry/empty/removed).
26382666 const isEmptyUserContent = currentUserContent . length === 0
2639- const shouldAddUserMessage =
2640- ( ( currentItem . retryAttempt ?? 0 ) === 0 && ! isEmptyUserContent ) || currentItem . userMessageWasRemoved
2667+ const shouldAddUserMessage = shouldAddUserMessageToHistory ( {
2668+ retryAttempt : currentItem . retryAttempt ,
2669+ isEmptyUserContent,
2670+ userMessageWasRemoved : currentItem . userMessageWasRemoved ,
2671+ } )
26412672 if ( shouldAddUserMessage ) {
26422673 await this . addToApiConversationHistory ( { role : "user" , content : finalUserContent } )
2643- TelemetryService . instance . captureConversationMessage ( this . taskId , " user" )
2674+ this . messageCounts . user ++
26442675 }
26452676
26462677 // Since we sent off a placeholder api_req_started message to update the
@@ -3557,7 +3588,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
35573588 )
35583589 this . assistantMessageSavedToHistory = true
35593590
3560- TelemetryService . instance . captureConversationMessage ( this . taskId , " assistant" )
3591+ this . messageCounts . assistant ++
35613592 }
35623593
35633594 // Present any partial blocks that were just completed.
@@ -3661,8 +3692,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
36613692 if ( this . apiConversationHistory . length > 0 ) {
36623693 const lastMessage = this . apiConversationHistory [ this . apiConversationHistory . length - 1 ]
36633694 if ( lastMessage . role === "user" ) {
3664- // Remove the last user message that we added earlier
3695+ // Remove the last user message that we added earlier. Decrement
3696+ // messageCounts.user to match -- both retry branches below mark
3697+ // userMessageWasRemoved so the message (and its count) is restored
3698+ // exactly once when the retry succeeds, keeping the total symmetric
3699+ // regardless of how many empty-response cycles occur first.
36653700 this . apiConversationHistory . pop ( )
3701+ this . messageCounts . user --
36663702 }
36673703 }
36683704
@@ -3706,32 +3742,45 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
37063742 if ( response === "yesButtonClicked" ) {
37073743 await this . say ( "api_req_retried" )
37083744
3709- // Push the same content back to retry
3745+ // Push the same content back to retry. Mark that user message was
3746+ // removed (same as the auto-retry path above) so it gets re-added --
3747+ // and messageCounts.user re-incremented -- on the retried attempt;
3748+ // otherwise shouldAddUserMessageToHistory sees retryAttempt > 0 with
3749+ // userMessageWasRemoved unset and skips re-adding it entirely.
37103750 stack . push ( {
37113751 userContent : currentUserContent ,
37123752 includeFileDetails : false ,
37133753 retryAttempt : ( currentItem . retryAttempt ?? 0 ) + 1 ,
3754+ userMessageWasRemoved : true ,
37143755 } )
37153756
37163757 // Continue to retry the request
37173758 continue
37183759 } else {
37193760 // User declined to retry
3720- // Re-add the user message we removed.
3761+ // Re-add the user message we removed (see messageCounts.user-- above)
3762+ // and increment messageCounts.user to match, same as the normal
3763+ // add-to-history path -- otherwise this abandoned-task path
3764+ // permanently undercounts by one.
37213765 await this . addToApiConversationHistory ( {
37223766 role : "user" ,
37233767 content : currentUserContent ,
37243768 } )
3769+ this . messageCounts . user ++
37253770
37263771 await this . say (
37273772 "error" ,
37283773 "Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output." ,
37293774 )
37303775
3776+ // Synthetic assistant message recording the failure -- increment
3777+ // messageCounts.assistant to match, same as the normal
3778+ // assistant-message-saved path.
37313779 await this . addToApiConversationHistory ( {
37323780 role : "assistant" ,
37333781 content : [ { type : "text" , text : "Failure: I did not provide a response." } ] ,
37343782 } )
3783+ this . messageCounts . assistant ++
37353784 }
37363785 }
37373786 }
@@ -4679,6 +4728,70 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
46794728 }
46804729 }
46814730
4731+ /**
4732+ * Emits a Task Completed installment for whatever toolUsage/messageCounts have
4733+ * changed since the previous installment (from any reason), then advances the
4734+ * telemetry baseline so a later installment reports only its own delta. Does
4735+ * NOT touch task.toolUsage/messageCounts themselves -- those stay running totals
4736+ * for the public TaskCompleted API event and the UI. No-ops if nothing changed
4737+ * since the last installment, so idle/shutdown checks don't emit empty events
4738+ * for tasks that were already fully reported (e.g. right after attempt_completion).
4739+ */
4740+ public flushTelemetryInstallment ( reason : "attempt_completion" | "idle" | "shutdown" ) : void {
4741+ const toolUsageDelta : ToolUsage = { }
4742+
4743+ for ( const [ toolName , usage ] of Object . entries ( this . toolUsage ) as [ ToolName , ToolUsage [ ToolName ] ] [ ] ) {
4744+ if ( ! usage ) {
4745+ continue
4746+ }
4747+
4748+ const baseline = this . telemetryToolUsageBaseline [ toolName ]
4749+ const attempts = usage . attempts - ( baseline ?. attempts ?? 0 )
4750+ const failures = usage . failures - ( baseline ?. failures ?? 0 )
4751+
4752+ if ( attempts > 0 || failures > 0 ) {
4753+ toolUsageDelta [ toolName ] = { attempts, failures }
4754+ }
4755+ }
4756+
4757+ const messageCountDelta = {
4758+ user : this . messageCounts . user - this . telemetryMessageCountsBaseline . user ,
4759+ assistant : this . messageCounts . assistant - this . telemetryMessageCountsBaseline . assistant ,
4760+ }
4761+
4762+ const hasToolUsageDelta = Object . keys ( toolUsageDelta ) . length > 0
4763+ const hasMessageDelta = messageCountDelta . user > 0 || messageCountDelta . assistant > 0
4764+
4765+ if ( ! hasToolUsageDelta && ! hasMessageDelta ) {
4766+ return
4767+ }
4768+
4769+ this . emitFinalTokenUsageUpdate ( )
4770+ TelemetryService . instance . captureTaskCompleted ( this . taskId , toolUsageDelta , messageCountDelta , reason )
4771+
4772+ this . telemetryToolUsageBaseline = JSON . parse ( JSON . stringify ( this . toolUsage ) )
4773+ this . telemetryMessageCountsBaseline = { ...this . messageCounts }
4774+ this . lastTelemetryFlushAt = Date . now ( )
4775+ }
4776+
4777+ private startIdleTelemetryCheck ( ) : void {
4778+ this . idleTelemetryCheckInterval = setInterval ( ( ) => {
4779+ // lastMessageTs only moves forward on activity, so comparing it against the
4780+ // last flush tells us whether anything happened since that flush -- if the
4781+ // task has been quiet since well before the last flush, there's nothing new
4782+ // to report and flushTelemetryInstallment's own empty-check would no-op anyway,
4783+ // but skipping here avoids waking up to do that check needlessly.
4784+ const idleForMs = Date . now ( ) - ( this . lastMessageTs ?? this . lastTelemetryFlushAt )
4785+
4786+ if ( idleForMs >= Task . IDLE_TELEMETRY_THRESHOLD_MS ) {
4787+ this . flushTelemetryInstallment ( "idle" )
4788+ }
4789+ } , Task . IDLE_TELEMETRY_CHECK_INTERVAL_MS )
4790+
4791+ // Don't hold the process open just for this timer.
4792+ this . idleTelemetryCheckInterval ?. unref ?.( )
4793+ }
4794+
46824795 // Getters
46834796
46844797 public get taskStatus ( ) : TaskStatus {
0 commit comments