mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
a8f87d2b1d
commit
e043387fb4
6 changed files with 296 additions and 2 deletions
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue