Ask separately for confirmation (#6949)
Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -6,7 +6,7 @@ use goose::config::ExtensionEntry;
|
||||
use goose::conversation::Conversation;
|
||||
use goose::dictation::download_manager::{DownloadProgress, DownloadStatus};
|
||||
use goose::model::ModelConfig;
|
||||
use goose::permission::permission_confirmation::PrincipalType;
|
||||
use goose::permission::permission_confirmation::{Permission, PrincipalType};
|
||||
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType};
|
||||
use goose::session::{Session, SessionInsights, SessionType, SystemInfo};
|
||||
use rmcp::model::{
|
||||
@@ -498,6 +498,7 @@ derive_utoipa!(Icon as IconSchema);
|
||||
ToolAnnotationsSchema,
|
||||
ToolInfo,
|
||||
PermissionLevel,
|
||||
Permission,
|
||||
PrincipalType,
|
||||
ModelInfo,
|
||||
ModelConfig,
|
||||
|
||||
@@ -14,7 +14,7 @@ pub struct ConfirmToolActionRequest {
|
||||
id: String,
|
||||
#[serde(default = "default_principal_type")]
|
||||
principal_type: PrincipalType,
|
||||
action: String,
|
||||
action: Permission,
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
@@ -37,19 +37,13 @@ pub async fn confirm_tool_action(
|
||||
Json(request): Json<ConfirmToolActionRequest>,
|
||||
) -> Result<Json<Value>, ErrorResponse> {
|
||||
let agent = state.get_agent_for_route(request.session_id).await?;
|
||||
let permission = match request.action.as_str() {
|
||||
"always_allow" => Permission::AlwaysAllow,
|
||||
"allow_once" => Permission::AllowOnce,
|
||||
"deny" => Permission::DenyOnce,
|
||||
_ => Permission::DenyOnce,
|
||||
};
|
||||
|
||||
agent
|
||||
.handle_confirmation(
|
||||
request.id.clone(),
|
||||
PermissionConfirmation {
|
||||
principal_type: request.principal_type,
|
||||
permission,
|
||||
permission: request.action,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -91,7 +85,7 @@ mod tests {
|
||||
serde_json::to_string(&ConfirmToolActionRequest {
|
||||
id: "test-id".to_string(),
|
||||
principal_type: PrincipalType::Tool,
|
||||
action: "allow_once".to_string(),
|
||||
action: Permission::AllowOnce,
|
||||
session_id: "test-session".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Permission {
|
||||
AlwaysAllow,
|
||||
AllowOnce,
|
||||
|
||||
+11
-1
@@ -3555,7 +3555,7 @@
|
||||
],
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string"
|
||||
"$ref": "#/components/schemas/Permission"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
@@ -5240,6 +5240,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Permission": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"always_allow",
|
||||
"allow_once",
|
||||
"cancel",
|
||||
"deny_once",
|
||||
"always_deny"
|
||||
]
|
||||
},
|
||||
"PermissionLevel": {
|
||||
"type": "string",
|
||||
"description": "Enum representing the possible permission levels for a tool.",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -112,7 +112,7 @@ export type ConfigResponse = {
|
||||
};
|
||||
|
||||
export type ConfirmToolActionRequest = {
|
||||
action: string;
|
||||
action: Permission;
|
||||
id: string;
|
||||
principalType?: PrincipalType;
|
||||
sessionId: string;
|
||||
@@ -662,6 +662,8 @@ export type ParseRecipeResponse = {
|
||||
recipe: Recipe;
|
||||
};
|
||||
|
||||
export type Permission = 'always_allow' | 'allow_once' | 'cancel' | 'deny_once' | 'always_deny';
|
||||
|
||||
/**
|
||||
* Enum representing the possible permission levels for a tool.
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
getToolConfirmationContent,
|
||||
getElicitationContent,
|
||||
getPendingToolConfirmationIds,
|
||||
getAnyToolConfirmationData,
|
||||
ToolConfirmationData,
|
||||
NotificationEvent,
|
||||
} from '../types/message';
|
||||
import { Message } from '../api';
|
||||
@@ -26,7 +28,7 @@ interface GooseMessageProps {
|
||||
metadata?: string[];
|
||||
toolCallNotifications: Map<string, NotificationEvent[]>;
|
||||
append: (value: string) => void;
|
||||
isStreaming?: boolean; // Whether this message is currently being streamed
|
||||
isStreaming: boolean;
|
||||
submitElicitationResponse?: (
|
||||
elicitationId: string,
|
||||
userData: Record<string, unknown>
|
||||
@@ -39,7 +41,7 @@ export default function GooseMessage({
|
||||
messages,
|
||||
toolCallNotifications,
|
||||
append,
|
||||
isStreaming = false,
|
||||
isStreaming,
|
||||
submitElicitationResponse,
|
||||
}: GooseMessageProps) {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -69,6 +71,18 @@ export default function GooseMessage({
|
||||
const messageIndex = messages.findIndex((msg) => msg.id === message.id);
|
||||
const toolConfirmationContent = getToolConfirmationContent(message);
|
||||
const elicitationContent = getElicitationContent(message);
|
||||
|
||||
const findConfirmationForToolAcrossMessages = (
|
||||
toolRequestId: string
|
||||
): ToolConfirmationData | undefined => {
|
||||
for (const msg of messages) {
|
||||
const confirmationData = getAnyToolConfirmationData(msg);
|
||||
if (confirmationData && confirmationData.id === toolRequestId) {
|
||||
return confirmationData;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const toolCallChains = useMemo(() => identifyConsecutiveToolCalls(messages), [messages]);
|
||||
const hideTimestamp = useMemo(
|
||||
() => shouldHideTimestamp(messageIndex, toolCallChains),
|
||||
@@ -77,6 +91,20 @@ export default function GooseMessage({
|
||||
const hasToolConfirmation = toolConfirmationContent !== undefined;
|
||||
const hasElicitation = elicitationContent !== undefined;
|
||||
|
||||
const toolConfirmationShownInline = useMemo(() => {
|
||||
if (!toolConfirmationContent) return false;
|
||||
const confirmationData = getAnyToolConfirmationData(message);
|
||||
if (!confirmationData) return false;
|
||||
|
||||
for (const msg of messages) {
|
||||
const requests = getToolRequests(msg);
|
||||
if (requests.some((req) => req.id === confirmationData.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [toolConfirmationContent, message, messages]);
|
||||
|
||||
const toolResponsesMap = useMemo(() => {
|
||||
const responseMap = new Map();
|
||||
|
||||
@@ -149,20 +177,28 @@ export default function GooseMessage({
|
||||
<div className={cn(displayText && 'mt-2')}>
|
||||
<div className="relative flex flex-col w-full">
|
||||
<div className="flex flex-col gap-3">
|
||||
{toolRequests.map((toolRequest) => (
|
||||
<div className="goose-message-tool" key={toolRequest.id}>
|
||||
<ToolCallWithResponse
|
||||
sessionId={sessionId}
|
||||
isCancelledMessage={false}
|
||||
toolRequest={toolRequest}
|
||||
toolResponse={toolResponsesMap.get(toolRequest.id)}
|
||||
notifications={toolCallNotifications.get(toolRequest.id)}
|
||||
isStreamingMessage={isStreaming}
|
||||
isPendingApproval={pendingConfirmationIds.has(toolRequest.id)}
|
||||
append={append}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{toolRequests.map((toolRequest) => {
|
||||
const hasResponse = toolResponsesMap.has(toolRequest.id);
|
||||
const isPending = pendingConfirmationIds.has(toolRequest.id);
|
||||
const confirmationContent = findConfirmationForToolAcrossMessages(toolRequest.id);
|
||||
const isApprovalClicked = confirmationContent && !isPending && hasResponse;
|
||||
return (
|
||||
<div className="goose-message-tool" key={toolRequest.id}>
|
||||
<ToolCallWithResponse
|
||||
sessionId={sessionId}
|
||||
isCancelledMessage={false}
|
||||
toolRequest={toolRequest}
|
||||
toolResponse={toolResponsesMap.get(toolRequest.id)}
|
||||
notifications={toolCallNotifications.get(toolRequest.id)}
|
||||
isStreamingMessage={isStreaming}
|
||||
isPendingApproval={isPending}
|
||||
append={append}
|
||||
confirmationContent={confirmationContent}
|
||||
isApprovalClicked={isApprovalClicked}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0 pt-1">
|
||||
{!isStreaming && !hideTimestamp && timestamp}
|
||||
@@ -171,10 +207,9 @@ export default function GooseMessage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasToolConfirmation && (
|
||||
{hasToolConfirmation && !toolConfirmationShownInline && (
|
||||
<ToolCallConfirmation
|
||||
sessionId={sessionId}
|
||||
isCancelledMessage={false}
|
||||
isClicked={false}
|
||||
actionRequiredContent={toolConfirmationContent}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { confirmToolAction, Permission } from '../api';
|
||||
|
||||
const globalApprovalState = new Map<
|
||||
string,
|
||||
{
|
||||
decision: Permission | null;
|
||||
isClicked: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
export interface ToolApprovalData {
|
||||
id: string;
|
||||
toolName: string;
|
||||
prompt?: string;
|
||||
sessionId: string;
|
||||
isClicked?: boolean;
|
||||
}
|
||||
|
||||
export default function ToolApprovalButtons({ data }: { data: ToolApprovalData }) {
|
||||
const { id, toolName, prompt, sessionId, isClicked: initialIsClicked } = data;
|
||||
|
||||
const storedState = globalApprovalState.get(id);
|
||||
const [decision, setDecision] = useState<Permission | null>(storedState?.decision ?? null);
|
||||
const [isClicked, setIsClicked] = useState(storedState?.isClicked ?? initialIsClicked ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentState = globalApprovalState.get(id);
|
||||
if (currentState) {
|
||||
setDecision(currentState.decision);
|
||||
setIsClicked(currentState.isClicked);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
globalApprovalState.set(id, { decision, isClicked });
|
||||
}, [id, decision, isClicked]);
|
||||
|
||||
const handleAction = async (action: Permission) => {
|
||||
setDecision(action);
|
||||
setIsClicked(true);
|
||||
|
||||
try {
|
||||
const response = await confirmToolAction({
|
||||
body: {
|
||||
sessionId,
|
||||
id,
|
||||
action,
|
||||
principalType: 'Tool',
|
||||
},
|
||||
});
|
||||
if (response.error) {
|
||||
console.error('Failed to confirm tool action:', response.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error confirming tool action:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isClicked && decision) {
|
||||
const statusMessages: Record<Permission, string> = {
|
||||
allow_once: 'Allowed once',
|
||||
always_allow: 'Always allowed',
|
||||
always_deny: 'Denied',
|
||||
deny_once: 'Denied once',
|
||||
cancel: 'Cancelled',
|
||||
};
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{toolName} - {statusMessages[decision]}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Button
|
||||
className="rounded-full"
|
||||
variant="secondary"
|
||||
onClick={() => handleAction('allow_once')}
|
||||
>
|
||||
Allow Once
|
||||
</Button>
|
||||
{!prompt && (
|
||||
<Button
|
||||
className="rounded-full"
|
||||
variant="secondary"
|
||||
onClick={() => handleAction('always_allow')}
|
||||
>
|
||||
Always Allow
|
||||
</Button>
|
||||
)}
|
||||
<Button className="rounded-full" variant="outline" onClick={() => handleAction('deny_once')}>
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,231 +1,32 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { snakeToTitleCase } from '../utils';
|
||||
import PermissionModal from './settings/permission/PermissionModal';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { confirmToolAction, ActionRequired } from '../api';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const ALLOW_ONCE = 'allow_once';
|
||||
const ALWAYS_ALLOW = 'always_allow';
|
||||
const DENY = 'deny';
|
||||
|
||||
// Global state to track tool confirmation decisions
|
||||
// This persists across navigation within the same session
|
||||
const toolConfirmationState = new Map<
|
||||
string,
|
||||
{
|
||||
clicked: boolean;
|
||||
status: string;
|
||||
actionDisplay: string;
|
||||
}
|
||||
>();
|
||||
import { ActionRequired } from '../api';
|
||||
import ToolApprovalButtons from './ToolApprovalButtons';
|
||||
|
||||
type ToolConfirmationData = Extract<ActionRequired['data'], { actionType: 'toolConfirmation' }>;
|
||||
|
||||
interface ToolConfirmationProps {
|
||||
sessionId: string;
|
||||
isCancelledMessage: boolean;
|
||||
isClicked: boolean;
|
||||
actionRequiredContent: ActionRequired & { type: 'actionRequired' };
|
||||
}
|
||||
|
||||
export default function ToolConfirmation({
|
||||
sessionId,
|
||||
isCancelledMessage,
|
||||
isClicked,
|
||||
actionRequiredContent,
|
||||
}: ToolConfirmationProps) {
|
||||
const data = actionRequiredContent.data as ToolConfirmationData;
|
||||
const { id: toolConfirmationId, toolName, prompt } = data;
|
||||
const { id, toolName, prompt } = data;
|
||||
|
||||
// Check if we have a stored state for this tool confirmation
|
||||
const storedState = toolConfirmationState.get(toolConfirmationId);
|
||||
|
||||
// Initialize state from stored state if available, otherwise use props/defaults
|
||||
const [clicked, setClicked] = useState(storedState?.clicked ?? isClicked);
|
||||
const [status, setStatus] = useState(storedState?.status ?? 'unknown');
|
||||
const [actionDisplay, setActionDisplay] = useState(storedState?.actionDisplay ?? '');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// Sync internal state with stored state and props
|
||||
useEffect(() => {
|
||||
const currentStoredState = toolConfirmationState.get(toolConfirmationId);
|
||||
|
||||
// If we have stored state, use it
|
||||
if (currentStoredState) {
|
||||
setClicked(currentStoredState.clicked);
|
||||
setStatus(currentStoredState.status);
|
||||
setActionDisplay(currentStoredState.actionDisplay);
|
||||
} else if (isClicked && !clicked) {
|
||||
// Fallback to prop-based logic for historical confirmations
|
||||
setClicked(isClicked);
|
||||
if (status === 'unknown') {
|
||||
setStatus('confirmed');
|
||||
setActionDisplay('confirmed');
|
||||
|
||||
// Store this state for future renders
|
||||
toolConfirmationState.set(toolConfirmationId, {
|
||||
clicked: true,
|
||||
status: 'confirmed',
|
||||
actionDisplay: 'confirmed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isClicked, clicked, status, toolName, toolConfirmationId]);
|
||||
|
||||
const handleButtonClick = async (newStatus: string) => {
|
||||
let newActionDisplay;
|
||||
|
||||
if (newStatus === ALWAYS_ALLOW) {
|
||||
newActionDisplay = 'always allowed';
|
||||
} else if (newStatus === ALLOW_ONCE) {
|
||||
newActionDisplay = 'allowed once';
|
||||
} else if (newStatus === DENY) {
|
||||
newActionDisplay = 'denied';
|
||||
} else {
|
||||
newActionDisplay = 'denied';
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setClicked(true);
|
||||
setStatus(newStatus);
|
||||
setActionDisplay(newActionDisplay);
|
||||
|
||||
// Store in global state for persistence across navigation
|
||||
toolConfirmationState.set(toolConfirmationId, {
|
||||
clicked: true,
|
||||
status: newStatus,
|
||||
actionDisplay: newActionDisplay,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await confirmToolAction({
|
||||
body: {
|
||||
sessionId: sessionId,
|
||||
id: toolConfirmationId,
|
||||
action: newStatus,
|
||||
principalType: 'Tool',
|
||||
},
|
||||
});
|
||||
if (response.error) {
|
||||
console.error('Failed to confirm tool action:', response.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error confirming tool action:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
function getExtensionName(toolName: string): string {
|
||||
const parts = toolName.split('__');
|
||||
return parts.length > 1 ? parts[0] : '';
|
||||
}
|
||||
|
||||
return isCancelledMessage ? (
|
||||
<div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 text-textStandard">
|
||||
Tool call confirmation is cancelled.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Display security message if present */}
|
||||
{prompt && (
|
||||
<div className="goose-message-content bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-2xl px-4 py-2 mb-2 text-yellow-800 dark:text-gray-200">
|
||||
{prompt}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 rounded-b-none text-textStandard">
|
||||
return (
|
||||
<div className="goose-message-content bg-background-default border border-borderSubtle rounded-2xl overflow-hidden">
|
||||
<div className="bg-background-muted px-4 py-2 text-textStandard">
|
||||
{prompt
|
||||
? 'Do you allow this tool call?'
|
||||
: 'Goose would like to call the above tool. Allow?'}
|
||||
</div>
|
||||
{clicked ? (
|
||||
<div className="goose-message-tool bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{(status === 'allow_once' || status === 'always_allow') && (
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
{status === 'deny' && (
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{status === 'confirmed' && (
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="ml-2 text-textStandard">
|
||||
{isClicked
|
||||
? 'Tool confirmation is not available'
|
||||
: `${snakeToTitleCase(toolName.substring(toolName.lastIndexOf('__') + 2))} is ${actionDisplay}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center cursor-pointer" onClick={() => setIsModalOpen(true)}>
|
||||
<span className="mr-1 text-textStandard">Change</span>
|
||||
<ChevronRight className="w-4 h-4 ml-1 text-iconStandard" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="goose-message-tool bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex gap-2 items-center">
|
||||
<Button
|
||||
className="rounded-full"
|
||||
variant="secondary"
|
||||
onClick={() => handleButtonClick(ALLOW_ONCE)}
|
||||
>
|
||||
Allow Once
|
||||
</Button>
|
||||
{/* Only show "Always Allow" if there's no security message (no security finding) */}
|
||||
{!prompt && (
|
||||
<Button
|
||||
className="rounded-full"
|
||||
variant="secondary"
|
||||
onClick={() => handleButtonClick(ALWAYS_ALLOW)}
|
||||
>
|
||||
Always Allow
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="rounded-full"
|
||||
variant="outline"
|
||||
onClick={() => handleButtonClick(DENY)}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal for updating tool permission */}
|
||||
{isModalOpen && (
|
||||
<PermissionModal onClose={handleModalClose} extensionName={getExtensionName(toolName)} />
|
||||
)}
|
||||
</>
|
||||
<ToolApprovalButtons
|
||||
data={{ id, toolName, prompt: prompt ?? undefined, sessionId, isClicked }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ToolRequestMessageContent,
|
||||
ToolResponseMessageContent,
|
||||
NotificationEvent,
|
||||
ToolConfirmationData,
|
||||
} from '../types/message';
|
||||
import { cn, snakeToTitleCase } from '../utils';
|
||||
import { LoadingStatus } from './ui/Dot';
|
||||
@@ -18,6 +19,7 @@ import MCPUIResourceRenderer from './MCPUIResourceRenderer';
|
||||
import { isUIResource } from '@mcp-ui/client';
|
||||
import { CallToolResponse, Content, EmbeddedResource } from '../api';
|
||||
import McpAppRenderer from './McpApps/McpAppRenderer';
|
||||
import ToolApprovalButtons from './ToolApprovalButtons';
|
||||
|
||||
interface ToolGraphNode {
|
||||
tool: string;
|
||||
@@ -58,6 +60,8 @@ interface ToolCallWithResponseProps {
|
||||
isStreamingMessage?: boolean;
|
||||
isPendingApproval: boolean;
|
||||
append?: (value: string) => void;
|
||||
confirmationContent?: ToolConfirmationData;
|
||||
isApprovalClicked?: boolean;
|
||||
}
|
||||
|
||||
function getToolResultContent(toolResult: Record<string, unknown>): Content[] {
|
||||
@@ -155,6 +159,8 @@ export default function ToolCallWithResponse({
|
||||
isStreamingMessage,
|
||||
isPendingApproval,
|
||||
append,
|
||||
confirmationContent,
|
||||
isApprovalClicked,
|
||||
}: ToolCallWithResponseProps) {
|
||||
// Handle both the wrapped ToolResult format and the unwrapped format
|
||||
// The server serializes ToolResult<T> as { status: "success", value: T } or { status: "error", error: string }
|
||||
@@ -176,11 +182,14 @@ export default function ToolCallWithResponse({
|
||||
|
||||
const shouldShowMcpContent = !isPendingApproval;
|
||||
|
||||
const showInlineApproval = isPendingApproval && confirmationContent && sessionId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full text-sm font-sans rounded-lg overflow-hidden border-borderSubtle border'
|
||||
'w-full text-sm font-sans rounded-lg overflow-hidden border',
|
||||
showInlineApproval ? 'border-amber-500/50 bg-amber-50/5' : 'border-borderSubtle'
|
||||
)}
|
||||
>
|
||||
<ToolCallView
|
||||
@@ -192,6 +201,27 @@ export default function ToolCallWithResponse({
|
||||
isStreamingMessage,
|
||||
}}
|
||||
/>
|
||||
{/* Inline approval UI */}
|
||||
{showInlineApproval && (
|
||||
<div className="border-t border-amber-500/30">
|
||||
{confirmationContent.prompt && (
|
||||
<div className="px-4 py-2 text-sm text-amber-600 dark:text-amber-400 bg-amber-50/10">
|
||||
{confirmationContent.prompt}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 pb-2">
|
||||
<ToolApprovalButtons
|
||||
data={{
|
||||
id: confirmationContent.id,
|
||||
toolName: confirmationContent.toolName,
|
||||
prompt: confirmationContent.prompt ?? undefined,
|
||||
sessionId,
|
||||
isClicked: isApprovalClicked,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* MCP UI — Inline */}
|
||||
{shouldShowMcpContent &&
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Message, MessageEvent, ActionRequired, ToolRequest, ToolResponse } from '../api';
|
||||
import {
|
||||
Message,
|
||||
MessageEvent,
|
||||
ActionRequired,
|
||||
ToolRequest,
|
||||
ToolResponse,
|
||||
ToolConfirmationRequest,
|
||||
} from '../api';
|
||||
|
||||
export type ToolRequestMessageContent = ToolRequest & { type: 'toolRequest' };
|
||||
export type ToolResponseMessageContent = ToolResponse & { type: 'toolResponse' };
|
||||
export type ToolConfirmationRequestContent = ToolConfirmationRequest & {
|
||||
type: 'toolConfirmationRequest';
|
||||
};
|
||||
export type NotificationEvent = Extract<MessageEvent, { type: 'Notification' }>;
|
||||
|
||||
// Compaction response message - must match backend constant
|
||||
@@ -108,6 +118,46 @@ export function getToolConfirmationContent(
|
||||
);
|
||||
}
|
||||
|
||||
export function getToolConfirmationRequestContent(
|
||||
message: Message
|
||||
): ToolConfirmationRequestContent | undefined {
|
||||
return message.content.find(
|
||||
(content): content is ToolConfirmationRequestContent =>
|
||||
content.type === 'toolConfirmationRequest'
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToolConfirmationData {
|
||||
id: string;
|
||||
toolName: string;
|
||||
arguments: Record<string, unknown>;
|
||||
prompt?: string | null;
|
||||
}
|
||||
|
||||
export function getAnyToolConfirmationData(message: Message): ToolConfirmationData | undefined {
|
||||
const confirmationRequest = getToolConfirmationRequestContent(message);
|
||||
if (confirmationRequest) {
|
||||
return {
|
||||
id: confirmationRequest.id,
|
||||
toolName: confirmationRequest.toolName,
|
||||
arguments: confirmationRequest.arguments,
|
||||
prompt: confirmationRequest.prompt,
|
||||
};
|
||||
}
|
||||
|
||||
const actionRequired = getToolConfirmationContent(message);
|
||||
if (actionRequired && actionRequired.data.actionType === 'toolConfirmation') {
|
||||
return {
|
||||
id: actionRequired.data.id,
|
||||
toolName: actionRequired.data.toolName,
|
||||
arguments: actionRequired.data.arguments,
|
||||
prompt: actionRequired.data.prompt,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getToolConfirmationId(
|
||||
content: ActionRequired & { type: 'actionRequired' }
|
||||
): string | undefined {
|
||||
@@ -129,12 +179,9 @@ export function getPendingToolConfirmationIds(messages: Message[]): Set<string>
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
const confirmation = getToolConfirmationContent(message);
|
||||
if (confirmation) {
|
||||
const confirmationId = getToolConfirmationId(confirmation);
|
||||
if (confirmationId && !respondedIds.has(confirmationId)) {
|
||||
pendingIds.add(confirmationId);
|
||||
}
|
||||
const confirmationData = getAnyToolConfirmationData(message);
|
||||
if (confirmationData && !respondedIds.has(confirmationData.id)) {
|
||||
pendingIds.add(confirmationData.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user