diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts index e00d622..33e28e7 100644 --- a/src/agent/check-approval.ts +++ b/src/agent/check-approval.ts @@ -10,7 +10,8 @@ import type { ApprovalRequest } from "../cli/helpers/stream"; const MESSAGE_HISTORY_LIMIT = 15; export interface ResumeData { - pendingApproval: ApprovalRequest | null; + pendingApproval: ApprovalRequest | null; // Deprecated: use pendingApprovals + pendingApprovals: ApprovalRequest[]; messageHistory: LettaMessageUnion[]; } @@ -30,7 +31,11 @@ export async function getResumeData( const messagesPage = await client.agents.messages.list(agent.id); const messages = messagesPage.items; if (!messages || messages.length === 0) { - return { pendingApproval: null, messageHistory: [] }; + return { + pendingApproval: null, + pendingApprovals: [], + messageHistory: [], + }; } // Compare cursor last message with in-context last message ID @@ -38,7 +43,11 @@ export async function getResumeData( // desynced, we need to check the in-context message for pending approvals const cursorLastMessage = messages[messages.length - 1]; if (!cursorLastMessage) { - return { pendingApproval: null, messageHistory: [] }; + return { + pendingApproval: null, + pendingApprovals: [], + messageHistory: [], + }; } const inContextLastMessageId = @@ -85,8 +94,9 @@ export async function getResumeData( } } - // Check for pending approval using SDK types + // Check for pending approval(s) using SDK types let pendingApproval: ApprovalRequest | null = null; + let pendingApprovals: ApprovalRequest[] = []; if (messageToCheck.message_type === "approval_request_message") { // Cast to access tool_calls with proper typing @@ -110,16 +120,18 @@ export async function getResumeData( ? [approvalMsg.tool_call] : []; - if (toolCalls.length > 0) { - const toolCall = toolCalls[0]; - // Ensure all required fields are present - if (toolCall?.tool_call_id && toolCall.name && toolCall.arguments) { - pendingApproval = { - toolCallId: toolCall.tool_call_id, - toolName: toolCall.name, - toolArgs: toolCall.arguments, - }; - } + // Extract ALL tool calls for parallel approval support + pendingApprovals = toolCalls + .filter((tc) => tc?.tool_call_id && tc.name && tc.arguments) + .map((tc) => ({ + toolCallId: tc.tool_call_id!, + toolName: tc.name!, + toolArgs: tc.arguments!, + })); + + // Set legacy singular field for backward compatibility (first approval only) + if (pendingApprovals.length > 0) { + pendingApproval = pendingApprovals[0] || null; } } @@ -132,9 +144,9 @@ export async function getResumeData( messageHistory = messageHistory.slice(1); } - return { pendingApproval, messageHistory }; + return { pendingApproval, pendingApprovals, messageHistory }; } catch (error) { console.error("Error getting resume data:", error); - return { pendingApproval: null, messageHistory: [] }; + return { pendingApproval: null, pendingApprovals: [], messageHistory: [] }; } } diff --git a/src/agent/create.ts b/src/agent/create.ts index 9dea07f..302a390 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -215,7 +215,7 @@ export async function createAgent( include_base_tool_rules: false, initial_message_sequence: [], // TODO: enable as default - // parallel_tool_calls: true, + parallel_tool_calls: true, // TODO: enable via flag --sleeptime // enable_sleeptime: true, }); diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 1981975..0bba8c5 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -104,6 +104,7 @@ export default function App({ loadingState = "ready", continueSession = false, startupApproval = null, + startupApprovals = [], messageHistory = [], tokenStreaming = true, }: { @@ -118,7 +119,8 @@ export default function App({ | "checking" | "ready"; continueSession?: boolean; - startupApproval?: ApprovalRequest | null; + startupApproval?: ApprovalRequest | null; // Deprecated: use startupApprovals + startupApprovals?: ApprovalRequest[]; messageHistory?: LettaMessageUnion[]; tokenStreaming?: boolean; }) { @@ -131,11 +133,42 @@ export default function App({ // Whether a command is running (disables input but no streaming UI) const [commandRunning, setCommandRunning] = useState(false); - // If we have an approval request, we should show the approval dialog instead of the input area - const [pendingApproval, setPendingApproval] = - useState(null); - const [approvalContext, setApprovalContext] = - useState(null); + // If we have approval requests, we should show the approval dialog instead of the input area + const [pendingApprovals, setPendingApprovals] = useState( + [], + ); + const [approvalContexts, setApprovalContexts] = useState( + [], + ); + + // Sequential approval: track results as user reviews each approval + const [approvalResults, setApprovalResults] = useState< + Array<{ + type: "approval" | "tool"; + tool_call_id: string; + approve?: boolean; + reason?: string; + tool_return?: string; + status?: "success" | "error"; + stdout?: string[]; + stderr?: string[]; + }> + >([]); + const [isExecutingTool, setIsExecutingTool] = useState(false); + + // Track auto-handled results to combine with user decisions + const [autoHandledResults, setAutoHandledResults] = useState< + Array<{ + toolCallId: string; + result: any; + }> + >([]); + const [autoDeniedApprovals, setAutoDeniedApprovals] = useState< + Array<{ + approval: ApprovalRequest; + reason: string; + }> + >([]); // If we have a plan approval request, show the plan dialog const [planApprovalPending, setPlanApprovalPending] = useState<{ @@ -270,46 +303,56 @@ export default function App({ // Restore pending approval from startup when ready useEffect(() => { - if (loadingState === "ready" && startupApproval) { + // Use new plural field if available, otherwise wrap singular in array for backward compat + const approvals = + startupApprovals?.length > 0 + ? startupApprovals + : startupApproval + ? [startupApproval] + : []; + + if (loadingState === "ready" && approvals.length > 0) { // Check if this is an ExitPlanMode approval - route to plan dialog - if (startupApproval.toolName === "ExitPlanMode") { + const planApproval = approvals.find((a) => a.toolName === "ExitPlanMode"); + if (planApproval) { const parsedArgs = safeJsonParseOr>( - startupApproval.toolArgs, + planApproval.toolArgs, {}, ); const plan = (parsedArgs.plan as string) || "No plan provided"; setPlanApprovalPending({ plan, - toolCallId: startupApproval.toolCallId, - toolArgs: startupApproval.toolArgs, + toolCallId: planApproval.toolCallId, + toolArgs: planApproval.toolArgs, }); } else { - // Regular tool approval - setPendingApproval(startupApproval); + // Regular tool approvals (may be multiple for parallel tools) + setPendingApprovals(approvals); - // Analyze approval context for restored approval - const analyzeStartupApproval = async () => { + // Analyze approval contexts for all restored approvals + const analyzeStartupApprovals = async () => { try { - const parsedArgs = safeJsonParseOr>( - startupApproval.toolArgs, - {}, + const contexts = await Promise.all( + approvals.map(async (approval) => { + const parsedArgs = safeJsonParseOr>( + approval.toolArgs, + {}, + ); + return await analyzeToolApproval(approval.toolName, parsedArgs); + }), ); - const context = await analyzeToolApproval( - startupApproval.toolName, - parsedArgs, - ); - setApprovalContext(context); + setApprovalContexts(contexts); } catch (error) { // If analysis fails, leave context as null (will show basic options) - console.error("Failed to analyze startup approval:", error); + console.error("Failed to analyze startup approvals:", error); } }; - analyzeStartupApproval(); + analyzeStartupApprovals(); } } - }, [loadingState, startupApproval]); + }, [loadingState, startupApproval, startupApprovals]); // Backfill message history when resuming (only once) useEffect(() => { @@ -388,7 +431,7 @@ export default function App({ async ( initialInput: Array, ): Promise => { - let currentInput = initialInput; + const currentInput = initialInput; try { setStreaming(true); @@ -397,7 +440,7 @@ export default function App({ while (true) { // Stream one turn const stream = await sendMessageStream(agentId, currentInput); - const { stopReason, approval, apiDurationMs, lastRunId } = + const { stopReason, approval, approvals, apiDurationMs, lastRunId } = await drainStreamWithResume( stream, buffersRef.current, @@ -427,101 +470,152 @@ export default function App({ // Case 2: Requires approval if (stopReason === "requires_approval") { - if (!approval) { + // Use new approvals array, fallback to legacy approval for backward compat + const approvalsToProcess = + approvals && approvals.length > 0 + ? approvals + : approval + ? [approval] + : []; + + if (approvalsToProcess.length === 0) { appendError( - `Unexpected null approval with stop reason: ${stopReason}`, + `Unexpected empty approvals with stop reason: ${stopReason}`, ); setStreaming(false); return; } - const { toolCallId, toolName, toolArgs } = approval; - - // Special handling for ExitPlanMode - show plan dialog - if (toolName === "ExitPlanMode") { + // Check each approval for ExitPlanMode special case + const planApproval = approvalsToProcess.find( + (a) => a.toolName === "ExitPlanMode", + ); + if (planApproval) { const parsedArgs = safeJsonParseOr>( - toolArgs, + planApproval.toolArgs, {}, ); const plan = (parsedArgs.plan as string) || "No plan provided"; - setPlanApprovalPending({ plan, toolCallId, toolArgs }); + setPlanApprovalPending({ + plan, + toolCallId: planApproval.toolCallId, + toolArgs: planApproval.toolArgs, + }); setStreaming(false); return; } - // Check permission using new permission system - const parsedArgs = safeJsonParseOr>( - toolArgs, - {}, + // Check permissions for all approvals + const approvalResults = await Promise.all( + approvalsToProcess.map(async (approvalItem) => { + const parsedArgs = safeJsonParseOr>( + approvalItem.toolArgs, + {}, + ); + const permission = await checkToolPermission( + approvalItem.toolName, + parsedArgs, + ); + const context = await analyzeToolApproval( + approvalItem.toolName, + parsedArgs, + ); + return { approval: approvalItem, permission, context }; + }), ); - const permission = await checkToolPermission(toolName, parsedArgs); - // Handle deny decision - use same flow as manual deny - if (permission.decision === "deny") { - const denyReason = `Permission denied by rule: ${permission.matchedRule || permission.reason}`; + // Categorize approvals by permission decision + const needsUserInput = approvalResults.filter( + (ac) => ac.permission.decision === "ask", + ); + const autoDenied = approvalResults.filter( + (ac) => ac.permission.decision === "deny", + ); + const autoAllowed = approvalResults.filter( + (ac) => ac.permission.decision === "allow", + ); + // Execute auto-allowed tools + const autoAllowedResults = await Promise.all( + autoAllowed.map(async (ac) => { + const parsedArgs = safeJsonParseOr>( + ac.approval.toolArgs, + {}, + ); + const result = await executeTool( + ac.approval.toolName, + parsedArgs, + ); + + // Update buffers with tool return for UI + onChunk(buffersRef.current, { + message_type: "tool_return_message", + id: "dummy", + date: new Date().toISOString(), + tool_call_id: ac.approval.toolCallId, + tool_return: result.toolReturn, + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + }); + + return { + toolCallId: ac.approval.toolCallId, + result, + }; + }), + ); + + // Create denial results for auto-denied tools + const autoDeniedResults = autoDenied.map((ac) => ({ + approval: ac.approval, + reason: `Permission denied by rule: ${ac.permission.matchedRule || ac.permission.reason}`, + })); + + // If all are auto-handled, continue immediately without showing dialog + if (needsUserInput.length === 0) { // Rotate to a new thinking message setThinkingMessage(getRandomThinkingMessage()); + refreshDerived(); + + // Combine auto-allowed results + auto-denied responses + const allResults = [ + ...autoAllowedResults.map((ar) => ({ + type: "tool" as const, + tool_call_id: ar.toolCallId, + tool_return: ar.result.toolReturn, + status: ar.result.status, + stdout: ar.result.stdout, + stderr: ar.result.stderr, + })), + ...autoDeniedResults.map((ad) => ({ + type: "tool" as const, + tool_call_id: ad.approval.toolCallId, + tool_return: JSON.stringify({ + status: "error", + message: ad.reason, + }), + status: "error" as const, + })), + ]; - // Send denial back to agent (same as manual deny) await processConversation([ { type: "approval", - approval_request_id: toolCallId, - approve: false, - reason: denyReason, + approvals: allResults, }, ]); return; } - // Handle ask decision - show approval dialog - if (permission.decision === "ask") { - // Analyze approval context for smart button text - const context = await analyzeToolApproval(toolName, parsedArgs); - - // Pause: show approval dialog and exit loop - // Handlers will restart the loop when user decides - setPendingApproval({ toolCallId, toolName, toolArgs }); - setApprovalContext(context); - setStreaming(false); - return; - } - - // Permission is "allow" - auto-execute tool and continue loop - const toolResult = await executeTool(toolName, parsedArgs); - - // Update buffers with tool return - onChunk(buffersRef.current, { - message_type: "tool_return_message", - id: "dummy", - date: new Date().toISOString(), - tool_call_id: toolCallId, - tool_return: toolResult.toolReturn, - status: toolResult.status, - stdout: toolResult.stdout, - stderr: toolResult.stderr, - }); - refreshDerived(); - - // Set up next input and continue loop - currentInput = [ - { - type: "approval", - approvals: [ - { - type: "tool", - tool_call_id: toolCallId, - tool_return: toolResult.toolReturn, - status: toolResult.status, - stdout: toolResult.stdout, - stderr: toolResult.stderr, - }, - ], - }, - ]; - continue; // Loop continues naturally + // Show approval dialog for tools that need user input + setPendingApprovals(needsUserInput.map((ac) => ac.approval)); + setApprovalContexts(needsUserInput.map((ac) => ac.context)); + setAutoHandledResults(autoAllowedResults); + setAutoDeniedApprovals(autoDeniedResults); + setStreaming(false); + return; } // Unexpected stop reason (error, llm_api_error, etc.) @@ -1021,7 +1115,7 @@ export default function App({ // The message will be restored to the input field for the user to decide // Note: The user message is already in the transcript (optimistic update) setStreaming(false); // Stop streaming indicator - setPendingApproval(existingApproval); + setPendingApprovals([existingApproval]); // Analyze approval context const parsedArgs = safeJsonParseOr>( @@ -1032,7 +1126,7 @@ export default function App({ existingApproval.toolName, parsedArgs, ); - setApprovalContext(context); + setApprovalContexts([context]); // Return false = message NOT submitted, will be restored to input return { submitted: false }; @@ -1068,58 +1162,136 @@ export default function App({ ], ); - // Handle approval callbacks - const handleApprove = useCallback(async () => { - if (!pendingApproval) return; + // Helper to send all approval results when done + const sendAllResults = useCallback( + async (additionalResult?: { + type: "approval" | "tool"; + tool_call_id: string; + approve?: boolean; + reason?: string; + tool_return?: string; + status?: "success" | "error"; + stdout?: string[]; + stderr?: string[]; + }) => { + const allResults = [ + ...autoHandledResults.map((ar) => ({ + type: "tool" as const, + tool_call_id: ar.toolCallId, + tool_return: ar.result.toolReturn, + status: ar.result.status, + stdout: ar.result.stdout, + stderr: ar.result.stderr, + })), + ...autoDeniedApprovals.map((ad) => ({ + type: "approval" as const, + tool_call_id: ad.approval.toolCallId, + approve: false, + reason: ad.reason, + })), + ...approvalResults, + ...(additionalResult ? [additionalResult] : []), + ]; - const { toolCallId, toolName, toolArgs } = pendingApproval; - setPendingApproval(null); + // Clear state + setPendingApprovals([]); + setApprovalContexts([]); + setApprovalResults([]); + setAutoHandledResults([]); + setAutoDeniedApprovals([]); + + // Rotate to a new thinking message + setThinkingMessage(getRandomThinkingMessage()); + refreshDerived(); + + // Continue conversation with all results + await processConversation([ + { + type: "approval", + approvals: allResults as any, // Type assertion: union type with optional fields is compatible at runtime + }, + ]); + }, + [ + approvalResults, + autoHandledResults, + autoDeniedApprovals, + processConversation, + refreshDerived, + ], + ); + + // Handle approval callbacks - sequential review + const handleApproveCurrent = useCallback(async () => { + const currentIndex = approvalResults.length; + const currentApproval = pendingApprovals[currentIndex]; + + if (!currentApproval) return; try { - // Execute the tool - const parsedArgs = safeJsonParseOr>(toolArgs, {}); - const toolResult = await executeTool(toolName, parsedArgs); + setIsExecutingTool(true); - // Update buffers with tool return + // Execute the approved tool + const parsedArgs = safeJsonParseOr>( + currentApproval.toolArgs, + {}, + ); + const toolResult = await executeTool( + currentApproval.toolName, + parsedArgs, + ); + + // Update buffers with tool return for UI onChunk(buffersRef.current, { message_type: "tool_return_message", id: "dummy", date: new Date().toISOString(), - tool_call_id: toolCallId, + tool_call_id: currentApproval.toolCallId, tool_return: toolResult.toolReturn, status: toolResult.status, stdout: toolResult.stdout, stderr: toolResult.stderr, }); - // Rotate to a new thinking message for this continuation - setThinkingMessage(getRandomThinkingMessage()); - refreshDerived(); - // Restart conversation loop with approval response - await processConversation([ - { - type: "approval", - approvals: [ - { - type: "tool", - tool_call_id: toolCallId, - tool_return: toolResult.toolReturn, - status: toolResult.status, - stdout: toolResult.stdout, - stderr: toolResult.stderr, - }, - ], - }, - ]); + // Store result + const result = { + type: "tool" as const, + tool_call_id: currentApproval.toolCallId, + tool_return: toolResult.toolReturn, + status: toolResult.status, + stdout: toolResult.stdout, + stderr: toolResult.stderr, + }; + + setIsExecutingTool(false); + + // Check if we're done with all approvals + if (currentIndex + 1 >= pendingApprovals.length) { + // All approvals processed, send results to backend + // Pass the new result directly to avoid async state update issue + await sendAllResults(result); + } else { + // Not done yet, store result and show next approval + setApprovalResults((prev) => [...prev, result]); + } + // Otherwise, next approval will be shown automatically via state update } catch (e) { + setIsExecutingTool(false); appendError(String(e)); setStreaming(false); } - }, [pendingApproval, processConversation, appendError, refreshDerived]); + }, [pendingApprovals, approvalResults, sendAllResults, appendError]); const handleApproveAlways = useCallback( async (scope?: "project" | "session") => { - if (!pendingApproval || !approvalContext) return; + // For now, just handle the first approval with approve-always + // TODO: Support approve-always for multiple approvals + if (pendingApprovals.length === 0 || approvalContexts.length === 0) + return; + + const currentIndex = approvalResults.length; + const approvalContext = approvalContexts[currentIndex]; + if (!approvalContext) return; const rule = approvalContext.recommendedRule; const actualScope = scope || approvalContext.defaultScope; @@ -1140,48 +1312,61 @@ export default function App({ buffersRef.current.order.push(cmdId); refreshDerived(); - // Clear approval context and approve - setApprovalContext(null); - await handleApprove(); + // Approve current tool + await handleApproveCurrent(); }, - [pendingApproval, approvalContext, handleApprove, refreshDerived], + [ + approvalResults, + approvalContexts, + pendingApprovals, + handleApproveCurrent, + refreshDerived, + ], ); - const handleDeny = useCallback( + const handleDenyCurrent = useCallback( async (reason: string) => { - if (!pendingApproval) return; + const currentIndex = approvalResults.length; + const currentApproval = pendingApprovals[currentIndex]; - const { toolCallId } = pendingApproval; - setPendingApproval(null); + if (!currentApproval) return; try { - // Rotate to a new thinking message for this continuation - setThinkingMessage(getRandomThinkingMessage()); + // Store denial result + const result = { + type: "approval" as const, + tool_call_id: currentApproval.toolCallId, + approve: false, + reason: reason || "User denied the tool execution", + }; - // Restart conversation loop with denial response - await processConversation([ - { - type: "approval", - approval_request_id: toolCallId, - approve: false, - reason: reason || "User denied the tool execution", - // TODO the above is legacy? - // approvals: [ - // { - // type: "approval", - // toolCallId, - // approve: false, - // reason: reason || "User denied the tool execution", - // }, - // ], - }, - ]); + // Update buffers with denial for UI (so it shows in the right order) + onChunk(buffersRef.current, { + message_type: "tool_return_message", + id: "dummy", + date: new Date().toISOString(), + tool_call_id: currentApproval.toolCallId, + tool_return: `Error: request to call tool denied. User reason: ${result.reason}`, + status: "error", + }); + + // Check if we're done with all approvals + if (currentIndex + 1 >= pendingApprovals.length) { + // All approvals processed, send results to backend + // Pass the new result directly to avoid async state update issue + setThinkingMessage(getRandomThinkingMessage()); + await sendAllResults(result); + } else { + // Not done yet, store result and show next approval + setApprovalResults((prev) => [...prev, result]); + } + // Otherwise, next approval will be shown automatically via state update } catch (e) { appendError(String(e)); setStreaming(false); } }, - [pendingApproval, processConversation, appendError], + [pendingApprovals, approvalResults, sendAllResults, appendError], ); const handleModelSelect = useCallback( @@ -1372,7 +1557,7 @@ export default function App({ return ln.phase === "running"; } if (ln.kind === "tool_call") { - if (!tokenStreamingEnabled && ln.phase === "streaming") return false; + // Always show tool calls in progress, regardless of tokenStreaming setting return ln.phase !== "finished"; } if (!tokenStreamingEnabled && ln.phase === "streaming") return false; @@ -1451,7 +1636,7 @@ export default function App({ <> {/* Transcript */} {liveItems.length > 0 && - !pendingApproval && + pendingApprovals.length === 0 && !planApprovalPending && ( {liveItems.map((ln) => ( @@ -1489,7 +1674,7 @@ export default function App({ 0 && ( <> )} diff --git a/src/cli/components/ApprovalDialogRich.tsx b/src/cli/components/ApprovalDialogRich.tsx index 7ebeb7e..1c69ea6 100644 --- a/src/cli/components/ApprovalDialogRich.tsx +++ b/src/cli/components/ApprovalDialogRich.tsx @@ -1,6 +1,6 @@ // Import useInput from vendored Ink for bracketed paste support import { Box, Text, useInput } from "ink"; -import { memo, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import type { ApprovalContext } from "../../permissions/analyzer"; import { type AdvancedDiffSuccess, computeAdvancedDiff } from "../helpers/diff"; import { resolvePlaceholders } from "../helpers/pasteRegistry"; @@ -10,11 +10,14 @@ import { colors } from "./colors"; import { PasteAwareTextInput } from "./PasteAwareTextInput"; type Props = { - approvalRequest: ApprovalRequest; - approvalContext: ApprovalContext | null; - onApprove: () => void; + approvals: ApprovalRequest[]; + approvalContexts: ApprovalContext[]; + progress?: { current: number; total: number }; + totalTools?: number; + isExecuting?: boolean; + onApproveAll: () => void; onApproveAlways: (scope?: "project" | "session") => void; - onDeny: (reason: string) => void; + onDenyAll: (reason: string) => void; }; type DynamicPreviewProps = { @@ -223,23 +226,42 @@ const DynamicPreview: React.FC = ({ }; export const ApprovalDialog = memo(function ApprovalDialog({ - approvalRequest, - approvalContext, - onApprove, + approvals, + approvalContexts, + progress, + totalTools, + isExecuting, + onApproveAll, onApproveAlways, - onDeny, + onDenyAll, }: Props) { const [selectedOption, setSelectedOption] = useState(0); const [isEnteringReason, setIsEnteringReason] = useState(false); const [denyReason, setDenyReason] = useState(""); + // Use first approval/context for now (backward compat) + // TODO: Support individual approval decisions for multiple approvals + // Note: Parent ensures approvals.length > 0 before rendering this component + const approvalRequest = approvals[0]; + const approvalContext = approvalContexts[0] || null; + + // Reset state when approval changes (e.g., moving from tool 2 to tool 3) + // biome-ignore lint/correctness/useExhaustiveDependencies: need to trigger on progress change + useEffect(() => { + setSelectedOption(0); + setIsEnteringReason(false); + setDenyReason(""); + }, [progress?.current]); + // Build options based on approval context const options = useMemo(() => { - const opts = [{ label: "Yes, just this once", action: onApprove }]; + const approvalLabel = + progress && progress.total > 1 + ? "Yes, approve this tool" + : "Yes, just this once"; + const opts = [{ label: approvalLabel, action: onApproveAll }]; - // Add context-aware approval option if available - // Claude Code style: max 3 options total (Yes once, Yes always, No) - // If context is missing, we just don't show "approve always" (2 options only) + // Add context-aware approval option if available (only for single approvals) if (approvalContext?.allowPersistence) { opts.push({ label: approvalContext.approveAlwaysText, @@ -253,13 +275,17 @@ export const ApprovalDialog = memo(function ApprovalDialog({ } // Add deny option + const denyLabel = + progress && progress.total > 1 + ? "No, deny this tool (esc)" + : "No, and tell Letta what to do differently (esc)"; opts.push({ - label: "No, and tell Letta what to do differently (esc)", + label: denyLabel, action: () => {}, // Handled separately via setIsEnteringReason }); return opts; - }, [approvalContext, onApprove, onApproveAlways]); + }, [progress, approvalContext, onApproveAll, onApproveAlways]); useInput((_input, key) => { if (isEnteringReason) { @@ -267,7 +293,7 @@ export const ApprovalDialog = memo(function ApprovalDialog({ if (key.return) { // Resolve placeholders before sending denial reason const resolvedReason = resolvePlaceholders(denyReason); - onDeny(resolvedReason); + onDenyAll(resolvedReason); } else if (key.escape) { setIsEnteringReason(false); setDenyReason(""); @@ -318,14 +344,16 @@ export const ApprovalDialog = memo(function ApprovalDialog({ // Parse JSON args let parsedArgs: Record | null = null; try { - parsedArgs = JSON.parse(approvalRequest.toolArgs); + parsedArgs = approvalRequest?.toolArgs + ? JSON.parse(approvalRequest.toolArgs) + : null; } catch { // Keep as-is if not valid JSON } // Compute diff for file-editing tools const precomputedDiff = useMemo((): AdvancedDiffSuccess | null => { - if (!parsedArgs) return null; + if (!parsedArgs || !approvalRequest) return null; const toolName = approvalRequest.toolName.toLowerCase(); if (toolName === "write") { @@ -361,6 +389,11 @@ export const ApprovalDialog = memo(function ApprovalDialog({ return null; }, [approvalRequest, parsedArgs]); + // Guard: should never happen as parent checks length, but satisfies TypeScript + if (!approvalRequest) { + return null; + } + // Get the human-readable header label const headerLabel = getHeaderLabel(approvalRequest.toolName); @@ -397,8 +430,17 @@ export const ApprovalDialog = memo(function ApprovalDialog({ > {/* Human-readable header (same color as border) */} - {headerLabel} + {progress && progress.total > 1 + ? `${progress.total} tools require approval${totalTools && totalTools > progress.total ? ` (${totalTools} total)` : ""}` + : headerLabel} + {progress && progress.total > 1 && ( + + ({progress.current - 1} reviewed,{" "} + {progress.total - (progress.current - 1)} remaining) + + )} + {isExecuting && Executing tool...} {/* Dynamic per-tool renderer (indented) */} diff --git a/src/cli/components/ToolCallMessageRich.tsx b/src/cli/components/ToolCallMessageRich.tsx index 34eccb6..03211d4 100644 --- a/src/cli/components/ToolCallMessageRich.tsx +++ b/src/cli/components/ToolCallMessageRich.tsx @@ -126,7 +126,11 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => { } // Truncate the result text for display (UI only, API gets full response) - const displayResultText = clipToolReturn(line.resultText); + // Strip trailing newlines to avoid extra visual spacing (e.g., from bash echo) + const displayResultText = clipToolReturn(line.resultText).replace( + /\n+$/, + "", + ); // Check if this is a todo_write tool with successful result // Check both the raw name and the display name diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index 0425efe..7e6783c 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -299,7 +299,7 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { // Handle otid transition for tracking purposes handleOtidTransition(b, chunk.otid ?? undefined); } else { - // Check if this otid is already used by a reasoning line + // Check if this otid is already used by another line if (id && b.byId.has(id)) { const existing = b.byId.get(id); if (existing && existing.kind === "reasoning") { @@ -307,6 +307,15 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { markAsFinished(b, id); // Use a different ID for the tool_call to avoid overwriting the reasoning id = `${id}-tool`; + } else if (existing && existing.kind === "tool_call") { + // Parallel tool calls: same otid, different tool_call_id + // Create unique ID for this parallel tool using its tool_call_id + if (toolCallId) { + id = `${id}-${toolCallId.slice(-8)}`; + } else { + // Fallback: append timestamp + id = `${id}-${Date.now().toString(36)}`; + } } } // ========== END BACKEND BUG WORKAROUND ========== @@ -328,10 +337,9 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { // Early exit if no valid id if (!id) break; - const desiredPhase = - chunk.message_type === "approval_request_message" - ? "ready" - : "streaming"; + // Tool calls should be "ready" (blinking) while pending execution + // Only approval requests explicitly set to "ready", but regular tool calls should also blink + const desiredPhase = "ready"; const line = ensure(b, id, () => ({ kind: "tool_call", id, @@ -363,29 +371,54 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { case "tool_return_message": { // Tool return is a special case // It will have a different otid than the tool call, but we want to merge into the tool call - const toolCallId = chunk.tool_call_id; - const resultText = chunk.tool_return; - const status = chunk.status; - // Look up the line by toolCallId - // Keep a mapping of toolCallId to line id (otid) - const id = toolCallId ? b.toolCallIdToLineId.get(toolCallId) : undefined; - if (!id) break; + // Handle parallel tool returns: check tool_returns array first, fallback to singular fields + const toolReturns = + Array.isArray(chunk.tool_returns) && chunk.tool_returns.length > 0 + ? chunk.tool_returns + : chunk.tool_call_id + ? [ + { + tool_call_id: chunk.tool_call_id, + status: chunk.status, + func_response: chunk.tool_return, + }, + ] + : []; - const line = ensure(b, id, () => ({ - kind: "tool_call", - id, - phase: "finished", - })); + for (const toolReturn of toolReturns) { + const toolCallId = toolReturn.tool_call_id; + // Handle both func_response (streaming) and tool_return (SDK) properties + const resultText = + ("func_response" in toolReturn + ? toolReturn.func_response + : undefined) || + ("tool_return" in toolReturn ? toolReturn.tool_return : undefined) || + ""; + const status = toolReturn.status; - // Immutable update: create new object with result - const updatedLine = { - ...line, - resultText, - phase: "finished" as const, - resultOk: status === "success", - }; - b.byId.set(id, updatedLine); + // Look up the line by toolCallId + // Keep a mapping of toolCallId to line id (otid) + const id = toolCallId + ? b.toolCallIdToLineId.get(toolCallId) + : undefined; + if (!id) continue; + + const line = ensure(b, id, () => ({ + kind: "tool_call", + id, + phase: "finished", + })); + + // Immutable update: create new object with result + const updatedLine = { + ...line, + resultText, + phase: "finished" as const, + resultOk: status === "success", + }; + b.byId.set(id, updatedLine); + } break; } diff --git a/src/cli/helpers/backfill.ts b/src/cli/helpers/backfill.ts index ab26485..9600b4d 100644 --- a/src/cli/helpers/backfill.ts +++ b/src/cli/helpers/backfill.ts @@ -120,49 +120,94 @@ export function backfillBuffers( ? [msg.tool_call] : []; - if (toolCalls.length > 0 && toolCalls[0]?.tool_call_id) { - const toolCall = toolCalls[0]; + // Process ALL tool calls (supports parallel tool calling) + for (let i = 0; i < toolCalls.length; i++) { + const toolCall = toolCalls[i]; + if (!toolCall?.tool_call_id) continue; + const toolCallId = toolCall.tool_call_id; // Skip if any required fields are missing - if (!toolCallId || !toolCall.name || !toolCall.arguments) break; + if (!toolCallId || !toolCall.name || !toolCall.arguments) continue; - const exists = buffers.byId.has(lineId); + // For parallel tool calls, create unique line ID for each + // Must match the streaming logic: first tool uses base lineId, + // subsequent tools append part of tool_call_id (not index!) + let uniqueLineId = lineId; - buffers.byId.set(lineId, { + // Check if base lineId is already used by a tool_call + if (buffers.byId.has(lineId)) { + const existing = buffers.byId.get(lineId); + if (existing && existing.kind === "tool_call") { + // Another tool already used this line ID + // Create unique ID using tool_call_id suffix (match streaming logic) + uniqueLineId = `${lineId}-${toolCallId.slice(-8)}`; + } + } + + const exists = buffers.byId.has(uniqueLineId); + + buffers.byId.set(uniqueLineId, { kind: "tool_call", - id: lineId, + id: uniqueLineId, toolCallId: toolCallId, name: toolCall.name, argsText: toolCall.arguments, phase: "ready", }); - if (!exists) buffers.order.push(lineId); + if (!exists) buffers.order.push(uniqueLineId); // Maintain mapping for tool return to find this line - buffers.toolCallIdToLineId.set(toolCallId, lineId); + buffers.toolCallIdToLineId.set(toolCallId, uniqueLineId); } break; } - // tool return message - merge into the existing tool call line + // tool return message - merge into the existing tool call line(s) case "tool_return_message": { - const toolCallId = msg.tool_call_id; - if (!toolCallId) break; + // Handle parallel tool returns: check tool_returns array first, fallback to singular fields + const toolReturns = + Array.isArray(msg.tool_returns) && msg.tool_returns.length > 0 + ? msg.tool_returns + : msg.tool_call_id + ? [ + { + tool_call_id: msg.tool_call_id, + status: msg.status, + func_response: msg.tool_return, + stdout: msg.stdout, + stderr: msg.stderr, + }, + ] + : []; - // Look up the line using the mapping (like streaming does) - const toolCallLineId = buffers.toolCallIdToLineId.get(toolCallId); - if (!toolCallLineId) break; + for (const toolReturn of toolReturns) { + const toolCallId = toolReturn.tool_call_id; + if (!toolCallId) continue; - const existingLine = buffers.byId.get(toolCallLineId); - if (!existingLine || existingLine.kind !== "tool_call") break; + // Look up the line using the mapping (like streaming does) + const toolCallLineId = buffers.toolCallIdToLineId.get(toolCallId); + if (!toolCallLineId) continue; - // Update the existing line with the result - buffers.byId.set(toolCallLineId, { - ...existingLine, - resultText: msg.tool_return, - resultOk: msg.status === "success", - phase: "finished", - }); + const existingLine = buffers.byId.get(toolCallLineId); + if (!existingLine || existingLine.kind !== "tool_call") continue; + + // Update the existing line with the result + // Handle both func_response (streaming) and tool_return (SDK) properties + const resultText = + ("func_response" in toolReturn + ? toolReturn.func_response + : undefined) || + ("tool_return" in toolReturn + ? toolReturn.tool_return + : undefined) || + ""; + buffers.byId.set(toolCallLineId, { + ...existingLine, + resultText, + resultOk: toolReturn.status === "success", + phase: "finished", + }); + } break; } diff --git a/src/cli/helpers/stream.ts b/src/cli/helpers/stream.ts index dacc6d2..d9055de 100644 --- a/src/cli/helpers/stream.ts +++ b/src/cli/helpers/stream.ts @@ -20,7 +20,8 @@ type DrainResult = { stopReason: StopReasonType; lastRunId?: string | null; lastSeqId?: number | null; - approval?: ApprovalRequest | null; // present only if we ended due to approval + approval?: ApprovalRequest | null; // DEPRECATED: kept for backward compat + approvals?: ApprovalRequest[]; // NEW: supports parallel approvals apiDurationMs: number; // time spent in API call }; @@ -32,23 +33,20 @@ export async function drainStream( ): Promise { const startTime = performance.now(); - let approvalRequestId: string | null = null; - let toolCallId: string | null = null; - let toolName: string | null = null; - let toolArgs: string | null = null; + let _approvalRequestId: string | null = null; + const pendingApprovals = new Map< + string, + { + toolCallId: string; + toolName: string; + toolArgs: string; + } + >(); let stopReason: StopReasonType | null = null; let lastRunId: string | null = null; let lastSeqId: number | null = null; - // Helper to reset tool accumulation state at segment boundaries - // Note: approvalRequestId is NOT reset here - it persists until approval is sent - const resetToolState = () => { - toolCallId = null; - toolName = null; - toolArgs = null; - }; - for await (const chunk of stream) { // console.log("chunk", chunk); @@ -73,26 +71,26 @@ export async function drainStream( if (chunk.message_type === "ping") continue; - // Reset tool state when a tool completes (server-side execution finished) - // This ensures the next tool call starts with clean state + // Remove tool from pending approvals when it completes (server-side execution finished) + // This means the tool was executed server-side and doesn't need approval if (chunk.message_type === "tool_return_message") { - resetToolState(); + if (chunk.tool_call_id) { + pendingApprovals.delete(chunk.tool_call_id); + } // Continue processing this chunk (for UI display) } // Need to store the approval request ID to send an approval in a new run if (chunk.message_type === "approval_request_message") { - approvalRequestId = chunk.id; + _approvalRequestId = chunk.id; } - // Accumulate tool call state across streaming chunks - // NOTE: this this a little ugly - we're basically processing tool name and chunk deltas - // in both the onChunk handler and here, we could refactor to instead pull the tool name - // and JSON args from the mutated lines (eg last mutated line) - if ( - chunk.message_type === "tool_call_message" || - chunk.message_type === "approval_request_message" - ) { + // Accumulate approval request state across streaming chunks + // Support parallel tool calls by tracking each tool_call_id separately + // NOTE: Only track approval_request_message, NOT tool_call_message + // tool_call_message = auto-executed server-side (e.g., web_search) + // approval_request_message = needs user approval (e.g., Bash) + if (chunk.message_type === "approval_request_message") { // Use deprecated tool_call or new tool_calls array const toolCall = chunk.tool_call || @@ -100,28 +98,25 @@ export async function drainStream( ? chunk.tool_calls[0] : null); - // Process tool_call_id FIRST, then name, then arguments - // This ordering prevents races where name arrives before ID in separate chunks if (toolCall?.tool_call_id) { - // If this is a NEW tool call (different ID), reset accumulated state - if (toolCallId && toolCall.tool_call_id !== toolCallId) { - resetToolState(); - } - toolCallId = toolCall.tool_call_id; - } + // Get or create entry for this tool_call_id + const existing = pendingApprovals.get(toolCall.tool_call_id) || { + toolCallId: toolCall.tool_call_id, + toolName: "", + toolArgs: "", + }; - // Set name after potential reset - if (toolCall?.name) { - toolName = toolCall.name; - } - - // Accumulate arguments (may arrive across multiple chunks) - if (toolCall?.arguments) { - if (toolArgs) { - toolArgs = toolArgs + toolCall.arguments; - } else { - toolArgs = toolCall.arguments; + // Update name if provided + if (toolCall.name) { + existing.toolName = toolCall.name; } + + // Accumulate arguments (may arrive across multiple chunks) + if (toolCall.arguments) { + existing.toolArgs += toolCall.arguments; + } + + pendingApprovals.set(toolCall.tool_call_id, existing); } } @@ -148,35 +143,40 @@ export async function drainStream( markCurrentLineAsFinished(buffers); queueMicrotask(refresh); - // Package the approval request at the end, with validation + // Package the approval request(s) at the end, with validation let approval: ApprovalRequest | null = null; + let approvals: ApprovalRequest[] = []; if (stopReason === "requires_approval") { - // Validate we have complete approval state - if (!toolCallId || !toolName || !toolArgs || !approvalRequestId) { - console.error("[drainStream] Incomplete approval state at end of turn:", { - hasToolCallId: !!toolCallId, - hasToolName: !!toolName, - hasToolArgs: !!toolArgs, - hasApprovalRequestId: !!approvalRequestId, - }); - // Don't construct approval - will return null + // Convert map to array, filtering out incomplete entries + approvals = Array.from(pendingApprovals.values()).filter( + (a) => a.toolCallId && a.toolName && a.toolArgs, + ); + + if (approvals.length === 0) { + console.error( + "[drainStream] No valid approvals collected despite requires_approval stop reason", + ); } else { - approval = { - toolCallId: toolCallId, - toolName: toolName, - toolArgs: toolArgs, - }; + // Set legacy singular field for backward compatibility + approval = approvals[0] || null; } - // Reset all state after processing approval (clean slate for next turn) - resetToolState(); - approvalRequestId = null; + // Clear the map for next turn + pendingApprovals.clear(); + _approvalRequestId = null; } const apiDurationMs = performance.now() - startTime; - return { stopReason, approval, lastRunId, lastSeqId, apiDurationMs }; + return { + stopReason, + approval, + approvals, + lastRunId, + lastSeqId, + apiDurationMs, + }; } /** diff --git a/src/index.ts b/src/index.ts index 9a70f5b..15bd1a5 100755 --- a/src/index.ts +++ b/src/index.ts @@ -452,6 +452,7 @@ async function main() { loadingState, continueSession: isResumingSession, startupApproval: resumeData?.pendingApproval ?? null, + startupApprovals: resumeData?.pendingApprovals ?? [], messageHistory: resumeData?.messageHistory ?? [], tokenStreaming: settings.tokenStreaming, }); @@ -463,6 +464,7 @@ async function main() { loadingState, continueSession: isResumingSession, startupApproval: resumeData?.pendingApproval ?? null, + startupApprovals: resumeData?.pendingApprovals ?? [], messageHistory: resumeData?.messageHistory ?? [], tokenStreaming: settings.tokenStreaming, });