feat: add push notifications for approval requests

- Add NotificationType enum and NotificationEvent interface
- Implement CloudAPI.notifyEvent() for backend communication
- Add CloudService.sendNotification() with user preference validation
- Integrate notifications in Task.ask() for tool/command/browser/MCP approvals
- Add notifications in askFollowupQuestionTool for follow-up questions
- Add notifications in attemptCompletionTool for task completion
- Add user settings configuration for enabling/disabling notification types
- Ensure non-blocking notifications that don't interrupt main functionality

Addresses #8721
This commit is contained in:
Roo Code 2025-10-18 17:16:07 +00:00
parent a8f87d2b1d
commit e043387fb4
6 changed files with 296 additions and 2 deletions

View file

@ -1,6 +1,12 @@
import { z } from "zod"
import { type AuthService, type ShareVisibility, type ShareResponse, shareResponseSchema } from "@roo-code/types"
import {
type AuthService,
type ShareVisibility,
type ShareResponse,
shareResponseSchema,
type NotificationEvent,
} from "@roo-code/types"
import { getRooCodeApiUrl } from "./config.js"
import { getUserAgent } from "./utils.js"
@ -134,4 +140,29 @@ export class CloudAPI {
.parse(data),
})
}
async notifyEvent(notification: NotificationEvent): Promise<{ success: boolean; error?: string }> {
this.log(`[CloudAPI] Sending notification for task ${notification.taskId}`)
try {
const response = await this.request("/api/extension/notifications", {
method: "POST",
body: JSON.stringify(notification),
parseResponse: (data) =>
z
.object({
success: z.boolean(),
error: z.string().optional(),
})
.parse(data),
})
this.log("[CloudAPI] Notification response:", response)
return response
} catch (error) {
this.log("[CloudAPI] Failed to send notification:", error)
// Return failure but don't throw - notifications are non-critical
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}
}

View file

@ -15,6 +15,7 @@ import type {
UserSettingsConfig,
UserSettingsData,
UserFeatures,
NotificationEvent,
} from "@roo-code/types"
import { TaskNotFoundError } from "./errors.js"
@ -336,6 +337,43 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements Di
return this.shareService!.canShareTask()
}
// Notifications
public async sendNotification(notification: NotificationEvent): Promise<{ success: boolean; error?: string }> {
this.ensureInitialized()
if (!this.cloudAPI) {
return { success: false, error: "CloudAPI not initialized" }
}
// Check if notifications are enabled for this type
const userSettings = this.getUserSettings()
const notificationSettings = userSettings?.settings?.notificationSettings
if (notificationSettings) {
// Check specific notification type settings
switch (notification.type) {
case "task_completion":
if (notificationSettings.taskCompletion === false) {
return { success: false, error: "Task completion notifications disabled" }
}
break
case "approval_required":
if (notificationSettings.approvalRequired === false) {
return { success: false, error: "Approval required notifications disabled" }
}
break
case "followup_question":
if (notificationSettings.followUpQuestions === false) {
return { success: false, error: "Follow-up question notifications disabled" }
}
break
}
}
return this.cloudAPI.notifyEvent(notification)
}
// Lifecycle
public dispose(): void {

View file

@ -174,6 +174,13 @@ export type UserFeatures = z.infer<typeof userFeaturesSchema>
export const userSettingsConfigSchema = z.object({
extensionBridgeEnabled: z.boolean().optional(),
taskSyncEnabled: z.boolean().optional(),
notificationSettings: z
.object({
taskCompletion: z.boolean().optional(),
approvalRequired: z.boolean().optional(),
followUpQuestions: z.boolean().optional(),
})
.optional(),
})
export type UserSettingsConfig = z.infer<typeof userSettingsConfigSchema>
@ -598,6 +605,41 @@ export const extensionBridgeCommandSchema = z.discriminatedUnion("type", [
export type ExtensionBridgeCommand = z.infer<typeof extensionBridgeCommandSchema>
/**
* NotificationType
*/
export enum NotificationType {
TaskCompletion = "task_completion",
ApprovalRequired = "approval_required",
FollowUpQuestion = "followup_question",
}
/**
* NotificationEvent
*/
export interface NotificationEvent {
type: NotificationType
taskId: string
userId?: string
organizationId?: string
title: string
message: string
timestamp: number
metadata?: {
question?: string
toolName?: string
mode?: string
result?: string
askType?: string
content?: string
isProtected?: boolean
tokenUsage?: Record<string, unknown>
suggestions?: Array<{ answer: string; mode?: string }>
}
}
/**
* TaskBridgeEvent
*/
@ -606,6 +648,7 @@ export enum TaskBridgeEventName {
Message = RooCodeEventName.Message,
TaskModeSwitched = RooCodeEventName.TaskModeSwitched,
TaskInteractive = RooCodeEventName.TaskInteractive,
NotificationRequested = "notification_requested",
}
export const taskBridgeEventSchema = z.discriminatedUnion("type", [
@ -624,6 +667,26 @@ export const taskBridgeEventSchema = z.discriminatedUnion("type", [
type: z.literal(TaskBridgeEventName.TaskInteractive),
taskId: z.string(),
}),
z.object({
type: z.literal(TaskBridgeEventName.NotificationRequested),
taskId: z.string(),
notification: z.object({
type: z.nativeEnum(NotificationType),
taskId: z.string(),
userId: z.string().optional(),
organizationId: z.string().optional(),
title: z.string(),
message: z.string(),
timestamp: z.number(),
metadata: z
.object({
question: z.string().optional(),
toolName: z.string().optional(),
mode: z.string().optional(),
})
.optional(),
}),
}),
])
export type TaskBridgeEvent = z.infer<typeof taskBridgeEventSchema>

View file

@ -35,6 +35,7 @@ import {
isInteractiveAsk,
isResumableAsk,
QueuedMessage,
NotificationType,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { CloudService, BridgeOrchestrator } from "@roo-code/cloud"
@ -796,6 +797,76 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected })
}
// Send notification for approval requests (except followup which is handled separately)
if (
!partial &&
CloudService.isEnabled() &&
(type === "tool" || type === "command" || type === "browser_action_launch" || type === "use_mcp_server")
) {
try {
const cloudService = CloudService.instance
if (cloudService) {
const taskMode = await this.getTaskMode()
// Determine notification type and message based on ask type
let notificationType: NotificationType = NotificationType.ApprovalRequired
let notificationMessage = "Roo needs your approval"
if (type === "command") {
notificationMessage = "Roo wants to execute a command"
} else if (type === "tool") {
notificationMessage = "Roo wants to use a tool"
} else if (type === "browser_action_launch") {
notificationMessage = "Roo wants to launch a browser action"
} else if (type === "use_mcp_server") {
notificationMessage = "Roo wants to use an MCP server"
}
// Extract tool/command details from the text if available
if (text) {
try {
const parsed = JSON.parse(text)
if (parsed.tool) {
notificationMessage = `Roo wants to use: ${parsed.tool}`
} else if (type === "command" && text) {
// For commands, the text is usually the command itself
const commandPreview = text.length > 50 ? text.substring(0, 50) + "..." : text
notificationMessage = `Roo wants to run: ${commandPreview}`
}
} catch {
// If not JSON, use the text directly for commands
if (type === "command" && text) {
const commandPreview = text.length > 50 ? text.substring(0, 50) + "..." : text
notificationMessage = `Roo wants to run: ${commandPreview}`
}
}
}
// Send notification event
await cloudService
.sendNotification({
timestamp: Date.now(),
type: notificationType,
message: notificationMessage,
taskId: this.taskId,
metadata: {
askType: type,
content: text,
mode: taskMode,
isProtected,
},
})
.catch((error) => {
// Log error but don't fail the ask operation
console.error("Failed to send approval notification:", error)
})
}
} catch (error) {
// Don't let notification errors block the ask operation
console.error("Error sending approval notification:", error)
}
}
// The state is mutable if the message is complete and the task will
// block (via the `pWaitFor`).
const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs)

View file

@ -2,6 +2,8 @@ import { Task } from "../task/Task"
import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
import { formatResponse } from "../prompts/responses"
import { parseXml } from "../../utils/xml"
import { NotificationType } from "@roo-code/types"
import { CloudService } from "@roo-code/cloud"
export async function askFollowupQuestionTool(
cline: Task,
@ -76,6 +78,38 @@ export async function askFollowupQuestionTool(
}
cline.consecutiveMistakeCount = 0
// Trigger push notification if enabled
try {
if (CloudService.hasInstance() && CloudService.instance.isAuthenticated()) {
const userInfo = CloudService.instance.getUserInfo()
const taskMode = await cline.getTaskMode()
// Send notification event
await CloudService.instance
.sendNotification({
type: NotificationType.FollowUpQuestion,
taskId: cline.taskId,
userId: userInfo?.id,
organizationId: userInfo?.organizationId,
title: "Roo needs your input",
message: question,
timestamp: Date.now(),
metadata: {
question,
mode: taskMode,
},
})
.catch((error) => {
// Log error but don't fail the tool
console.error("Failed to send notification:", error)
})
}
} catch (error) {
// Don't fail the tool if notification fails
console.error("Error checking notification settings:", error)
}
const { text, images } = await cline.ask("followup", JSON.stringify(follow_up_json), false)
await cline.say("user_feedback", text ?? "", images)
pushToolResult(formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images))

View file

@ -1,8 +1,9 @@
import Anthropic from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { RooCodeEventName } from "@roo-code/types"
import { RooCodeEventName, NotificationType } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { CloudService } from "@roo-code/cloud"
import { Task } from "../task/Task"
import {
@ -72,6 +73,34 @@ export async function attemptCompletionTool(
TelemetryService.instance.captureTaskCompleted(cline.taskId)
cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage)
// Send task completion notification
if (CloudService.isEnabled()) {
try {
const cloudService = CloudService.instance
if (cloudService) {
const taskMode = await cline.getTaskMode()
await cloudService
.sendNotification({
timestamp: Date.now(),
type: NotificationType.TaskCompletion,
title: "Task Completed",
message: "Task completed successfully",
taskId: cline.taskId,
metadata: {
result: result?.substring(0, 200), // Truncate long results
mode: taskMode,
tokenUsage: cline.getTokenUsage(),
},
})
.catch((error) => {
console.error("Failed to send task completion notification:", error)
})
}
} catch (error) {
console.error("Error sending task completion notification:", error)
}
}
await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {})
}
} else {
@ -95,6 +124,34 @@ export async function attemptCompletionTool(
TelemetryService.instance.captureTaskCompleted(cline.taskId)
cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage)
// Send task completion notification
if (CloudService.isEnabled()) {
try {
const cloudService = CloudService.instance
if (cloudService) {
const taskMode = await cline.getTaskMode()
await cloudService
.sendNotification({
timestamp: Date.now(),
type: NotificationType.TaskCompletion,
title: "Task Completed",
message: "Task completed successfully",
taskId: cline.taskId,
metadata: {
result: result.substring(0, 200), // Truncate long results
mode: taskMode,
tokenUsage: cline.getTokenUsage(),
},
})
.catch((error) => {
console.error("Failed to send task completion notification:", error)
})
}
} catch (error) {
console.error("Error sending task completion notification:", error)
}
}
if (cline.parentTask) {
const didApprove = await askFinishSubTaskApproval()