feat: refactor parallel tool calling UIs (#142)

This commit is contained in:
Charles Packer
2025-12-01 18:56:16 -08:00
committed by GitHub
parent edd173cf34
commit cbea00a163
2 changed files with 254 additions and 362 deletions

View File

@@ -6,10 +6,15 @@ import type {
} from "@letta-ai/letta-client/resources/agents/messages";
import type { ToolReturnMessage } from "@letta-ai/letta-client/resources/tools";
import type { ApprovalRequest } from "../cli/helpers/stream";
import { executeTool } from "../tools/manager";
import { executeTool, type ToolExecutionResult } from "../tools/manager";
export type ApprovalDecision =
| { type: "approve"; approval: ApprovalRequest }
| {
type: "approve";
approval: ApprovalRequest;
// If set, skip executeTool and use this result (for fancy UI tools)
precomputedResult?: ToolExecutionResult;
}
| { type: "deny"; approval: ApprovalRequest; reason: string };
// Align result type with the SDK's expected union for approvals payloads
@@ -61,6 +66,20 @@ export async function executeApprovalBatch(
}
if (decision.type === "approve") {
// If fancy UI already computed the result, use it directly
if (decision.precomputedResult) {
// Don't call onChunk - UI was already updated in the fancy UI handler
results.push({
type: "tool",
tool_call_id: decision.approval.toolCallId,
tool_return: decision.precomputedResult.toolReturn,
status: decision.precomputedResult.status,
stdout: decision.precomputedResult.stdout,
stderr: decision.precomputedResult.stderr,
});
continue;
}
// Execute the approved tool
try {
const parsedArgs =

View File

@@ -164,6 +164,31 @@ function readPlanFile(): string {
}
}
// Fancy UI tools require specialized dialogs instead of the standard ApprovalDialog
function isFancyUITool(name: string): boolean {
return (
name === "AskUserQuestion" ||
name === "EnterPlanMode" ||
name === "ExitPlanMode"
);
}
// Extract questions from AskUserQuestion tool args
function getQuestionsFromApproval(approval: ApprovalRequest) {
const parsed = safeJsonParseOr<Record<string, unknown>>(
approval.toolArgs,
{},
);
return (
(parsed.questions as Array<{
question: string;
header: string;
options: Array<{ label: string; description: string }>;
multiSelect: boolean;
}>) || []
);
}
// Get skill unload reminder if skills are loaded (using cached flag)
function getSkillUnloadReminder(): string {
const { hasLoadedSkills } = require("../agent/context");
@@ -274,29 +299,9 @@ export default function App({
}>
>([]);
// If we have a plan approval request, show the plan dialog
const [planApprovalPending, setPlanApprovalPending] = useState<{
plan: string;
toolCallId: string;
toolArgs: string;
} | null>(null);
// If we have a question approval request, show the question dialog
const [questionApprovalPending, setQuestionApprovalPending] = useState<{
questions: Array<{
question: string;
header: string;
options: Array<{ label: string; description: string }>;
multiSelect: boolean;
}>;
toolCallId: string;
} | null>(null);
// If we have an EnterPlanMode approval request, show the dialog
const [enterPlanModeApprovalPending, setEnterPlanModeApprovalPending] =
useState<{
toolCallId: string;
} | null>(null);
// Derive current approval from pending approvals and results
// This is the approval currently being shown to the user
const currentApproval = pendingApprovals[approvalResults.length];
// Model selector state
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
@@ -436,6 +441,8 @@ export default function App({
}, [refreshDerived]);
// Restore pending approval from startup when ready
// All approvals (including fancy UI tools) go through pendingApprovals
// The render logic determines which UI to show based on tool name
useEffect(() => {
// Use new plural field if available, otherwise wrap singular in array for backward compat
const approvals =
@@ -446,58 +453,7 @@ export default function App({
: [];
if (loadingState === "ready" && approvals.length > 0) {
// Check if this is an ExitPlanMode approval - route to plan dialog
const planApproval = approvals.find((a) => a.toolName === "ExitPlanMode");
if (planApproval) {
// Read plan from the plan file (not from toolArgs)
const plan = readPlanFile();
setPlanApprovalPending({
plan,
toolCallId: planApproval.toolCallId,
toolArgs: planApproval.toolArgs,
});
return;
}
// Check if this is an AskUserQuestion approval - route to question dialog
const questionApproval = approvals.find(
(a) => a.toolName === "AskUserQuestion",
);
if (questionApproval) {
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
questionApproval.toolArgs,
{},
);
const questions =
(parsedArgs.questions as Array<{
question: string;
header: string;
options: Array<{ label: string; description: string }>;
multiSelect: boolean;
}>) || [];
if (questions.length > 0) {
setQuestionApprovalPending({
questions,
toolCallId: questionApproval.toolCallId,
});
return;
}
}
// Check if this is an EnterPlanMode approval - route to enter plan mode dialog
const enterPlanModeApproval = approvals.find(
(a) => a.toolName === "EnterPlanMode",
);
if (enterPlanModeApproval) {
setEnterPlanModeApprovalPending({
toolCallId: enterPlanModeApproval.toolCallId,
});
return;
}
// Regular tool approvals (may be multiple for parallel tools)
// All approvals go through the same flow - UI rendering decides which dialog to show
setPendingApprovals(approvals);
// Analyze approval contexts for all restored approvals
@@ -667,63 +623,7 @@ export default function App({
return;
}
// Check each approval for ExitPlanMode special case
const planApproval = approvalsToProcess.find(
(a) => a.toolName === "ExitPlanMode",
);
if (planApproval) {
// Read plan from the plan file (not from toolArgs)
const plan = readPlanFile();
setPlanApprovalPending({
plan,
toolCallId: planApproval.toolCallId,
toolArgs: planApproval.toolArgs,
});
setStreaming(false);
return;
}
// Check each approval for AskUserQuestion special case
const questionApproval = approvalsToProcess.find(
(a) => a.toolName === "AskUserQuestion",
);
if (questionApproval) {
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
questionApproval.toolArgs,
{},
);
const questions =
(parsedArgs.questions as Array<{
question: string;
header: string;
options: Array<{ label: string; description: string }>;
multiSelect: boolean;
}>) || [];
if (questions.length > 0) {
setQuestionApprovalPending({
questions,
toolCallId: questionApproval.toolCallId,
});
setStreaming(false);
return;
}
}
// Check each approval for EnterPlanMode special case
const enterPlanModeApproval = approvalsToProcess.find(
(a) => a.toolName === "EnterPlanMode",
);
if (enterPlanModeApproval) {
setEnterPlanModeApprovalPending({
toolCallId: enterPlanModeApproval.toolCallId,
});
setStreaming(false);
return;
}
// Check permissions for all approvals
// Check permissions for all approvals (including fancy UI tools)
const approvalResults = await Promise.all(
approvalsToProcess.map(async (approvalItem) => {
// Check if approval is incomplete (missing name or arguments)
@@ -756,15 +656,30 @@ export default function App({
);
// 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",
);
// Fancy UI tools should always go through their dialog, even if auto-allowed
const needsUserInput: typeof approvalResults = [];
const autoDenied: typeof approvalResults = [];
const autoAllowed: typeof approvalResults = [];
for (const ac of approvalResults) {
const { approval, permission } = ac;
let decision = permission.decision;
// Fancy tools should always go through a UI dialog in interactive mode,
// even if a rule says "allow". Deny rules are still respected.
if (isFancyUITool(approval.toolName) && decision === "allow") {
decision = "ask";
}
if (decision === "ask") {
needsUserInput.push(ac);
} else if (decision === "deny") {
autoDenied.push(ac);
} else {
// decision === "allow"
autoAllowed.push(ac);
}
}
// Execute auto-allowed tools
const autoAllowedResults = await Promise.all(
@@ -2450,10 +2365,11 @@ ${recentCommits}
const handlePlanApprove = useCallback(
async (acceptEdits: boolean = false) => {
if (!planApprovalPending) return;
const currentIndex = approvalResults.length;
const approval = pendingApprovals[currentIndex];
if (!approval) return;
const { toolCallId, toolArgs } = planApprovalPending;
setPlanApprovalPending(null);
const isLast = currentIndex + 1 >= pendingApprovals.length;
// Exit plan mode
const newMode = acceptEdits ? "acceptEdits" : "default";
@@ -2461,9 +2377,9 @@ ${recentCommits}
setUiPermissionMode(newMode);
try {
// Execute ExitPlanMode tool
// Execute ExitPlanMode tool to get the result
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
toolArgs,
approval.toolArgs,
{},
);
const toolResult = await executeTool("ExitPlanMode", parsedArgs);
@@ -2473,132 +2389,131 @@ ${recentCommits}
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: toolCallId,
tool_call_id: approval.toolCallId,
tool_return: toolResult.toolReturn,
status: toolResult.status,
stdout: toolResult.stdout,
stderr: toolResult.stderr,
});
// Rotate to a new thinking message
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,
},
],
},
]);
const decision = {
type: "approve" as const,
approval,
precomputedResult: toolResult,
};
if (isLast) {
setIsExecutingTool(true);
await sendAllResults(decision);
} else {
setApprovalResults((prev) => [...prev, decision]);
}
} catch (e) {
appendError(String(e));
setStreaming(false);
}
},
[planApprovalPending, processConversation, appendError, refreshDerived],
[
pendingApprovals,
approvalResults,
sendAllResults,
appendError,
refreshDerived,
],
);
const handlePlanKeepPlanning = useCallback(
async (reason: string) => {
if (!planApprovalPending) return;
const currentIndex = approvalResults.length;
const approval = pendingApprovals[currentIndex];
if (!approval) return;
const { toolCallId } = planApprovalPending;
setPlanApprovalPending(null);
const isLast = currentIndex + 1 >= pendingApprovals.length;
// Stay in plan mode - send denial with user's feedback to agent
try {
// Rotate to a new thinking message for this continuation
setThinkingMessage(getRandomThinkingMessage());
// Stay in plan mode
const denialReason =
reason ||
"The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
// Restart conversation loop with denial response
await processConversation([
{
type: "approval",
approval_request_id: toolCallId,
approve: false,
reason:
reason ||
"The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.",
},
]);
} catch (e) {
appendError(String(e));
setStreaming(false);
const decision = {
type: "deny" as const,
approval,
reason: denialReason,
};
if (isLast) {
setIsExecutingTool(true);
await sendAllResults(decision);
} else {
setApprovalResults((prev) => [...prev, decision]);
}
},
[planApprovalPending, processConversation, appendError],
[pendingApprovals, approvalResults, sendAllResults],
);
const handleQuestionSubmit = useCallback(
async (answers: Record<string, string>) => {
if (!questionApprovalPending) return;
const currentIndex = approvalResults.length;
const approval = pendingApprovals[currentIndex];
if (!approval) return;
const { toolCallId, questions } = questionApprovalPending;
setQuestionApprovalPending(null);
const isLast = currentIndex + 1 >= pendingApprovals.length;
try {
// Format the answer string like Claude Code does
const answerParts = questions.map((q) => {
const answer = answers[q.question] || "";
return `"${q.question}"="${answer}"`;
});
const toolReturn = `User has answered your questions: ${answerParts.join(", ")}. You can now continue with the user's answers in mind.`;
// Get questions from approval args
const questions = getQuestionsFromApproval(approval);
// 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: toolReturn,
status: "success",
stdout: null,
stderr: null,
});
// Format the answer string like Claude Code does
const answerParts = questions.map((q) => {
const answer = answers[q.question] || "";
return `"${q.question}"="${answer}"`;
});
const toolReturn = `User has answered your questions: ${answerParts.join(", ")}. You can now continue with the user's answers in mind.`;
// Rotate to a new thinking message
setThinkingMessage(getRandomThinkingMessage());
refreshDerived();
const precomputedResult: ToolExecutionResult = {
toolReturn,
status: "success",
};
// Restart conversation loop with the answer
await processConversation([
{
type: "approval",
approvals: [
{
type: "tool",
tool_call_id: toolCallId,
tool_return: toolReturn,
status: "success",
stdout: null,
stderr: null,
},
],
},
]);
} catch (e) {
appendError(String(e));
setStreaming(false);
// Update buffers with tool return
onChunk(buffersRef.current, {
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: approval.toolCallId,
tool_return: toolReturn,
status: "success",
stdout: null,
stderr: null,
});
setThinkingMessage(getRandomThinkingMessage());
refreshDerived();
const decision = {
type: "approve" as const,
approval,
precomputedResult,
};
if (isLast) {
setIsExecutingTool(true);
await sendAllResults(decision);
} else {
setApprovalResults((prev) => [...prev, decision]);
}
},
[questionApprovalPending, processConversation, appendError, refreshDerived],
[pendingApprovals, approvalResults, sendAllResults, refreshDerived],
);
const handleEnterPlanModeApprove = useCallback(async () => {
if (!enterPlanModeApprovalPending) return;
const currentIndex = approvalResults.length;
const approval = pendingApprovals[currentIndex];
if (!approval) return;
const { toolCallId } = enterPlanModeApprovalPending;
setEnterPlanModeApprovalPending(null);
const isLast = currentIndex + 1 >= pendingApprovals.length;
// Generate plan file path
const planFilePath = generatePlanFilePath();
@@ -2623,95 +2538,63 @@ Remember: DO NOT write or edit any files yet. This is a read-only exploration an
Plan file path: ${planFilePath}`;
try {
// 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: toolReturn,
status: "success",
stdout: null,
stderr: null,
});
const precomputedResult: ToolExecutionResult = {
toolReturn,
status: "success",
};
// Rotate to a new thinking message
setThinkingMessage(getRandomThinkingMessage());
refreshDerived();
// Update buffers with tool return
onChunk(buffersRef.current, {
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: approval.toolCallId,
tool_return: toolReturn,
status: "success",
stdout: null,
stderr: null,
});
// Restart conversation loop with approval
await processConversation([
{
type: "approval",
approvals: [
{
type: "tool",
tool_call_id: toolCallId,
tool_return: toolReturn,
status: "success",
stdout: null,
stderr: null,
},
],
},
]);
} catch (e) {
appendError(String(e));
setStreaming(false);
setThinkingMessage(getRandomThinkingMessage());
refreshDerived();
const decision = {
type: "approve" as const,
approval,
precomputedResult,
};
if (isLast) {
setIsExecutingTool(true);
await sendAllResults(decision);
} else {
setApprovalResults((prev) => [...prev, decision]);
}
}, [
enterPlanModeApprovalPending,
processConversation,
appendError,
refreshDerived,
]);
}, [pendingApprovals, approvalResults, sendAllResults, refreshDerived]);
const handleEnterPlanModeReject = useCallback(async () => {
if (!enterPlanModeApprovalPending) return;
const currentIndex = approvalResults.length;
const approval = pendingApprovals[currentIndex];
if (!approval) return;
const { toolCallId } = enterPlanModeApprovalPending;
setEnterPlanModeApprovalPending(null);
const isLast = currentIndex + 1 >= pendingApprovals.length;
const rejectionReason =
"User chose to skip plan mode and start implementing directly.";
try {
// Update buffers with tool rejection (format matches what harness sends)
onChunk(buffersRef.current, {
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: toolCallId,
tool_return: `Error: request to call tool denied. User reason: ${rejectionReason}`,
status: "error",
stdout: null,
stderr: null,
});
const decision = {
type: "deny" as const,
approval,
reason: rejectionReason,
};
// Rotate to a new thinking message
setThinkingMessage(getRandomThinkingMessage());
refreshDerived();
// Restart conversation loop with rejection
await processConversation([
{
type: "approval",
approval_request_id: toolCallId,
approve: false,
reason: rejectionReason,
},
]);
} catch (e) {
appendError(String(e));
setStreaming(false);
if (isLast) {
setIsExecutingTool(true);
await sendAllResults(decision);
} else {
setApprovalResults((prev) => [...prev, decision]);
}
}, [
enterPlanModeApprovalPending,
processConversation,
appendError,
refreshDerived,
]);
}, [pendingApprovals, approvalResults, sendAllResults]);
// Live area shows only in-progress items
const liveItems = useMemo(() => {
@@ -2799,29 +2682,27 @@ Plan file path: ${planFilePath}`;
{loadingState === "ready" && (
<>
{/* Transcript */}
{liveItems.length > 0 &&
pendingApprovals.length === 0 &&
!planApprovalPending && (
<Box flexDirection="column">
{liveItems.map((ln) => (
<Box key={ln.id} marginTop={1}>
{ln.kind === "user" ? (
<UserMessage line={ln} />
) : ln.kind === "reasoning" ? (
<ReasoningMessage line={ln} />
) : ln.kind === "assistant" ? (
<AssistantMessage line={ln} />
) : ln.kind === "tool_call" ? (
<ToolCallMessage line={ln} />
) : ln.kind === "error" ? (
<ErrorMessage line={ln} />
) : (
<CommandMessage line={ln} />
)}
</Box>
))}
</Box>
)}
{liveItems.length > 0 && pendingApprovals.length === 0 && (
<Box flexDirection="column">
{liveItems.map((ln) => (
<Box key={ln.id} marginTop={1}>
{ln.kind === "user" ? (
<UserMessage line={ln} />
) : ln.kind === "reasoning" ? (
<ReasoningMessage line={ln} />
) : ln.kind === "assistant" ? (
<AssistantMessage line={ln} />
) : ln.kind === "tool_call" ? (
<ToolCallMessage line={ln} />
) : ln.kind === "error" ? (
<ErrorMessage line={ln} />
) : (
<CommandMessage line={ln} />
)}
</Box>
))}
</Box>
)}
{/* Ensure 1 blank line above input when there are no live items */}
{liveItems.length === 0 && <Box height={1} />}
@@ -2842,10 +2723,7 @@ Plan file path: ${planFilePath}`;
!modelSelectorOpen &&
!toolsetSelectorOpen &&
!systemPromptSelectorOpen &&
!agentSelectorOpen &&
!planApprovalPending &&
!questionApprovalPending &&
!enterPlanModeApprovalPending
!agentSelectorOpen
}
streaming={streaming}
commandRunning={commandRunning}
@@ -2901,12 +2779,12 @@ Plan file path: ${planFilePath}`;
/>
)}
{/* Plan Mode Dialog - below live items */}
{planApprovalPending && (
{/* Plan Mode Dialog - for ExitPlanMode tool */}
{currentApproval?.toolName === "ExitPlanMode" && (
<>
<Box height={1} />
<PlanModeDialog
plan={planApprovalPending.plan}
plan={readPlanFile()}
onApprove={() => handlePlanApprove(false)}
onApproveAndAcceptEdits={() => handlePlanApprove(true)}
onKeepPlanning={handlePlanKeepPlanning}
@@ -2915,15 +2793,18 @@ Plan file path: ${planFilePath}`;
)}
{/* Question Dialog - for AskUserQuestion tool */}
{questionApprovalPending && (
<QuestionDialog
questions={questionApprovalPending.questions}
onSubmit={handleQuestionSubmit}
/>
{currentApproval?.toolName === "AskUserQuestion" && (
<>
<Box height={1} />
<QuestionDialog
questions={getQuestionsFromApproval(currentApproval)}
onSubmit={handleQuestionSubmit}
/>
</>
)}
{/* Enter Plan Mode Dialog - for EnterPlanMode tool */}
{enterPlanModeApprovalPending && (
{currentApproval?.toolName === "EnterPlanMode" && (
<>
<Box height={1} />
<EnterPlanModeDialog
@@ -2933,27 +2814,19 @@ Plan file path: ${planFilePath}`;
</>
)}
{/* Approval Dialog - below live items */}
{pendingApprovals.length > 0 && (
{/* Approval Dialog - for standard tools (not fancy UI tools) */}
{currentApproval && !isFancyUITool(currentApproval.toolName) && (
<>
<Box height={1} />
<ApprovalDialog
approvals={
pendingApprovals[approvalResults.length]
? ([
pendingApprovals[
approvalResults.length
] as ApprovalRequest,
] as ApprovalRequest[])
: []
}
approvals={[currentApproval]}
approvalContexts={
approvalContexts[approvalResults.length]
? ([
? [
approvalContexts[
approvalResults.length
] as ApprovalContext,
] as ApprovalContext[])
]
: []
}
progress={{