Skip to content

feat(telemetry): aggregate task completion telemetry with delta insta… - #1071

Draft
edelauna wants to merge 2 commits into
mainfrom
issue/830-3
Draft

feat(telemetry): aggregate task completion telemetry with delta insta…#1071
edelauna wants to merge 2 commits into
mainfrom
issue/830-3

Conversation

@edelauna

@edelauna edelauna commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Related GitHub Issue

Refs: #830 (task completion telemetry aggregation — split from #835; independent of #1069 and #1070)

Description

Replaces per-turn `Conversation Message` and `Tool Used` events with a single `Task Completed` installment per model-initiated `attempt_completion` call.

Delta invariants. Each installment carries only the delta in `toolsUsed` and `messageCount` since the prior installment for that task. Summing installments for a `taskId` reconstructs full-task totals without double-counting. An unchanged task produces no empty installment.

Completion reasons. `completionReason` is constrained to three values:

  • `"attempt_completion"` — the model called that tool (fires regardless of whether the user accepts, declines, or gives feedback)
  • `"idle"` — the task has been quiet for 30 minutes
  • `"shutdown"` — the task was disposed (panel closed, task switched, extension deactivated)

Idle and shutdown flushes. A 5-minute check interval fires an idle installment after 30 minutes of quiet. `dispose()` flushes any unreported activity as a shutdown installment. Neither path mutates `task.toolUsage` or `task.messageCounts` — those stay running totals for the public `RooCodeEventName.TaskCompleted` API event and the UI.

Stale replay suppression. When a user revisits a completed subtask from history, the handler runs again on a fresh `Task` instance with a zero baseline. The `isStaleHistoryReplay` flag (set when `historyItem.status === "completed"`) prevents that replay from emitting a duplicate installment or a duplicate public `TaskCompleted` event.

Public API preserved. `RooCodeEventName.TaskCompleted` still represents genuine task completion or acceptance. The PostHog telemetry flush is independent of it and fires earlier (on every model-initiated `attempt_completion`).

Retry symmetry. `messageCounts.user` decrements when `Task.ts` pops a message on an empty assistant response, and restores exactly once on the next successful attempt via `userMessageWasRemoved`. The manual-retry branch now also sets `userMessageWasRemoved: true` (previously it did not, causing the message count to stay at zero on a user-approved retry). The decline-to-retry branch increments both `messageCounts.user` and `messageCounts.assistant` to match the messages it re-adds directly.

Scope

Included:

  • `Task` message and tool-usage counters for completion telemetry
  • `Task Completed` payload additions: `completionReason`, `toolsUsed`, `messageCount`
  • Telemetry installment on every model-initiated `attempt_completion`
  • Idle (30 min) and shutdown installments for abandoned/long-running tasks
  • Delta/baseline tracking so multiple installments do not double-count
  • Empty-assistant-response retry accounting fixes for accurate message counts
  • Stale completed-subtask replay suppression

Excluded (per scope):

Test Procedure

  • `pnpm check-types` passes repo-wide
  • `pnpm lint` passes with no suppression count increases
  • New and updated tests:
    • `packages/telemetry/src/tests/TelemetryService.task-completed.test.ts` — `captureTaskCompleted` signature (default reason, with summaries, idle reason)
    • `src/core/task/tests/messageCounting.spec.ts` — `shouldAddUserMessageToHistory` predicate (7 cases)
    • `src/core/task/tests/messageCounting.retrySymmetry.spec.ts` — increment/decrement/re-increment symmetry across retry cycles (4 cases)
    • `src/core/task/tests/Task.spec.ts` — `flushTelemetryInstallment` (first/second/no-op installments, failure deltas, idle timer, dispose flush, disposed-task timer stop)
    • `src/core/tools/tests/attemptCompletionTool.spec.ts` — attempt completion before acceptance, feedback-instead-of-acceptance, stale replay suppression, no duplicate public `TaskCompleted` on replay

Pre-Submission Checklist

  • Issue Linked: This PR is linked to an approved GitHub Issue (see "Related GitHub Issue" above).
  • Scope: My changes are focused on the linked issue (one major feature/fix per PR).
  • Self-Review: I have performed a thorough self-review of my code.
  • Testing: New and/or updated tests have been added to cover my changes.
  • Documentation Impact: No documentation updates required for this PR.
  • Contribution Guidelines: I have read and agree to the Contributor Guidelines.

Summary by CodeRabbit

  • Improvements
    • Improved task completion reporting with more accurate completion reasons, tool usage, and message counts.
    • Added incremental activity tracking during tasks, including updates when tasks become idle or are shut down.
    • Improved conversation history handling during retries and empty responses to prevent missing or duplicate messages.
    • Prevented duplicate completion reporting when revisiting completed delegated tasks.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: db4ae72c-64cc-4416-9c11-a1d211087afa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The changes add incremental task telemetry for tool usage and message counts. Tasks flush telemetry on completion, idle, and shutdown. Completion handling separates telemetry reporting from public completion events and prevents duplicate reporting during stale history replays.

Changes

Task telemetry lifecycle

Layer / File(s) Summary
Completion telemetry contract
packages/telemetry/src/TelemetryService.ts, packages/telemetry/src/__tests__/TelemetryService.task-completed.test.ts
captureTaskCompleted accepts optional tool usage, message-count deltas, and completion reasons. Tests cover default and explicit values.
Task activity tracking and installment flushing
src/core/task/messageCounting.ts, src/core/task/Task.ts, src/core/task/__tests__/Task.spec.ts, src/core/task/__tests__/messageCounting*.spec.ts
Tasks track user and assistant messages, maintain retry-symmetric history counts, flush telemetry deltas during idle and shutdown, and avoid duplicate or empty installments.
Completion telemetry and public event separation
src/core/tools/AttemptCompletionTool.ts, src/core/tools/__tests__/attemptCompletionTool.spec.ts
Model-initiated completion attempts flush telemetry independently of acceptance. Stale replays avoid duplicate reporting, while public completion events remain acceptance-dependent. Tests cover delegation, acceptance, follow-up feedback, and replay behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: navedmerchant

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: aggregating task completion telemetry with delta installments.
Description check ✅ Passed The description explains the implementation, scope, testing, issue reference, and checklist; only optional sections and the visual snapshot checkbox are omitted.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue/830-3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (5)
src/core/tools/__tests__/attemptCompletionTool.spec.ts (1)

88-90: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider asserting on flushTelemetryInstallment instead of the payload this mock synthesizes.

The mock forwards mockTask.toolUsage and mockTask.messageCounts as-is, so the toolsUsed and messageCount arguments asserted throughout this file come from the mock, not from Task#flushTelemetryInstallment. Delta semantics are covered in Task.spec.ts. What this file needs to verify is that the tool calls the flush and passes "attempt_completion". Asserting expect(task.flushTelemetryInstallment).toHaveBeenCalledWith("attempt_completion") states that intent directly and stays correct if the payload shape changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/tools/__tests__/attemptCompletionTool.spec.ts` around lines 88 - 90,
Update the attempt-completion tests to assert directly on
task.flushTelemetryInstallment, verifying it is called with
"attempt_completion". Remove assertions on synthesized toolsUsed and
messageCount payloads from mockCaptureTaskCompleted, while preserving coverage
of the tool’s completion behavior.
src/core/task/Task.ts (1)

4777-4793: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The idle check re-fires every 5 minutes after the first idle flush, and lastTelemetryFlushAt never suppresses it.

lastMessageTs only advances on new activity. flushTelemetryInstallment updates lastTelemetryFlushAt, not lastMessageTs. After the first idle flush, idleForMs stays above IDLE_TELEMETRY_THRESHOLD_MS, so the interval calls flushTelemetryInstallment("idle") again every 5 minutes for the rest of the task's life. Each repeat no-ops on the empty-delta check, so no duplicate events are sent, but the comment at lines 4779-4783 states that this check "avoids waking up to do that check needlessly" — the condition never becomes false.

lastTelemetryFlushAt is also read only in the ?? fallback, so it has no effect whenever lastMessageTs is set. Compare against the later of the two timestamps so a completed flush actually resets the window.

♻️ Proposed fix
 	private startIdleTelemetryCheck(): void {
 		this.idleTelemetryCheckInterval = setInterval(() => {
-			// lastMessageTs only moves forward on activity, so comparing it against the
-			// last flush tells us whether anything happened since that flush -- if the
-			// task has been quiet since well before the last flush, there's nothing new
-			// to report and flushTelemetryInstallment's own empty-check would no-op anyway,
-			// but skipping here avoids waking up to do that check needlessly.
-			const idleForMs = Date.now() - (this.lastMessageTs ?? this.lastTelemetryFlushAt)
+			// Measure idleness from the later of the last activity and the last flush.
+			// Using lastMessageTs alone would keep this condition true forever after the
+			// first idle flush, re-running the empty-delta check on every interval tick.
+			const lastEventAt = Math.max(this.lastMessageTs ?? 0, this.lastTelemetryFlushAt)
+			const idleForMs = Date.now() - lastEventAt
 
 			if (idleForMs >= Task.IDLE_TELEMETRY_THRESHOLD_MS) {
 				this.flushTelemetryInstallment("idle")
 			}
 		}, Task.IDLE_TELEMETRY_CHECK_INTERVAL_MS)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/task/Task.ts` around lines 4777 - 4793, Update
startIdleTelemetryCheck to calculate idleForMs from the later of lastMessageTs
and lastTelemetryFlushAt, while preserving the fallback when neither timestamp
is set. Ensure a completed flush resets the idle threshold so the interval does
not repeatedly invoke flushTelemetryInstallment("idle") until new activity
occurs.
src/core/tools/AttemptCompletionTool.ts (1)

126-127: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

emitFinalTokenUsageUpdate() is already called inside flushTelemetryInstallment.

Task#flushTelemetryInstallment calls this.emitFinalTokenUsageUpdate() at Task.ts line 4769 before it captures the event. The explicit call here is redundant. The same duplication exists at lines 183-184. One difference: the internal call runs only when a delta exists, so removing the external calls also removes the forced token emit for a no-delta completion attempt. Decide which layer owns the responsibility and keep one call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/tools/AttemptCompletionTool.ts` around lines 126 - 127, Remove the
redundant emitFinalTokenUsageUpdate call from the completion flow in
AttemptCompletionTool, including the duplicate occurrence near the other
reported location, and retain flushTelemetryInstallment("attempt_completion") as
the single ownership point. Verify the resulting behavior still matches the
intended no-delta token usage handling.
src/core/task/__tests__/Task.spec.ts (1)

3121-3124: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Dispose each created Task in afterEach so the 5-minute interval does not outlive its test.

createTask() starts a real setInterval in the Task constructor. Only the three dispose tests clear it. The other seven tests leave a live interval that holds the Task instance and can invoke the shared captureTaskCompletedSpy while a later test in this file is running. That makes the suite order-dependent.

♻️ Proposed fix
+	const createdTasks: Task[] = []
+
 	afterEach(() => {
+		for (const task of createdTasks) {
+			task.dispose()
+		}
+		createdTasks.length = 0
 		vi.useRealTimers()
 		captureTaskCompletedSpy.mockRestore()
 	})
 
 	function createTask() {
-		return new Task({
+		const task = new Task({
 			provider: mockProvider,
 			apiConfiguration: mockApiConfig,
 			task: "test task",
 			startTask: false,
 		})
+		createdTasks.push(task)
+		return task
 	}

dispose() is idempotent for the interval, so the three dispose tests remain correct.

As per path instructions, **/*.{test,spec}.{ts,tsx,js}: "Use package-local unit tests for pure logic, parsing, state transitions, ...".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/task/__tests__/Task.spec.ts` around lines 3121 - 3124, Update the
Task test cleanup in afterEach to dispose every Task instance created by
createTask before restoring timers and mocks. Track created tasks within the
test setup and call each task’s idempotent dispose method during teardown,
preserving the existing dispose-test behavior.

Source: Path instructions

src/core/task/__tests__/messageCounting.retrySymmetry.spec.ts (1)

28-41: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

These tests re-implement the Task.ts counter steps, so they cannot catch a regression in Task.ts.

simulatePopOnEmptyResponse and simulateDeclineRetry hard-code messageCounts.user-- and messageCounts.user++. Only the add decision comes from production code. If someone removes this.messageCounts.user-- at Task.ts line 3701, or the this.messageCounts.user++ at line 3769, these tests still pass. Consider driving the empty-response retry cycle through Task#recursivelyMakeClineRequests with a stubbed stream, or extract the counter mutations into messageCounting.ts so both sides share one implementation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/task/__tests__/messageCounting.retrySymmetry.spec.ts` around lines
28 - 41, Replace the hard-coded counter mutations in simulatePopOnEmptyResponse
and simulateDeclineRetry with tests that exercise the production
Task#recursivelyMakeClineRequests retry flow using a stubbed stream, or move the
shared counter mutations into messageCounting.ts and test that implementation
directly. Ensure the tests fail if Task removes either the user decrement for an
empty response or the user increment during declined retry.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/task/Task.ts`:
- Around line 3695-3701: Guard the decrement in the retry cleanup around
apiConversationHistory.pop() so messageCounts.user is reduced only when this
method previously incremented it for the removed message, preserving the
existing restoration behavior otherwise. Also update flushTelemetryInstallment
to clamp both user and assistant message-count deltas to zero or greater before
emitting telemetry.

---

Nitpick comments:
In `@src/core/task/__tests__/messageCounting.retrySymmetry.spec.ts`:
- Around line 28-41: Replace the hard-coded counter mutations in
simulatePopOnEmptyResponse and simulateDeclineRetry with tests that exercise the
production Task#recursivelyMakeClineRequests retry flow using a stubbed stream,
or move the shared counter mutations into messageCounting.ts and test that
implementation directly. Ensure the tests fail if Task removes either the user
decrement for an empty response or the user increment during declined retry.

In `@src/core/task/__tests__/Task.spec.ts`:
- Around line 3121-3124: Update the Task test cleanup in afterEach to dispose
every Task instance created by createTask before restoring timers and mocks.
Track created tasks within the test setup and call each task’s idempotent
dispose method during teardown, preserving the existing dispose-test behavior.

In `@src/core/task/Task.ts`:
- Around line 4777-4793: Update startIdleTelemetryCheck to calculate idleForMs
from the later of lastMessageTs and lastTelemetryFlushAt, while preserving the
fallback when neither timestamp is set. Ensure a completed flush resets the idle
threshold so the interval does not repeatedly invoke
flushTelemetryInstallment("idle") until new activity occurs.

In `@src/core/tools/__tests__/attemptCompletionTool.spec.ts`:
- Around line 88-90: Update the attempt-completion tests to assert directly on
task.flushTelemetryInstallment, verifying it is called with
"attempt_completion". Remove assertions on synthesized toolsUsed and
messageCount payloads from mockCaptureTaskCompleted, while preserving coverage
of the tool’s completion behavior.

In `@src/core/tools/AttemptCompletionTool.ts`:
- Around line 126-127: Remove the redundant emitFinalTokenUsageUpdate call from
the completion flow in AttemptCompletionTool, including the duplicate occurrence
near the other reported location, and retain
flushTelemetryInstallment("attempt_completion") as the single ownership point.
Verify the resulting behavior still matches the intended no-delta token usage
handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 610a96d6-9c0b-4461-b893-f1da90e47d3b

📥 Commits

Reviewing files that changed from the base of the PR and between cd29243 and a060887.

📒 Files selected for processing (9)
  • packages/telemetry/src/TelemetryService.ts
  • packages/telemetry/src/__tests__/TelemetryService.task-completed.test.ts
  • src/core/task/Task.ts
  • src/core/task/__tests__/Task.spec.ts
  • src/core/task/__tests__/messageCounting.retrySymmetry.spec.ts
  • src/core/task/__tests__/messageCounting.spec.ts
  • src/core/task/messageCounting.ts
  • src/core/tools/AttemptCompletionTool.ts
  • src/core/tools/__tests__/attemptCompletionTool.spec.ts

Comment thread src/core/task/Task.ts Outdated
Comment on lines +3695 to +3701
// Remove the last user message that we added earlier. Decrement
// messageCounts.user to match -- both retry branches below mark
// userMessageWasRemoved so the message (and its count) is restored
// exactly once when the retry succeeds, keeping the total symmetric
// regardless of how many empty-response cycles occur first.
this.apiConversationHistory.pop()
this.messageCounts.user--

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

The decrement can be unmatched, which lets messageCounts.user go negative and ship a negative delta to telemetry.

The decrement runs whenever the last entry in apiConversationHistory has role "user". That entry is not always one this method counted:

  • flushPendingToolResultsToHistory() appends a user message at line 1009 and never increments messageCounts.user.
  • A resumed task starts with history loaded from disk while messageCounts starts at { user: 0, assistant: 0 }.

In a delegation resume, shouldAddUserMessageToHistory returns false for empty content, so no increment occurred this turn, yet the last history message is the flushed tool-result user message. The pop then drives the counter to -1.

flushTelemetryInstallment gates emission on messageCountDelta.user > 0, but it does not clamp the value. Once any tool delta exists, the negative messageCount.user is sent in the payload, and summing installments per taskId no longer reconstructs the turn count.

Guard the decrement so it only reverses an increment this method made.

🐛 Proposed fix
 					const state = await this.providerRef.deref()?.getState()
 					if (this.apiConversationHistory.length > 0) {
 						const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
 						if (lastMessage.role === "user") {
 							// Remove the last user message that we added earlier. Decrement
 							// messageCounts.user to match -- both retry branches below mark
 							// userMessageWasRemoved so the message (and its count) is restored
 							// exactly once when the retry succeeds, keeping the total symmetric
 							// regardless of how many empty-response cycles occur first.
 							this.apiConversationHistory.pop()
-							this.messageCounts.user--
+							// Only reverse a count this turn actually added. The popped message
+							// may predate this turn (resumed history, or a user message appended
+							// by flushPendingToolResultsToHistory, neither of which incremented).
+							if (shouldAddUserMessage) {
+								this.messageCounts.user--
+							}
 						}
 					}

As an additional safeguard, clamp both delta components in flushTelemetryInstallment so a negative value can never leave the process.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Remove the last user message that we added earlier. Decrement
// messageCounts.user to match -- both retry branches below mark
// userMessageWasRemoved so the message (and its count) is restored
// exactly once when the retry succeeds, keeping the total symmetric
// regardless of how many empty-response cycles occur first.
this.apiConversationHistory.pop()
this.messageCounts.user--
// Remove the last user message that we added earlier. Decrement
// messageCounts.user to match -- both retry branches below mark
// userMessageWasRemoved so the message (and its count) is restored
// exactly once when the retry succeeds, keeping the total symmetric
// regardless of how many empty-response cycles occur first.
this.apiConversationHistory.pop()
// Only reverse a count this turn actually added. The popped message
// may predate this turn (resumed history, or a user message appended
// by flushPendingToolResultsToHistory, neither of which incremented).
if (shouldAddUserMessage) {
this.messageCounts.user--
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/task/Task.ts` around lines 3695 - 3701, Guard the decrement in the
retry cleanup around apiConversationHistory.pop() so messageCounts.user is
reduced only when this method previously incremented it for the removed message,
preserving the existing restoration behavior otherwise. Also update
flushTelemetryInstallment to clamp both user and assistant message-count deltas
to zero or greater before emitting telemetry.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.48387% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/task/Task.ts 79.54% 8 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant