mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 14:33:11 +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,
|
configurable: true,
|
||||||
value: ORIGINAL_LOCATION,
|
value: ORIGINAL_LOCATION,
|
||||||
});
|
});
|
||||||
|
window.history.pushState({}, "", "/");
|
||||||
vi.unstubAllEnvs();
|
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 { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
|
||||||
import { ExecutionStatus } from "#/types/agent-server/core";
|
import { ExecutionStatus } from "#/types/agent-server/core";
|
||||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
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 { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend";
|
||||||
|
import type { Backend } from "#/api/backend-registry/types";
|
||||||
|
|
||||||
// Mock the unified stop conversation hook
|
// Mock the unified stop conversation hook
|
||||||
const mockStopConversationMutate = vi.fn();
|
const mockStopConversationMutate = vi.fn();
|
||||||
@@ -117,6 +123,14 @@ describe("ConversationPanel", () => {
|
|||||||
createMockConversation({ id: "3", title: "Conversation 3" }),
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockStopConversationMutate.mockClear();
|
mockStopConversationMutate.mockClear();
|
||||||
@@ -166,6 +180,108 @@ describe("ConversationPanel", () => {
|
|||||||
expect(cards).toHaveLength(3);
|
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 () => {
|
it("should display an empty state when there are no conversations", async () => {
|
||||||
const searchConversationsSpy = vi.spyOn(
|
const searchConversationsSpy = vi.spyOn(
|
||||||
AgentServerConversationService,
|
AgentServerConversationService,
|
||||||
|
|||||||
@@ -94,9 +94,12 @@ describe("useTaskPolling", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.taskStatus).toBe("READY"));
|
await waitFor(() => expect(result.current.taskStatus).toBe("READY"));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(navigate).toHaveBeenCalledWith("/conversations/conversation-1", {
|
expect(navigate).toHaveBeenCalledWith(
|
||||||
replace: true,
|
"/conversations/conversation-1?backend=default-local",
|
||||||
});
|
{
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getConversationState("conversation-1").draftMessage).toBe(
|
expect(getConversationState("conversation-1").draftMessage).toBe(
|
||||||
@@ -129,9 +132,12 @@ describe("useTaskPolling", () => {
|
|||||||
renderHook(() => useTaskPollingController(), { wrapper: createWrapper() });
|
renderHook(() => useTaskPollingController(), { wrapper: createWrapper() });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(navigate).toHaveBeenCalledWith("/conversations/conversation-1", {
|
expect(navigate).toHaveBeenCalledWith(
|
||||||
replace: true,
|
"/conversations/conversation-1?backend=default-local",
|
||||||
});
|
{
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(getStoredConversationMetadata("conversation-1")).toEqual({
|
expect(getStoredConversationMetadata("conversation-1")).toEqual({
|
||||||
selected_repository: "OpenHands/agent-canvas",
|
selected_repository: "OpenHands/agent-canvas",
|
||||||
@@ -184,9 +190,12 @@ describe("useTaskPolling", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(navigate).toHaveBeenCalledWith("/conversations/conversation-1", {
|
expect(navigate).toHaveBeenCalledWith(
|
||||||
replace: true,
|
"/conversations/conversation-1?backend=default-local",
|
||||||
});
|
{
|
||||||
|
replace: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
renderHook(() => useTaskPollingController(), {
|
renderHook(() => useTaskPollingController(), {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
linkPendingTaskMessages,
|
linkPendingTaskMessages,
|
||||||
schedulePendingTaskMessageReassign,
|
schedulePendingTaskMessageReassign,
|
||||||
} from "#/utils/pending-task-message-link";
|
} from "#/utils/pending-task-message-link";
|
||||||
|
import { useBackendScopedPath } from "#/hooks/use-backend-scoped-path";
|
||||||
|
|
||||||
const storeTaskPlugins = (
|
const storeTaskPlugins = (
|
||||||
task: AppConversationStartTask,
|
task: AppConversationStartTask,
|
||||||
@@ -113,6 +114,7 @@ export const useTaskPollingController = () => {
|
|||||||
const { task, taskId } = polling;
|
const { task, taskId } = polling;
|
||||||
const { conversationId } = useOptionalConversationId();
|
const { conversationId } = useOptionalConversationId();
|
||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
|
const backendScopedPath = useBackendScopedPath();
|
||||||
const handledReadyTaskIdRef = useRef<string | null>(null);
|
const handledReadyTaskIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Reassign optimistic pending messages before paint on the real conversation
|
// 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;
|
return polling;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -230,7 +230,12 @@ test.describe("mock-LLM automation lifecycle", () => {
|
|||||||
const authHeader = `-H 'X-Session-API-Key: ${SESSION_API_KEY}'`;
|
const authHeader = `-H 'X-Session-API-Key: ${SESSION_API_KEY}'`;
|
||||||
|
|
||||||
const createCmd = [
|
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'`,
|
`curl -s -X POST '${AUTOMATION_API_BASE}/preset/prompt'`,
|
||||||
|
`--retry 3 --retry-connrefused --retry-delay 1`,
|
||||||
`-H 'Content-Type: application/json'`,
|
`-H 'Content-Type: application/json'`,
|
||||||
authHeader,
|
authHeader,
|
||||||
`-d '${JSON.stringify({
|
`-d '${JSON.stringify({
|
||||||
|
|||||||
@@ -105,16 +105,29 @@ async function githubRequest(method, path, token, body) {
|
|||||||
const retryableStatuses = new Set([429, 502, 503, 504]);
|
const retryableStatuses = new Set([429, 502, 503, 504]);
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
const response = await fetch(`${API_ROOT}${path}`, {
|
let response;
|
||||||
method,
|
try {
|
||||||
headers: {
|
response = await fetch(`${API_ROOT}${path}`, {
|
||||||
Authorization: `Bearer ${token}`,
|
method,
|
||||||
Accept: "application/vnd.github+json",
|
headers: {
|
||||||
"X-GitHub-Api-Version": "2022-11-28",
|
Authorization: `Bearer ${token}`,
|
||||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
Accept: "application/vnd.github+json",
|
||||||
},
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
...(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();
|
const text = await response.text();
|
||||||
if (!response.ok && retryableStatuses.has(response.status) && attempt < 4) {
|
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");
|
const confirmBtn = page.getByTestId("delete-profile-confirm");
|
||||||
await confirmBtn.waitFor({ state: "visible", timeout: 5_000 });
|
await confirmBtn.waitFor({ state: "visible", timeout: 5_000 });
|
||||||
await confirmBtn.click();
|
await confirmBtn.click();
|
||||||
await waitForTestId(page, "add-llm-profile");
|
await expect(confirmBtn).toBeHidden({ timeout: 30_000 });
|
||||||
|
await expect(row).toBeHidden({ timeout: 30_000 });
|
||||||
} else {
|
} else {
|
||||||
await page.keyboard.press("Escape");
|
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
|
// `ProgressEvent` is still defined. We restore real timers first so a test
|
||||||
// that left fake timers active can't stall the drain.
|
// that left fake timers active can't stall the drain.
|
||||||
vi.useRealTimers();
|
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));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
server.close();
|
server.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user