feat: parallel tool calling (#75)
This commit is contained in:
529
src/cli/App.tsx
529
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<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user