mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 15:03:43 +08:00
fix: fetch cloud git changes/diff via app-conversations endpoints (#1225)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -44,6 +44,7 @@ export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
return AgentServerGitService.getGitChangeDiff(
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
absoluteFilePath,
|
||||
|
||||
Reference in New Issue
Block a user