Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion packages/cloud/src/CloudAPI.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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) }
}
}
}
38 changes: 38 additions & 0 deletions packages/cloud/src/CloudService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
UserSettingsConfig,
UserSettingsData,
UserFeatures,
NotificationEvent,
} from "@roo-code/types"

import { TaskNotFoundError } from "./errors.js"
Expand Down Expand Up @@ -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 {
Expand Down
63 changes: 63 additions & 0 deletions packages/types/src/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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
*/
Expand All @@ -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", [
Expand All @@ -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>
Expand Down
72 changes: 72 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -796,6 +797,77 @@ 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,
title: "Approval Required",
message: notificationMessage,
taskId: this.taskId,
metadata: {
askType: type,
content: text,
mode: taskMode,
isProtected,
},
})
Comment on lines +846 to +859
Copy link
Author

Choose a reason for hiding this comment

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

Inconsistency in notification metadata: The approval notifications in Task.ts don't populate userId and organizationId fields, while the follow-up question notification in askFollowupQuestionTool.ts does (lines 85-94). For consistency and to ensure proper routing/filtering on the backend, all notification types should populate these fields. Consider adding:

const userInfo = CloudService.instance.getUserInfo()
// ... then in the notification object:
userId: userInfo?.id,
organizationId: userInfo?.organizationId,

.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)
Expand Down
34 changes: 34 additions & 0 deletions src/core/tools/askFollowupQuestionTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading