fix: fetch cloud git changes/diff via app-conversations endpoints (#1225)

This commit is contained in:
Hiep Le
2026-06-10 19:24:10 +07:00
committed by GitHub
parent dcf469855a
commit 969fcceea4
5 changed files with 153 additions and 15 deletions
+139 -1
View File
@@ -1,5 +1,12 @@
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import { callCloudProxy } from "#/api/cloud/proxy";
import type { Backend } from "#/api/backend-registry/types";
import AgentServerGitService from "../../src/api/git-service/agent-server-git-service.api";
const { mockGitChanges, mockGitDiff } = vi.hoisted(() => ({
@@ -16,6 +23,10 @@ vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({
}),
}));
vi.mock("#/api/cloud/proxy", () => ({
callCloudProxy: vi.fn(),
}));
describe("AgentServerGitService", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -30,6 +41,7 @@ describe("AgentServerGitService", () => {
await expect(
AgentServerGitService.getGitChanges(
"123",
"http://localhost:3000/api/conversations/123",
"test-api-key",
"/workspace",
@@ -41,6 +53,7 @@ describe("AgentServerGitService", () => {
mockGitChanges.mockResolvedValue([]);
await AgentServerGitService.getGitChanges(
"123",
"http://localhost:3000/api/conversations/123",
"my-session-key",
"/workspace/project",
@@ -61,6 +74,7 @@ describe("AgentServerGitService", () => {
const pathWithSlashes = "/workspace/project/src/components";
await AgentServerGitService.getGitChanges(
"123",
"http://localhost:3000/api/conversations/123",
"test-api-key",
pathWithSlashes,
@@ -80,6 +94,7 @@ describe("AgentServerGitService", () => {
]);
const result = await AgentServerGitService.getGitChanges(
"123",
"http://localhost:3000/api/conversations/123",
"test-api-key",
"/workspace",
@@ -101,6 +116,7 @@ describe("AgentServerGitService", () => {
});
await AgentServerGitService.getGitChangeDiff(
"123",
"http://localhost:3000/api/conversations/123",
"test-api-key",
"/workspace/project/file.ts",
@@ -121,6 +137,7 @@ describe("AgentServerGitService", () => {
const filePath = "/workspace/project/src/components/Button.tsx";
await AgentServerGitService.getGitChangeDiff(
"123",
"http://localhost:3000/api/conversations/123",
"test-api-key",
filePath,
@@ -136,6 +153,7 @@ describe("AgentServerGitService", () => {
mockGitDiff.mockResolvedValue(expectedDiff);
const result = await AgentServerGitService.getGitChangeDiff(
"123",
"http://localhost:3000/api/conversations/123",
"test-api-key",
"/workspace/file.ts",
@@ -148,4 +166,124 @@ describe("AgentServerGitService", () => {
});
});
});
describe("cloud backend", () => {
const cloudBackend: Backend = {
id: "cloud-1",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "cloud-key",
kind: "cloud",
};
const runtimeConversationUrl =
"https://abc123.prod-runtime.all-hands.dev/api/conversations/conv-1";
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" });
vi.mocked(callCloudProxy).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("getGitChanges", () => {
test("fetches changes via the cloud app-conversations git endpoint and maps statuses", async () => {
// Arrange
vi.mocked(callCloudProxy).mockResolvedValue([
{ status: "ADDED", path: "new-file.ts" },
{ status: "UPDATED", path: "changed-file.ts" },
]);
// Act
const result = await AgentServerGitService.getGitChanges(
"conv-1",
runtimeConversationUrl,
"session-key",
"workspace/project",
);
// Assert — addressed by conversation id on the cloud API itself
// (no hostOverride / session-api-key runtime hop), with the
// relative git path normalized to an absolute runtime path.
expect(callCloudProxy).toHaveBeenCalledWith({
backend: cloudBackend,
method: "GET",
path: "/api/v1/app-conversations/conv-1/git/changes?path=%2Fworkspace%2Fproject",
});
expect(result).toEqual([
{ status: "A", path: "new-file.ts" },
{ status: "M", path: "changed-file.ts" },
]);
});
test("throws when the cloud endpoint returns a non-array response", async () => {
// Arrange — a dead runtime can surface as a non-JSON-array body.
vi.mocked(callCloudProxy).mockResolvedValue(
"<!DOCTYPE html><html>...</html>",
);
// Act + Assert
await expect(
AgentServerGitService.getGitChanges(
"conv-1",
runtimeConversationUrl,
"session-key",
"workspace/project",
),
).rejects.toThrow("Invalid response from runtime");
});
});
describe("getGitChangeDiff", () => {
test("fetches the diff via the cloud app-conversations git endpoint", async () => {
// Arrange
vi.mocked(callCloudProxy).mockResolvedValue({
original: "old content",
modified: "new content",
});
// Act
const result = await AgentServerGitService.getGitChangeDiff(
"conv-1",
runtimeConversationUrl,
"session-key",
"/workspace/project/src/file.ts",
);
// Assert
expect(callCloudProxy).toHaveBeenCalledWith({
backend: cloudBackend,
method: "GET",
path: "/api/v1/app-conversations/conv-1/git/diff?path=%2Fworkspace%2Fproject%2Fsrc%2Ffile.ts",
});
expect(result).toEqual({
original: "old content",
modified: "new content",
});
});
});
test("does not touch the runtime workspace SDK on cloud backends", async () => {
// Arrange
vi.mocked(callCloudProxy).mockResolvedValue([]);
// Act
await AgentServerGitService.getGitChanges(
"conv-1",
runtimeConversationUrl,
"session-key",
"workspace/project",
);
// Assert — the conversation's runtime URL must no longer be dialed
// from the browser; the cloud API makes the runtime hop server-side.
expect(RemoteWorkspace).not.toHaveBeenCalled();
});
});
});
@@ -27,6 +27,7 @@ describe("mock conversation handlers", () => {
// representative entries (UPDATED→M, ADDED→A, DELETED→D) so E2E snapshot
// tests can exercise the full diff-viewer UI without per-test manipulation.
const changes = await AgentServerGitService.getGitChanges(
"1",
"http://localhost:3000/api/conversations/1",
null,
"workspace/project",
@@ -1,6 +1,5 @@
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
import { mapAnyGitStatusToClientStatus } from "#/utils/git-status-mapper";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import type { GitChange, GitChangeDiff } from "../open-hands.types";
import { getActiveBackend } from "../backend-registry/active-store";
import { callCloudProxy } from "../cloud/proxy";
@@ -18,9 +17,11 @@ interface AgentServerGitChange {
* (it's `127.0.0.1:18000`); the SDK's `RemoteWorkspace` calls land
* fine. In **cloud** mode the runtime is at
* `*.prod-runtime.all-hands.dev`, which doesn't allow CORS from
* `localhost`. So cloud-mode calls go through `callCloudProxy` with the
* runtime URL as `hostOverride` and the conversation's
* `session_api_key` as the auth header — server-side hop, no CORS.
* `localhost`. So cloud-mode calls hit the cloud API's
* `GET /api/v1/app-conversations/{id}/git/{changes,diff}` proxy
* endpoints instead — the server resolves the conversation's runtime
* and makes the hop itself with the sandbox's session API key, and the
* cloud API's CORS is permissive for bearer-token requests.
*/
/**
@@ -37,22 +38,20 @@ function toAbsoluteRuntimePath(path: string): string {
class AgentServerGitService {
static async getGitChanges(
conversationId: string,
conversationUrl: string | null | undefined,
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChange[]> {
const active = getActiveBackend().backend;
if (active.kind === "cloud" && conversationUrl) {
if (active.kind === "cloud" && conversationId) {
const params = new URLSearchParams();
params.set("path", toAbsoluteRuntimePath(path));
const data = await callCloudProxy<AgentServerGitChange[]>({
backend: active,
method: "GET",
hostOverride: buildHttpBaseUrl(conversationUrl),
path: `/api/git/changes?${params.toString()}`,
authMode: "session-api-key",
sessionApiKey,
path: `/api/v1/app-conversations/${conversationId}/git/changes?${params.toString()}`,
});
if (!Array.isArray(data)) {
throw new Error(
@@ -90,22 +89,20 @@ class AgentServerGitService {
}
static async getGitChangeDiff(
conversationId: string,
conversationUrl: string | null | undefined,
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChangeDiff> {
const active = getActiveBackend().backend;
if (active.kind === "cloud" && conversationUrl) {
if (active.kind === "cloud" && conversationId) {
const params = new URLSearchParams();
params.set("path", toAbsoluteRuntimePath(path));
const diff = await callCloudProxy<GitChangeDiff & { diff?: string }>({
backend: active,
method: "GET",
hostOverride: buildHttpBaseUrl(conversationUrl),
path: `/api/git/diff?${params.toString()}`,
authMode: "session-api-key",
sessionApiKey,
path: `/api/v1/app-conversations/${conversationId}/git/diff?${params.toString()}`,
});
return {
modified: diff?.modified ?? "",
@@ -36,6 +36,7 @@ export const useUnifiedGetGitChanges = () => {
if (!conversationId) throw new Error("No conversation ID");
return AgentServerGitService.getGitChanges(
conversationId,
conversationUrl,
sessionApiKey,
gitPath,
+1
View File
@@ -44,6 +44,7 @@ export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => {
if (!conversationId) throw new Error("No conversation ID");
return AgentServerGitService.getGitChangeDiff(
conversationId,
conversationUrl,
sessionApiKey,
absoluteFilePath,