From 000cdcf4a0299b86cacd0e61b1bafcea57ddc483 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 29 Jun 2026 21:50:08 +0100 Subject: [PATCH] feat(telemetry): instrument Agent Mode run lifecycle Refs GH-183 --- .../Runtime/AgentModeRunService.swift | 2 + .../Runtime/AgentRunSentryTelemetry.swift | 417 ++++++++++++++++++ .../AgentRunTerminalCommitBarrier.swift | 17 +- ...gentModeViewModel+InteractionActions.swift | 92 ++++ .../AgentModeViewModel+TabSession.swift | 11 + .../AgentModeViewModel+WorktreeMerge.swift | 14 + .../ViewModels/AgentModeViewModel.swift | 24 + 7 files changed, 575 insertions(+), 2 deletions(-) create mode 100644 Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunSentryTelemetry.swift diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentModeRunService.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentModeRunService.swift index f536b1346..94ea45208 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentModeRunService.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentModeRunService.swift @@ -181,6 +181,8 @@ final class AgentModeRunService { return selectedAgent == .codexExec ? .failed(message: message) : nil } + AgentRunSentryTelemetry.recordStarted(session: session, attachments: attachments) + if selectedAgent == .codexExec { return await codexRunner.startRun( tabID: tabID, diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunSentryTelemetry.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunSentryTelemetry.swift new file mode 100644 index 000000000..286705011 --- /dev/null +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunSentryTelemetry.swift @@ -0,0 +1,417 @@ +import Foundation + +enum AgentRunSentryTelemetry { + @MainActor private static var observedProviderEventRunKeys: Set = [] + + @MainActor + static func recordStarted( + session: AgentModeViewModel.TabSession, + attachments: [AgentImageAttachment] + ) { + #if REPOPROMPT_SENTRY_ENABLED + let attributes = baseAttributes(for: session, outcome: .started) + + [.attachmentCount(attachments.count)] + SentryTelemetryBootstrap.addBreadcrumb( + .agentRun, + action: .agentRunStarted, + attributes: attributes + ) + SentryTelemetryBootstrap.increment( + .agentRunSessionStarts, + attributes: attributes + ) + SentryTelemetryBootstrap.gauge(.agentRunActive, value: 1, attributes: attributes) + #endif + } + + @MainActor + static func recordTerminal( + session: AgentModeViewModel.TabSession, + terminalState: AgentSessionRunState + ) { + #if REPOPROMPT_SENTRY_ENABLED + guard let action = action(for: terminalState), + let outcome = outcome(for: terminalState) + else { return } + + let attributes = runSummaryAttributes(for: session, outcome: outcome) + observedProviderEventRunKeys.remove(providerEventRunKey(for: session)) + SentryTelemetryBootstrap.addBreadcrumb( + .agentRun, + action: action, + attributes: attributes + ) + SentryTelemetryBootstrap.gauge(.agentRunActive, value: 0, attributes: attributes) + if let startedAt = session.activeAgentRunStartedAt { + SentryTelemetryBootstrap.distributionMilliseconds( + .agentRunDuration, + value: max(0, Date().timeIntervalSince(startedAt) * 1000), + attributes: attributes + ) + } + #endif + } + + @MainActor + static func recordItemAppended(session: AgentModeViewModel.TabSession, item: AgentChatItem) { + #if REPOPROMPT_SENTRY_ENABLED + recordProviderFirstEventIfNeeded(session: session) + recordMessageObserved(session: session, item: item) + switch item.kind { + case .toolCall: + recordToolStarted(session: session, item: item) + case .toolResult: + recordToolTerminal(session: session, item: item) + case .assistant, .assistantInline, .error, .system, .thinking, .user: + break + } + #endif + } + + @MainActor + static func recordItemReplaced( + session: AgentModeViewModel.TabSession, + previousItem: AgentChatItem, + updatedItem: AgentChatItem + ) { + #if REPOPROMPT_SENTRY_ENABLED + if previousItem.kind != .toolCall, updatedItem.kind == .toolCall { + recordToolStarted(session: session, item: updatedItem) + } + if previousItem.kind != .toolResult, updatedItem.kind == .toolResult { + recordToolTerminal(session: session, item: updatedItem) + } + #endif + } + + @MainActor + static func recordApprovalDecision( + session: AgentModeViewModel.TabSession, + kind: SentryTelemetryBootstrap.ApprovalKind, + outcome: SentryTelemetryBootstrap.ApprovalOutcome, + cancellationReason: SentryTelemetryBootstrap.CancellationReason? = nil + ) { + #if REPOPROMPT_SENTRY_ENABLED + var attributes = baseAttributes(for: session, outcome: outcome == .approved ? .accepted : .rejected) + attributes.append(.approvalKind(kind)) + attributes.append(.approvalOutcome(outcome)) + attributes.append(.messageRole(.tool)) + let toolName = toolName(for: kind) + attributes.append(.toolDomain(toolName.domain)) + attributes.append(.toolName(toolName)) + if let cancellationReason { + attributes.append(.cancellationReason(cancellationReason)) + } + SentryTelemetryBootstrap.addBreadcrumb( + .agentTool, + action: outcome == .approved ? .agentToolCompleted : .agentToolFailed, + attributes: attributes + ) + #endif + } + + @MainActor + static func recordRuntimeEvent( + session: AgentModeViewModel.TabSession, + event: SentryTelemetryBootstrap.RuntimeEvent, + action: SentryTelemetryBootstrap.Action, + outcome: SentryTelemetryBootstrap.Outcome + ) { + #if REPOPROMPT_SENTRY_ENABLED + let attributes = baseAttributes(for: session, outcome: outcome) + [.runtimeEvent(event)] + SentryTelemetryBootstrap.addBreadcrumb( + .agentRuntime, + action: action, + attributes: attributes + ) + SentryTelemetryBootstrap.increment(.agentRuntimeEvents, attributes: attributes) + #endif + } + + @MainActor + static func recordProviderError( + session: AgentModeViewModel.TabSession, + kind: SentryTelemetryBootstrap.ProviderErrorKind + ) { + #if REPOPROMPT_SENTRY_ENABLED + let attributes = baseAttributes(for: session, outcome: .failed) + [.providerErrorKind(kind)] + SentryTelemetryBootstrap.addBreadcrumb( + .agentRuntime, + action: .agentProviderError, + attributes: attributes + ) + SentryTelemetryBootstrap.increment(.agentProviderErrors, attributes: attributes) + #endif + } + + @MainActor + static func recordProviderError( + session: AgentModeViewModel.TabSession, + error: Error + ) { + #if REPOPROMPT_SENTRY_ENABLED + recordProviderError(session: session, kind: providerErrorKind(for: error)) + #endif + } + + #if REPOPROMPT_SENTRY_ENABLED + @MainActor + private static func recordProviderFirstEventIfNeeded(session: AgentModeViewModel.TabSession) { + guard observedProviderEventRunKeys.insert(providerEventRunKey(for: session)).inserted else { return } + SentryTelemetryBootstrap.span(.agentProviderFirstEvent, attributes: baseAttributes(for: session, outcome: .completed)) {} + } + + @MainActor + private static func providerEventRunKey(for session: AgentModeViewModel.TabSession) -> String { + if let attemptID = session.activeRunOwnership?.attemptID { + return attemptID.uuidString + } + if let runID = session.runID { + return runID.uuidString + } + return session.tabID.uuidString + } + + @MainActor + private static func recordMessageObserved(session: AgentModeViewModel.TabSession, item: AgentChatItem) { + guard let role = messageRole(for: item.kind) else { return } + SentryTelemetryBootstrap.addBreadcrumb( + .agentMessage, + action: .agentMessageObserved, + attributes: baseAttributes(for: session, outcome: .completed) + + [.messageRole(role)] + ) + } + + @MainActor + private static func recordToolStarted(session: AgentModeViewModel.TabSession, item: AgentChatItem) { + let attributes = toolAttributes(for: session, item: item, outcome: .started, isError: false) + SentryTelemetryBootstrap.addBreadcrumb( + .agentTool, + action: .agentToolStarted, + attributes: attributes + ) + } + + @MainActor + private static func recordToolTerminal(session: AgentModeViewModel.TabSession, item: AgentChatItem) { + let failed = item.toolIsError == true + let attributes = toolAttributes( + for: session, + item: item, + outcome: failed ? .failed : .completed, + isError: failed + ) + SentryTelemetryBootstrap.addBreadcrumb( + .agentTool, + action: failed ? .agentToolFailed : .agentToolCompleted, + attributes: attributes + ) + } + + @MainActor + private static func baseAttributes( + for session: AgentModeViewModel.TabSession, + outcome: SentryTelemetryBootstrap.Outcome + ) -> [SentryTelemetryBootstrap.Attribute] { + [ + .entrypoint(session.mcpControlContext == nil ? .user : .mcp), + .clientClass(session.mcpControlContext == nil ? .inApp : .externalAgent), + .providerKind(SentryTelemetryBootstrap.ProviderKind(agentKind: session.selectedAgent)), + .modelFamily(modelFamily(for: session.selectedAgent, modelRaw: session.selectedModelRaw)), + .outcome(outcome), + .isChildSession(session.parentSessionID != nil), + .hasProviderResumeSession(session.providerSessionID != nil) + ] + } + + @MainActor + private static func runSummaryAttributes( + for session: AgentModeViewModel.TabSession, + outcome: SentryTelemetryBootstrap.Outcome + ) -> [SentryTelemetryBootstrap.Attribute] { + baseAttributes(for: session, outcome: outcome) + + [ + .messageCount(session.items.count), + .toolCallCount(session.items.count(where: { $0.kind == .toolCall })) + ] + } + + @MainActor + private static func toolAttributes( + for session: AgentModeViewModel.TabSession, + item: AgentChatItem, + outcome: SentryTelemetryBootstrap.Outcome, + isError: Bool + ) -> [SentryTelemetryBootstrap.Attribute] { + var attributes = baseAttributes(for: session, outcome: outcome) + attributes.append(.messageRole(.tool)) + attributes.append(.isError(isError)) + if let rawToolName = item.toolName, + let toolName = SentryTelemetryBootstrap.ToolName(rawToolName: rawToolName) + { + attributes.append(.toolName(toolName)) + attributes.append(.toolDomain(toolName.domain)) + } + return attributes + } + + private static func action( + for terminalState: AgentSessionRunState + ) -> SentryTelemetryBootstrap.Action? { + switch terminalState { + case .completed: .agentRunCompleted + case .cancelled: .agentRunCancelled + case .failed: .agentRunFailed + case .idle, .running, .waitingForUser, .waitingForQuestion, .waitingForApproval: + nil + } + } + + private static func outcome( + for terminalState: AgentSessionRunState + ) -> SentryTelemetryBootstrap.Outcome? { + switch terminalState { + case .completed: .completed + case .cancelled: .cancelled + case .failed: .failed + case .idle, .running, .waitingForUser, .waitingForQuestion, .waitingForApproval: + nil + } + } + + private static func messageRole(for kind: AgentChatItemKind) -> SentryTelemetryBootstrap.MessageRole? { + switch kind { + case .assistant, .assistantInline, .thinking: + .assistant + case .error, .system: + .system + case .toolCall, .toolResult: + .tool + case .user: + .user + } + } + + private static func modelFamily( + for agentKind: AgentProviderKind, + modelRaw: String + ) -> SentryTelemetryBootstrap.ModelFamily { + let trimmed = modelRaw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.caseInsensitiveCompare(AgentModel.defaultModel.rawValue) == .orderedSame { + return .defaultModel + } + if let model = AgentModel.resolvedModel(forRaw: trimmed, agentKind: agentKind) { + return modelFamily(for: model) + } + return fallbackModelFamily(for: agentKind, modelRaw: trimmed) + } + + private static func modelFamily(for model: AgentModel) -> SentryTelemetryBootstrap.ModelFamily { + switch model { + case .defaultModel: + .defaultModel + case .codexMini, .gpt54MiniLow, .gpt54MiniMedium, .gpt54MiniHigh: + .gptMini + case .gpt55CodexLow, .gpt55CodexMedium, .gpt55CodexHigh, .gpt55CodexXHigh: + .gpt55 + case .codexLow, .codexMedium, .codexHigh, .codexXHigh: + .gpt53Codex + case .gpt54Low, .gpt54Medium, .gpt54High, .gpt54XHigh: + .gpt54 + case .gpt5Low, .gpt5Medium, .gpt5High, .gpt5XHigh: + .gpt52 + case .claudeFable5: + .fable + case .claudeOpus, .claudeOpus1m, .claudeOpus45, .claudeOpus46, .claudeOpus47: + .opus + case .claudeSonnet, .claudeSonnet45, .claudeSonnet46: + .sonnet + case .claudeHaiku, .claudeHaiku45: + .haiku + case .glm45Air, .glm47, .glm52, .glm52_1m, .glm5Turbo, .glm5: + .glm + case .kimiCode: + .kimi + case .customClaudeCompatible: + .customClaudeCompatible + case .cursorAuto, .cursorComposer2: + .cursor + } + } + + private static func fallbackModelFamily( + for agentKind: AgentProviderKind, + modelRaw: String + ) -> SentryTelemetryBootstrap.ModelFamily { + let lowercasedRaw = modelRaw.lowercased() + switch agentKind { + case .codexExec: + if lowercasedRaw.contains("mini") { return .gptMini } + if lowercasedRaw.contains("codex") { return .codex } + if lowercasedRaw.hasPrefix("gpt-") { return .gpt } + return .codex + case .claudeCode: + if lowercasedRaw.contains("opus") { return .opus } + if lowercasedRaw.contains("sonnet") { return .sonnet } + if lowercasedRaw.contains("haiku") { return .haiku } + if lowercasedRaw.contains("fable") { return .fable } + return .claude + case .claudeCodeGLM: + return .glm + case .kimiCode: + return .kimi + case .customClaudeCompatible: + return .customClaudeCompatible + case .cursor: + return .cursor + case .openCode: + return .openCode + } + } + + private static func providerErrorKind(for error: Error) -> SentryTelemetryBootstrap.ProviderErrorKind { + if error is CancellationError { + return .cancelled + } + if let providerError = error as? AIProviderError { + switch providerError { + case .missingAPIKey: + return .missingCredential + case .missingOllamaURL, .missingURL: + return .missingProviderURL + case .providerNotConfigured: + return .providerNotConfigured + case .invalidConfiguration, .missingAzureConfiguration, .invalidModel, .invalidSystemPrompt, + .messageCreationFailed: + return .invalidConfiguration + case .invalidResponse: + return .invalidResponse + case .apiError: + return .apiError + case .unknown: + return .unknown + } + } + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorTimedOut { + return .timeout + } + return .unknown + } + + private static func toolName( + for approvalKind: SentryTelemetryBootstrap.ApprovalKind + ) -> SentryTelemetryBootstrap.ToolName { + switch approvalKind { + case .applyEdits: + .applyEdits + case .worktreeMerge: + .manageWorktree + case .commandExecution, .fileChange, .toolPermission: + .agentRun + } + } + + #endif +} diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunTerminalCommitBarrier.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunTerminalCommitBarrier.swift index 8fa5553b6..c657601ca 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunTerminalCommitBarrier.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunTerminalCommitBarrier.swift @@ -258,8 +258,21 @@ final class AgentRunTerminalCommitBarrier { session.clearClaudeReasoningStatus(clearDisplayedStatus: true) session.setRunningStatus(nil, source: nil) session.waitingPrompt = nil - session.runState = request.terminalState - _ = session.endRunAttempt(ifCurrent: request.ownership, source: request.source) + await SentryTelemetryBootstrap.spanAsync( + .agentTerminalCommit, + attributes: [ + .entrypoint(session.mcpControlContext == nil ? .user : .mcp), + .clientClass(session.mcpControlContext == nil ? .inApp : .externalAgent), + .providerKind(SentryTelemetryBootstrap.ProviderKind(agentKind: session.selectedAgent)), + .outcome(request.terminalState == .completed ? .completed : request.terminalState == .cancelled ? .cancelled : .failed), + .isChildSession(session.parentSessionID != nil), + .hasProviderResumeSession(session.providerSessionID != nil) + ] + ) { _ in + session.runState = request.terminalState + _ = session.endRunAttempt(ifCurrent: request.ownership, source: request.source) + AgentRunSentryTelemetry.recordTerminal(session: session, terminalState: request.terminalState) + } hooks.setAgentRunActive(session.tabID, false) hooks.prepareTerminalPublication(session) if let runID = request.expectedRunID, let terminalTurnID { diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+InteractionActions.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+InteractionActions.swift index 430b10d06..911a8cec3 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+InteractionActions.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+InteractionActions.swift @@ -8,6 +8,12 @@ extension AgentModeViewModel { else { return } + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: telemetryApprovalKind(for: request.kind), + outcome: telemetryApprovalOutcome(for: decision), + cancellationReason: telemetryCancellationReason(for: decision) + ) switch request.requestID { case .codex: codexCoordinator.submitApprovalDecision(session: session, decision: decision) @@ -44,6 +50,14 @@ extension AgentModeViewModel { reviewID: UUID, decision: ApplyEditsReviewDecision ) { + if let session = sessions[tabID], session.pendingApplyEditsReview?.id == reviewID { + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: .applyEdits, + outcome: telemetryApprovalOutcome(for: decision), + cancellationReason: telemetryCancellationReason(for: decision) + ) + } let scope = applyEditsScope(for: tabID) Task { [applyEditsApprovalStore] in await applyEditsApprovalStore.resolveReview( @@ -53,4 +67,82 @@ extension AgentModeViewModel { ) } } + + func telemetryApprovalKind(for kind: AgentApprovalKind) -> SentryTelemetryBootstrap.ApprovalKind { + switch kind { + case .commandExecution: + .commandExecution + case .fileChange: + .fileChange + } + } + + func telemetryApprovalOutcome(for decision: AgentApprovalDecision) -> SentryTelemetryBootstrap.ApprovalOutcome { + switch decision { + case .accept, .acceptForSession, .acceptWithExecpolicyAmendment: + .approved + case .decline, .cancel: + .denied + } + } + + func telemetryCancellationReason(for decision: AgentApprovalDecision) -> SentryTelemetryBootstrap.CancellationReason? { + switch decision { + case .cancel: + .user + case .accept, .acceptForSession, .acceptWithExecpolicyAmendment, .decline: + nil + } + } + + func telemetryApprovalOutcome(for decision: ApplyEditsReviewDecision) -> SentryTelemetryBootstrap.ApprovalOutcome { + switch decision { + case .accept: + .approved + case .reject, .timeout, .cancelled: + .denied + } + } + + func telemetryCancellationReason(for decision: ApplyEditsReviewDecision) -> SentryTelemetryBootstrap.CancellationReason? { + switch decision { + case .timeout: + .timeout + case .cancelled: + .user + case .accept, .reject: + nil + } + } + + func telemetryApprovalOutcome(for decision: WorktreeMergeReviewDecision) -> SentryTelemetryBootstrap.ApprovalOutcome { + switch decision { + case .accept: + .approved + case .reject, .timeout, .cancelled: + .denied + } + } + + func telemetryCancellationReason(for decision: WorktreeMergeReviewDecision) -> SentryTelemetryBootstrap.CancellationReason? { + switch decision { + case .timeout: + .timeout + case .cancelled: + .user + case .accept, .reject: + nil + } + } + + func telemetryCancellationReason(forApprovalCancellationReason reason: String) -> SentryTelemetryBootstrap.CancellationReason { + let normalized = reason.lowercased() + if normalized.contains("replaced") || normalized.contains("newer") || normalized.contains("superseded") { + return .superseded + } + if normalized.contains("timed out") || normalized.contains("timeout") { + return .timeout + } + return .user + } } diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift index 428b01456..fe3eb4549 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift @@ -1439,6 +1439,7 @@ extension AgentModeViewModel { reconcileIncrementalEphemeralPayload(previousItem: nil, updatedItem: newItem) appendToolCorrelationIndexes(for: newItem, at: appendedIndex) finishIncrementalSourceItemsMutation(.append(index: appendedIndex, itemKind: newItem.kind)) + AgentRunSentryTelemetry.recordItemAppended(session: self, item: newItem) if newItem.kind == .user { hasSentFirstMessage = true lastUserMessageAt = newItem.timestamp @@ -1460,6 +1461,11 @@ extension AgentModeViewModel { finishIncrementalSourceItemsMutation( .replace(index: index, previousKind: previousItem.kind, currentKind: updatedItem.kind) ) + AgentRunSentryTelemetry.recordItemReplaced( + session: self, + previousItem: previousItem, + updatedItem: updatedItem + ) lastActivityAt = Date() isDirty = true } @@ -1477,6 +1483,11 @@ extension AgentModeViewModel { reconcileIncrementalEphemeralPayload(previousItem: previousItem, updatedItem: updatedItem) updateToolCorrelationIndexes(previousItem: previousItem, updatedItem: updatedItem, at: index) finishIncrementalSourceItemsMutation(.mutate(index: index, itemKind: updatedItem.kind)) + AgentRunSentryTelemetry.recordItemReplaced( + session: self, + previousItem: previousItem, + updatedItem: updatedItem + ) lastActivityAt = Date() isDirty = true } diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+WorktreeMerge.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+WorktreeMerge.swift index 149f11877..8d19d92d6 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+WorktreeMerge.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+WorktreeMerge.swift @@ -572,6 +572,12 @@ extension AgentModeViewModel { session.pendingWorktreeMergeReview?.id == reviewID, let continuation = session.worktreeMergeReviewContinuation else { return } + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: .worktreeMerge, + outcome: telemetryApprovalOutcome(for: decision), + cancellationReason: telemetryCancellationReason(for: decision) + ) finishPendingWorktreeMergeReview(session: session) continuation.resume(returning: decision) } @@ -594,6 +600,14 @@ extension AgentModeViewModel { func cancelPendingWorktreeMergeReview(for session: TabSession, reason: String) { let operationID = session.pendingWorktreeMergeReview?.operationID + if session.pendingWorktreeMergeReview != nil { + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: .worktreeMerge, + outcome: .denied, + cancellationReason: telemetryCancellationReason(forApprovalCancellationReason: reason) + ) + } guard let continuation = session.worktreeMergeReviewContinuation else { finishPendingWorktreeMergeReview(session: session) if let operationID { diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift index fd4891145..e50aaf918 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift @@ -15010,6 +15010,22 @@ final class AgentModeViewModel: ObservableObject { } private func cancelPendingApproval(for session: TabSession) { + if let pendingApproval = session.pendingApproval { + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: telemetryApprovalKind(for: pendingApproval.kind), + outcome: .denied, + cancellationReason: .user + ) + } + if session.pendingPermissionsRequest != nil { + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: .toolPermission, + outcome: .denied, + cancellationReason: .user + ) + } session.pendingApproval = nil session.pendingPermissionsRequest = nil session.pendingMCPElicitationRequest = nil @@ -15019,6 +15035,14 @@ final class AgentModeViewModel: ObservableObject { } private func cancelPendingApplyEditsReview(for session: TabSession, reason: String) { + if session.pendingApplyEditsReview != nil { + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: .applyEdits, + outcome: .denied, + cancellationReason: telemetryCancellationReason(forApprovalCancellationReason: reason) + ) + } session.pendingApplyEditsReview = nil let scope = applyEditsScope(for: session.tabID) Task { [applyEditsApprovalStore] in