mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 12:33:43 +08:00
fix: preserve backend scope in conversation links (#16091)
Co-authored-by: neubig <398875+neubig@users.noreply.github.com> Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: neubig <neubig@users.noreply.github.com> Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
This commit is contained in:
co-authored by
neubig
openhands
neubig
allhands-bot
parent
e6d6c8bb62
commit
dc99e98615
@@ -27,7 +27,7 @@ afterEach(() => {
|
||||
configurable: true,
|
||||
value: ORIGINAL_LOCATION,
|
||||
});
|
||||
|
||||
window.history.pushState({}, "", "/");
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
|
||||
@@ -31,8 +31,14 @@ import AgentServerConversationService from "#/api/conversation-service/agent-ser
|
||||
import { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
|
||||
import { ExecutionStatus } from "#/types/agent-server/core";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
// Mock the unified stop conversation hook
|
||||
const mockStopConversationMutate = vi.fn();
|
||||
@@ -117,6 +123,14 @@ describe("ConversationPanel", () => {
|
||||
createMockConversation({ id: "3", title: "Conversation 3" }),
|
||||
];
|
||||
|
||||
const cloudBackend: Backend = {
|
||||
id: "cloud-prod",
|
||||
name: "Production",
|
||||
host: "https://app.all-hands.dev",
|
||||
apiKey: "bearer-key",
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStopConversationMutate.mockClear();
|
||||
@@ -166,6 +180,108 @@ describe("ConversationPanel", () => {
|
||||
expect(cards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("includes the active backend scope in conversation card links", async () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
|
||||
|
||||
const ScopedRouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => <ConversationPanel onClose={onCloseMock} />,
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
Component: () => null,
|
||||
path: "/conversations/:conversationId",
|
||||
},
|
||||
]);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ActiveBackendProvider>
|
||||
<NavigationProvider
|
||||
value={{
|
||||
currentPath: "/",
|
||||
conversationId: null,
|
||||
isNavigating: false,
|
||||
navigate: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<ScopedRouterStub />
|
||||
</NavigationProvider>
|
||||
</ActiveBackendProvider>
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const title = await screen.findByText("Conversation 1");
|
||||
expect(title.closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/conversations/1?backend=cloud-prod&org=org-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the active backend scope in compact conversation row links", async () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
|
||||
vi.spyOn(
|
||||
AgentServerConversationService,
|
||||
"searchConversations",
|
||||
).mockResolvedValue({
|
||||
items: [
|
||||
createMockConversation({
|
||||
id: "running",
|
||||
title: "Running Conversation",
|
||||
execution_status: ExecutionStatus.RUNNING,
|
||||
}),
|
||||
],
|
||||
next_page_id: null,
|
||||
});
|
||||
|
||||
const CompactRouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => <ConversationPanel compact />,
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
Component: () => null,
|
||||
path: "/conversations/:conversationId",
|
||||
},
|
||||
]);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ActiveBackendProvider>
|
||||
<NavigationProvider
|
||||
value={{
|
||||
currentPath: "/",
|
||||
conversationId: null,
|
||||
isNavigating: false,
|
||||
navigate: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<CompactRouterStub />
|
||||
</NavigationProvider>
|
||||
</ActiveBackendProvider>
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByLabelText("Running Conversation"),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"/conversations/running?backend=cloud-prod&org=org-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("should display an empty state when there are no conversations", async () => {
|
||||
const searchConversationsSpy = vi.spyOn(
|
||||
AgentServerConversationService,
|
||||
|
||||
@@ -94,9 +94,12 @@ describe("useTaskPolling", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.taskStatus).toBe("READY"));
|
||||
await waitFor(() => {
|
||||
expect(navigate).toHaveBeenCalledWith("/conversations/conversation-1", {
|
||||
replace: true,
|
||||
});
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
"/conversations/conversation-1?backend=default-local",
|
||||
{
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(getConversationState("conversation-1").draftMessage).toBe(
|
||||
@@ -129,9 +132,12 @@ describe("useTaskPolling", () => {
|
||||
renderHook(() => useTaskPollingController(), { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigate).toHaveBeenCalledWith("/conversations/conversation-1", {
|
||||
replace: true,
|
||||
});
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
"/conversations/conversation-1?backend=default-local",
|
||||
{
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
expect(getStoredConversationMetadata("conversation-1")).toEqual({
|
||||
selected_repository: "OpenHands/agent-canvas",
|
||||
@@ -184,9 +190,12 @@ describe("useTaskPolling", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigate).toHaveBeenCalledWith("/conversations/conversation-1", {
|
||||
replace: true,
|
||||
});
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
"/conversations/conversation-1?backend=default-local",
|
||||
{
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
renderHook(() => useTaskPollingController(), {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
linkPendingTaskMessages,
|
||||
schedulePendingTaskMessageReassign,
|
||||
} from "#/utils/pending-task-message-link";
|
||||
import { useBackendScopedPath } from "#/hooks/use-backend-scoped-path";
|
||||
|
||||
const storeTaskPlugins = (
|
||||
task: AppConversationStartTask,
|
||||
@@ -113,6 +114,7 @@ export const useTaskPollingController = () => {
|
||||
const { task, taskId } = polling;
|
||||
const { conversationId } = useOptionalConversationId();
|
||||
const { navigate } = useNavigation();
|
||||
const backendScopedPath = useBackendScopedPath();
|
||||
const handledReadyTaskIdRef = useRef<string | null>(null);
|
||||
|
||||
// Reassign optimistic pending messages before paint on the real conversation
|
||||
@@ -168,9 +170,11 @@ export const useTaskPollingController = () => {
|
||||
});
|
||||
}
|
||||
|
||||
navigate(`/conversations/${appConversationId}`, { replace: true });
|
||||
navigate(backendScopedPath(`/conversations/${appConversationId}`), {
|
||||
replace: true,
|
||||
});
|
||||
})();
|
||||
}, [task, taskId, navigate]);
|
||||
}, [backendScopedPath, task, taskId, navigate]);
|
||||
|
||||
return polling;
|
||||
};
|
||||
|
||||
@@ -230,7 +230,12 @@ test.describe("mock-LLM automation lifecycle", () => {
|
||||
const authHeader = `-H 'X-Session-API-Key: ${SESSION_API_KEY}'`;
|
||||
|
||||
const createCmd = [
|
||||
// --retry: the automation backend can still be settling right after
|
||||
// startup in the uvx/bin dev paths; transient connect/reset/5xx
|
||||
// failures should not abort the create (observed flake → assert then
|
||||
// sees 0 automations).
|
||||
`curl -s -X POST '${AUTOMATION_API_BASE}/preset/prompt'`,
|
||||
`--retry 3 --retry-connrefused --retry-delay 1`,
|
||||
`-H 'Content-Type: application/json'`,
|
||||
authHeader,
|
||||
`-d '${JSON.stringify({
|
||||
|
||||
@@ -105,16 +105,29 @@ async function githubRequest(method, path, token, body) {
|
||||
const retryableStatuses = new Set([429, 502, 503, 504]);
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const response = await fetch(`${API_ROOT}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${API_ROOT}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { "Content-Type": "application/json" }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
// Transient network failure (DNS/connect/reset) rejects before any
|
||||
// HTTP status is available — retry it like a retryable status.
|
||||
if (attempt < 4) {
|
||||
await sleep(1000 * 2 ** attempt);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok && retryableStatuses.has(response.status) && attempt < 4) {
|
||||
|
||||
@@ -699,7 +699,8 @@ export async function deleteProfileIfExists(page: Page, profileName: string) {
|
||||
const confirmBtn = page.getByTestId("delete-profile-confirm");
|
||||
await confirmBtn.waitFor({ state: "visible", timeout: 5_000 });
|
||||
await confirmBtn.click();
|
||||
await waitForTestId(page, "add-llm-profile");
|
||||
await expect(confirmBtn).toBeHidden({ timeout: 30_000 });
|
||||
await expect(row).toBeHidden({ timeout: 30_000 });
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
+4
-1
@@ -183,7 +183,10 @@ afterAll(async () => {
|
||||
// `ProgressEvent` is still defined. We restore real timers first so a test
|
||||
// that left fake timers active can't stall the drain.
|
||||
vi.useRealTimers();
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
// Reset handlers first so no new intercepted requests start processing
|
||||
// during the drain window.
|
||||
server.resetHandlers();
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
server.close();
|
||||
|
||||
Reference in New Issue
Block a user