feat: parallel tool calling (#75)

This commit is contained in:
Charles Packer
2025-11-07 15:02:37 -08:00
committed by GitHub
parent ea313159ce
commit 36495810ef
9 changed files with 651 additions and 312 deletions

View File

@@ -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<ApprovalRequest | null>(null);
const [approvalContext, setApprovalContext] =
useState<ApprovalContext | null>(null);
// If we have approval requests, we should show the approval dialog instead of the input area
const [pendingApprovals, setPendingApprovals] = useState<ApprovalRequest[]>(
[],
);
const [approvalContexts, setApprovalContexts] = useState<ApprovalContext[]>(
[],
);
// 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<Record<string, unknown>>(
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<Record<string, unknown>>(
startupApproval.toolArgs,
{},
const contexts = await Promise.all(
approvals.map(async (approval) => {
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
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<MessageCreate | ApprovalCreate>,
): Promise<void> => {
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<Record<string, unknown>>(
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<Record<string, unknown>>(
toolArgs,
{},
// Check permissions for all approvals
const approvalResults = await Promise.all(
approvalsToProcess.map(async (approvalItem) => {
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
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<Record<string, unknown>>(
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<Record<string, unknown>>(
@@ -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<Record<string, unknown>>(toolArgs, {});
const toolResult = await executeTool(toolName, parsedArgs);
setIsExecutingTool(true);
// Update buffers with tool return
// Execute the approved tool
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
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 && (
<Box flexDirection="column">
{liveItems.map((ln) => (
@@ -1489,7 +1674,7 @@ export default function App({
<Input
visible={
!showExitStats &&
!pendingApproval &&
pendingApprovals.length === 0 &&
!modelSelectorOpen &&
!planApprovalPending
}
@@ -1529,15 +1714,31 @@ export default function App({
)}
{/* Approval Dialog - below live items */}
{pendingApproval && (
{pendingApprovals.length > 0 && (
<>
<Box height={1} />
<ApprovalDialog
approvalRequest={pendingApproval}
approvalContext={approvalContext}
onApprove={handleApprove}
approvals={
pendingApprovals[approvalResults.length]
? [pendingApprovals[approvalResults.length]!]
: []
}
approvalContexts={
approvalContexts[approvalResults.length]
? [approvalContexts[approvalResults.length]!]
: []
}
progress={{
current: approvalResults.length + 1,
total: pendingApprovals.length,
}}
totalTools={
autoHandledResults.length + pendingApprovals.length
}
isExecuting={isExecutingTool}
onApproveAll={handleApproveCurrent}
onApproveAlways={handleApproveAlways}
onDeny={handleDeny}
onDenyAll={handleDenyCurrent}
/>
</>
)}

View File

@@ -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<DynamicPreviewProps> = ({
};
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<string, unknown> | 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) */}
<Text bold color={colors.approval.header}>
{headerLabel}
{progress && progress.total > 1
? `${progress.total} tools require approval${totalTools && totalTools > progress.total ? ` (${totalTools} total)` : ""}`
: headerLabel}
</Text>
{progress && progress.total > 1 && (
<Text dimColor>
({progress.current - 1} reviewed,{" "}
{progress.total - (progress.current - 1)} remaining)
</Text>
)}
{isExecuting && <Text dimColor>Executing tool...</Text>}
<Box height={1} />
{/* Dynamic per-tool renderer (indented) */}

View File

@@ -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

View File

@@ -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<ToolCallLine>(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<ToolCallLine>(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<ToolCallLine>(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;
}

View File

@@ -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;
}

View File

@@ -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<DrainResult> {
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,
};
}
/**