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:
Graham Neubig
2026-08-14 20:20:19 -04:00
committed by GitHub
co-authored by neubig openhands neubig allhands-bot
parent e6d6c8bb62
commit dc99e98615
8 changed files with 176 additions and 25 deletions
@@ -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(), {
+6 -2
View File
@@ -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) {
+2 -1
View File
@@ -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
View File
@@ -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();