fix assistant custom widget creation

This commit is contained in:
ajnart committed 2026-08-14 11:32:11 +02:00
1 parent 474d9968b5
commit ceed0979a1
21 files changed
+513 -47

No files matched your search

@@ -2,7 +2,7 @@
import { act, createElement, Fragment } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
AssistantAutoApprovalProvider,
@@ -67,6 +67,19 @@ describe("assistant automatic approval tracker", () => {
expect(tracker.claim("app-create-1")).toBe(true);
});
test("bounds automatic retries and permits a fresh claim after completion", () => {
const tracker = createAssistantAutoApprovalTracker(2);
expect(tracker.claim("custom-widget-create-1")).toBe(true);
tracker.release("custom-widget-create-1");
expect(tracker.claim("custom-widget-create-1")).toBe(true);
tracker.release("custom-widget-create-1");
expect(tracker.claim("custom-widget-create-1")).toBe(false);
tracker.complete("custom-widget-create-1");
expect(tracker.claim("custom-widget-create-1")).toBe(true);
});
test("keeps the setting for the same local conversation and resets it on a real switch", async () => {
const container = document.createElement("div");
containers.push(container);
@@ -185,4 +198,48 @@ describe("assistant automatic approval tracker", () => {
await act(async () => root.unmount());
});
test("retries an approval response that never settles and restores manual controls after two attempts", async () => {
vi.useFakeTimers();
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
const root = createRoot(container);
let confirmationCount = 0;
await act(async () => {
root.render(
createElement(
AssistantAutoApprovalProvider,
{ conversationId: "local-thread-1" },
createElement(
Fragment,
null,
createElement(AutoApprovalProbe),
createElement(AutomaticActionProbe, {
ready: true,
completed: false,
confirm: () => {
confirmationCount += 1;
},
}),
),
),
);
});
act(() => currentAutoApproval?.setEnabled(true));
expect(confirmationCount).toBe(1);
expect(automaticActionInProgress).toBe(true);
await act(async () => vi.advanceTimersByTime(5_000));
expect(confirmationCount).toBe(2);
expect(automaticActionInProgress).toBe(true);
await act(async () => vi.advanceTimersByTime(5_000));
expect(confirmationCount).toBe(2);
expect(automaticActionInProgress).toBe(false);
await act(async () => root.unmount());
vi.useRealTimers();
});
});
@@ -3,20 +3,32 @@
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
export const createAssistantAutoApprovalTracker = () => {
const AUTO_APPROVAL_RETRY_TIMEOUT_MS = 5_000;
const AUTO_APPROVAL_MAX_ATTEMPTS = 2;
export const createAssistantAutoApprovalTracker = (maximumAttempts = Number.POSITIVE_INFINITY) => {
const claimedToolCalls = new Set<string>();
const attemptsByToolCall = new Map<string, number>();
return {
claim(toolCallId: string) {
if (claimedToolCalls.has(toolCallId)) return false;
const attempts = attemptsByToolCall.get(toolCallId) ?? 0;
if (attempts >= maximumAttempts) return false;
claimedToolCalls.add(toolCallId);
attemptsByToolCall.set(toolCallId, attempts + 1);
return true;
},
release(toolCallId: string) {
claimedToolCalls.delete(toolCallId);
},
complete(toolCallId: string) {
claimedToolCalls.delete(toolCallId);
attemptsByToolCall.delete(toolCallId);
},
clear() {
claimedToolCalls.clear();
attemptsByToolCall.clear();
},
};
};
@@ -25,6 +37,8 @@ interface AssistantAutoApprovalContextValue {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
requestAction: (toolCallId: string, confirm: () => void) => boolean;
completeAction: (toolCallId: string) => void;
retryRevision: number;
}
const AssistantAutoApprovalContext = createContext<AssistantAutoApprovalContextValue | null>(null);
@@ -34,22 +48,41 @@ export const AssistantAutoApprovalProvider = ({
conversationId,
}: PropsWithChildren<{ conversationId: string }>) => {
const [enabled, setEnabledState] = useState(false);
const trackerRef = useRef(createAssistantAutoApprovalTracker());
const [retryRevision, setRetryRevision] = useState(0);
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
const retryTimersRef = useRef(new Map<string, number>());
const previousConversationIdRef = useRef(conversationId);
const setEnabled = useCallback((nextEnabled: boolean) => {
if (!nextEnabled) trackerRef.current.clear();
setEnabledState(nextEnabled);
const clearRetryTimer = useCallback((toolCallId: string) => {
const timer = retryTimersRef.current.get(toolCallId);
if (timer !== undefined) window.clearTimeout(timer);
retryTimersRef.current.delete(toolCallId);
}, []);
const clearAutomaticActions = useCallback(() => {
for (const timer of retryTimersRef.current.values()) window.clearTimeout(timer);
retryTimersRef.current.clear();
trackerRef.current.clear();
}, []);
const setEnabled = useCallback(
(nextEnabled: boolean) => {
if (!nextEnabled) clearAutomaticActions();
setEnabledState(nextEnabled);
},
[clearAutomaticActions],
);
useEffect(() => {
const previousConversationId = previousConversationIdRef.current;
previousConversationIdRef.current = conversationId;
if (previousConversationId === conversationId) return;
trackerRef.current.clear();
clearAutomaticActions();
setEnabledState(false);
}, [conversationId]);
}, [clearAutomaticActions, conversationId]);
useEffect(() => clearAutomaticActions, [clearAutomaticActions]);
const requestAction = useCallback(
(toolCallId: string, confirm: () => void) => {
@@ -57,16 +90,36 @@ export const AssistantAutoApprovalProvider = ({
try {
confirm();
clearRetryTimer(toolCallId);
retryTimersRef.current.set(
toolCallId,
window.setTimeout(() => {
retryTimersRef.current.delete(toolCallId);
trackerRef.current.release(toolCallId);
setRetryRevision((revision) => revision + 1);
}, AUTO_APPROVAL_RETRY_TIMEOUT_MS),
);
return true;
} catch {
trackerRef.current.release(toolCallId);
return false;
}
},
[enabled],
[clearRetryTimer, enabled],
);
const value = useMemo(() => ({ enabled, setEnabled, requestAction }), [enabled, requestAction, setEnabled]);
const completeAction = useCallback(
(toolCallId: string) => {
clearRetryTimer(toolCallId);
trackerRef.current.complete(toolCallId);
},
[clearRetryTimer],
);
const value = useMemo(
() => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }),
[completeAction, enabled, requestAction, retryRevision, setEnabled],
);
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
};
@@ -88,20 +141,21 @@ export const useAssistantAutomaticAction = ({
completed: boolean;
confirm: () => void;
}) => {
const { enabled, requestAction } = useAssistantAutoApproval();
const { completeAction, enabled, requestAction, retryRevision } = useAssistantAutoApproval();
const [inProgress, setInProgress] = useState(false);
const confirmRef = useRef(confirm);
confirmRef.current = confirm;
useEffect(() => {
if (!ready || completed || !enabled) {
if (completed) completeAction(toolCallId);
setInProgress(false);
return;
}
const requested = requestAction(toolCallId, () => confirmRef.current());
setInProgress(requested);
}, [completed, enabled, ready, requestAction, toolCallId]);
}, [completeAction, completed, enabled, ready, requestAction, retryRevision, toolCallId]);
return inProgress;
};
@@ -42,6 +42,38 @@ describe("shouldAutomaticallyContinueAssistant", () => {
).toBe(true);
});
test("continues an approved mutation when the same step also reports a provider-executed server tool", () => {
expect(
shouldAutomaticallyContinueAssistant({
messages: [
{
id: "assistant-message",
role: "assistant",
parts: [
{ type: "step-start" },
{
type: "dynamic-tool",
toolName: "openrouter_web_search",
toolCallId: "search-1",
input: { query: "PokéAPI" },
state: "input-available",
providerExecuted: true,
},
{
type: "dynamic-tool",
toolName: "customWidget_createFromPreview",
toolCallId: "create-1",
input: { previewSessionId: "preview-1" },
state: "approval-responded",
approval: { id: "approval-1", approved: true },
},
],
},
],
}),
).toBe(true);
});
test("waits while a human tool has no result", () => {
expect(
shouldAutomaticallyContinueAssistant({
@@ -1,6 +1,35 @@
import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import {
isToolUIPart,
lastAssistantMessageIsCompleteWithApprovalResponses,
lastAssistantMessageIsCompleteWithToolCalls,
} from "ai";
import type { UIMessage } from "ai";
type AutoSubmitOptions = Parameters<typeof lastAssistantMessageIsCompleteWithToolCalls>[0];
const lastAssistantMessageHasCompleteLocalApprovalResponses = (messages: UIMessage[]) => {
const message = messages.at(-1);
if (message?.role !== "assistant") return false;
const lastStepStartIndex = message.parts.reduce(
(lastIndex, part, index) => (part.type === "step-start" ? index : lastIndex),
-1,
);
const localToolParts = message.parts
.slice(lastStepStartIndex + 1)
.filter(isToolUIPart)
.filter((part) => !part.providerExecuted);
return (
localToolParts.some((part) => part.state === "approval-responded") &&
localToolParts.every(
(part) =>
part.state === "output-available" || part.state === "output-error" || part.state === "approval-responded",
)
);
};
export const shouldAutomaticallyContinueAssistant = (options: AutoSubmitOptions) =>
lastAssistantMessageIsCompleteWithApprovalResponses(options) || lastAssistantMessageIsCompleteWithToolCalls(options);
lastAssistantMessageIsCompleteWithApprovalResponses(options) ||
lastAssistantMessageHasCompleteLocalApprovalResponses(options.messages) ||
lastAssistantMessageIsCompleteWithToolCalls(options);
@@ -83,7 +83,6 @@ export const AssistantAskUserTool = ({
completed: result !== undefined,
confirm: () => {
if (!affirmativeResult) return;
setSubmitting(true);
addResult(affirmativeResult);
},
});
@@ -299,7 +298,6 @@ export const AssistantConfigureAppTool = ({
completed: result !== undefined,
confirm: () => {
if (!initialValues) return;
setSubmitting(true);
addResult(initialValues);
},
});
@@ -868,7 +868,6 @@ const ToolPart = ({
completed: !awaitingApproval,
confirm: () => {
respondToApproval({ approved: true, reason: "Approved automatically by the user." });
setApprovalResponse("approve");
},
});