Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,5 @@
- Onboarding modal: `src/components/features/onboarding/onboarding-modal.tsx` is a 4-step welcome flow rendered by `<OnboardingHost />` (mounted on the home route) and gated by the `openhands-onboarded` localStorage flag (`use-onboarding-completion.ts`). The four steps live under `steps/`: choose-agent (Step 0 – OpenHands selectable, Claude Code & Codex disabled with a "coming soon" note), check-backend (embeds the new `BackendForm` extracted from `backend-form-modal.tsx` plus a colored connection banner driven by `useBackendsHealth`), setup-llm (renders `<LlmSettingsScreen onSaveSuccess={onNext} />` so the existing settings UI keeps owning validation), and say-hello (text input pre-filled from `ONBOARDING$HELLO_DEFAULT_MESSAGE`, launches a no-workspace conversation via `useCreateConversation` and closes the modal). Animation: all four panels are mounted as siblings inside a horizontal rail; advancing/retreating just sets `currentStep`, which translates the rail by `-(step * 100)%` for the slide effect. Progress is rendered by `OnboardingProgressBar` with `data-state` per segment (`completed` | `current` | `upcoming`). When extending, refactor `BackendFormModal` carefully — the inner `BackendForm` is the public surface used both by the modal and by `CheckBackendStep`; the modal version still owns dirty/save tracking so it keeps "Save"/"Cancel" footer behavior.

- Worktree policy (this conversation): commits are made on the worktree branch and the user expects the worktree to stay attached to that branch. Do NOT run `git switch --detach` in the worktree and reattach the branch to the main workspace after each commit — only do that when the user explicitly asks. See `~/.openhands/skills/worktree-switch/SKILL.md` for the manual procedure the user invokes.

- `src/api/typescript-client.ts` now exports `createConversationClient()` and `createFileClient()` factories on top of `resolveClientOptions(overrides)`. Prefer them over hand-written `createHttpClient().get/post/patch/delete` for any agent-server endpoint that the SDK's `ConversationClient` or `FileClient` already covers (PR #291). `SecretsService` (`src/api/secrets-service.ts`) and `cloud/organization-service.api.ts` are still on raw `createHttpClient` because the SDK doesn't ship typed clients for them yet — migrate the SDK first.
2 changes: 2 additions & 0 deletions __tests__/api/agent-server-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ describe("agent server config", () => {
});

it("defaults the working dir to the relative workspace path", () => {
vi.stubEnv("VITE_WORKING_DIR", "");

expect(getAgentServerWorkingDir()).toBe(DEFAULT_WORKING_DIR);
});

Expand Down
150 changes: 86 additions & 64 deletions __tests__/api/agent-server-conversation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,51 @@ import AgentServerConversationService from "#/api/conversation-service/agent-ser
vi.mock("axios");

const {
mockHttpGet,
mockHttpPost,
mockHttpDelete,
mockCreateConversation,
mockGetConversations,
mockDeleteConversation,
mockDownloadTrajectory,
mockDownloadTextFile,
mockFileUpload,
mockCreateHttpClient,
mockCreateConversationClient,
mockCreateFileClient,
mockCreateRemoteWorkspace,
mockGetSettings,
mockGetSettingsForConversation,
} = vi.hoisted(() => ({
mockHttpGet: vi.fn(),
mockHttpPost: vi.fn(),
mockHttpDelete: vi.fn(),
mockCreateConversation: vi.fn(),
mockGetConversations: vi.fn(),
mockDeleteConversation: vi.fn(),
mockDownloadTrajectory: vi.fn(),
mockDownloadTextFile: vi.fn(),
mockFileUpload: vi.fn(),
mockCreateHttpClient: vi.fn(),
mockCreateConversationClient: vi.fn(),
mockCreateFileClient: vi.fn(),
mockCreateRemoteWorkspace: vi.fn(),
mockGetSettings: vi.fn(),
mockGetSettingsForConversation: vi.fn(),
}));

vi.mock("#/api/typescript-client", () => ({
createHttpClient: mockCreateHttpClient,
createConversationClient: mockCreateConversationClient,
createFileClient: mockCreateFileClient,
createRemoteWorkspace: mockCreateRemoteWorkspace,
createVSCodeClient: vi.fn(),
// Tests still patch the skills loader path indirectly via the adapter;
// returning a no-op SkillsClient is sufficient.
createSkillsClient: vi.fn(() => ({
publicSkills: vi.fn().mockResolvedValue({ skills: [] }),
})),
// SecretsService.getSecrets still uses createHttpClient directly (not
// yet migrated to a typed SDK client). Without this stub the
// createConversation tests would hit retry+fallback timing.
createHttpClient: vi.fn(() => ({
get: vi.fn().mockResolvedValue({ data: { secrets: [] } }),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
})),
}));

vi.mock("#/api/agent-server-config", () => ({
Expand All @@ -58,16 +80,33 @@ vi.mock("#/api/settings-service/settings-service.api", () => ({
describe("AgentServerConversationService", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHttpGet.mockReset();
mockHttpPost.mockReset();
mockHttpDelete.mockReset();
mockCreateConversation.mockReset();
mockGetConversations.mockReset();
mockDeleteConversation.mockReset();
mockDownloadTrajectory.mockReset();
mockDownloadTextFile.mockReset();
mockFileUpload.mockReset();

mockCreateHttpClient.mockReturnValue({
get: mockHttpGet,
post: mockHttpPost,
patch: vi.fn(),
delete: mockHttpDelete,
mockCreateConversationClient.mockReturnValue({
createConversation: mockCreateConversation,
getConversations: mockGetConversations,
deleteConversation: mockDeleteConversation,
// The rest are unused by these tests but the client surface is
// typed in the consumer, so provide no-op stubs.
sendEvent: vi.fn(),
pauseConversation: vi.fn(),
runConversation: vi.fn(),
askAgent: vi.fn(),
getConversation: vi.fn(),
searchConversations: vi.fn(),
updateConversation: vi.fn(),
});
mockCreateFileClient.mockReturnValue({
downloadTrajectory: mockDownloadTrajectory,
downloadTextFile: mockDownloadTextFile,
downloadFile: vi.fn(),
searchSubdirectories: vi.fn(),
getHome: vi.fn(),
});
mockCreateRemoteWorkspace.mockReturnValue({
fileUpload: mockFileUpload,
Expand All @@ -76,37 +115,27 @@ describe("AgentServerConversationService", () => {

describe("readConversationFile", () => {
it("downloads the plan from the conversation's own working_dir when no filePath is provided", async () => {
const encodedPlan = new TextEncoder().encode("# PLAN content").buffer;
mockHttpGet.mockImplementation((url: string) => {
if (url === "/api/conversations") {
return Promise.resolve({
data: [
{
id: "conv-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
workspace: {
working_dir: "/workspace/project/agent-canvas/conv-123",
},
},
],
});
}
return Promise.resolve({ data: encodedPlan });
});
mockGetConversations.mockResolvedValue([
{
id: "conv-123",
created_at: "2024-01-01",
updated_at: "2024-01-01",
workspace: {
working_dir: "/workspace/project/agent-canvas/conv-123",
},
},
]);
mockDownloadTextFile.mockResolvedValue("# PLAN content");

const content =
await AgentServerConversationService.readConversationFile("conv-123");

expect(content).toBe("# PLAN content");
expect(mockHttpGet).toHaveBeenCalledWith(
"/api/file/download",
expect.objectContaining({
params: {
path: "/workspace/project/agent-canvas/conv-123/.agents_tmp/PLAN.md",
},
responseType: "arrayBuffer",
}),
// The conversation lookup picks the working_dir; the file path is
// {working_dir}/.agents_tmp/PLAN.md, which the SDK's
// FileClient.downloadTextFile sends as a `path` query param.
expect(mockDownloadTextFile).toHaveBeenCalledWith(
"/workspace/project/agent-canvas/conv-123/.agents_tmp/PLAN.md",
);
});
});
Expand All @@ -122,24 +151,22 @@ describe("AgentServerConversationService", () => {
conversationSettings: {},
secretsEncrypted: true,
});
mockHttpPost.mockResolvedValue({
data: {
id: "ignored-server-id",
created_at: "2024-01-01",
updated_at: "2024-01-01",
},
mockCreateConversation.mockResolvedValue({
id: "ignored-server-id",
created_at: "2024-01-01",
updated_at: "2024-01-01",
});

await AgentServerConversationService.createConversation();
await AgentServerConversationService.createConversation();

expect(mockHttpPost).toHaveBeenCalledTimes(2);
const [firstCall, secondCall] = mockHttpPost.mock.calls;
const firstPayload = firstCall[1] as {
expect(mockCreateConversation).toHaveBeenCalledTimes(2);
const [firstCall, secondCall] = mockCreateConversation.mock.calls;
const firstPayload = firstCall[0] as {
conversation_id: string;
workspace: { working_dir: string };
};
const secondPayload = secondCall[1] as {
const secondPayload = secondCall[0] as {
conversation_id: string;
workspace: { working_dir: string };
};
Expand Down Expand Up @@ -171,17 +198,14 @@ describe("AgentServerConversationService", () => {
__resetActiveStoreForTests();
});

it("hits the local /api/file/download-trajectory endpoint with responseType blob when active backend is local", async () => {
it("delegates to FileClient.downloadTrajectory with the conversation id when active backend is local", async () => {
const zipBlob = new Blob(["zip-bytes"], { type: "application/zip" });
mockHttpGet.mockResolvedValue({ data: zipBlob });
mockDownloadTrajectory.mockResolvedValue(zipBlob);

const result =
await AgentServerConversationService.downloadConversation("conv-abc");

expect(mockHttpGet).toHaveBeenCalledWith(
"/api/file/download-trajectory/conv-abc",
expect.objectContaining({ responseType: "blob" }),
);
expect(mockDownloadTrajectory).toHaveBeenCalledWith("conv-abc");
expect(result).toBe(zipBlob);
});
});
Expand All @@ -197,14 +221,12 @@ describe("AgentServerConversationService", () => {
__resetActiveStoreForTests();
});

it("hits the local /api/conversations/{id} endpoint when active backend is local", async () => {
mockHttpDelete.mockResolvedValue({ data: undefined });
it("delegates to ConversationClient.deleteConversation when active backend is local", async () => {
mockDeleteConversation.mockResolvedValue(undefined);

await AgentServerConversationService.deleteConversation("conv-abc");

expect(mockHttpDelete).toHaveBeenCalledWith(
"/api/conversations/conv-abc",
);
expect(mockDeleteConversation).toHaveBeenCalledWith("conv-abc");
});
});

Expand Down
10 changes: 9 additions & 1 deletion __tests__/api/mock-conversation-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
import AgentServerGitService from "#/api/git-service/agent-server-git-service.api";

describe("mock conversation handlers", () => {
beforeEach(() => {
vi.stubEnv("VITE_WORKING_DIR", "");
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("returns adapted conversations for batch lookups", async () => {
const [conversation] = await AgentServerConversationService.batchGetAppConversations([
"1",
Expand Down
56 changes: 38 additions & 18 deletions __tests__/api/use-create-conversation-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,36 @@ import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"
import { getStoredConversationMetadata } from "#/api/conversation-metadata-store";

const {
mockHttpPost,
mockCreateHttpClient,
mockCreateConversation,
mockCreateConversationClient,
mockGetSettings,
mockGetSettingsForConversation,
} = vi.hoisted(() => ({
mockHttpPost: vi.fn(),
mockCreateHttpClient: vi.fn(),
mockCreateConversation: vi.fn(),
mockCreateConversationClient: vi.fn(),
mockGetSettings: vi.fn(),
mockGetSettingsForConversation: vi.fn(),
}));

vi.mock("#/api/typescript-client", () => ({
createHttpClient: mockCreateHttpClient,
createConversationClient: mockCreateConversationClient,
createFileClient: vi.fn(),
createRemoteWorkspace: vi.fn(),
createVSCodeClient: vi.fn(),
createSkillsClient: vi.fn(() => ({
publicSkills: vi.fn().mockResolvedValue({ skills: [] }),
})),
// SecretsService.getSecrets still uses createHttpClient directly (it's
// not part of the SDK clients yet); stub it as a returns-empty-data
// shape so its retry+fallback path returns [] quickly instead of
// hanging waitFor.
createHttpClient: vi.fn(() => ({
get: vi.fn().mockResolvedValue({ data: { secrets: [] } }),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
})),
}));

vi.mock("#/api/agent-server-config", () => ({
Expand Down Expand Up @@ -56,8 +71,8 @@ const wrapper = ({ children }: { children: React.ReactNode }) => {
describe("useCreateConversation persists selected repository metadata", () => {
beforeEach(() => {
window.localStorage.clear();
mockHttpPost.mockReset();
mockCreateHttpClient.mockReset();
mockCreateConversation.mockReset();
mockCreateConversationClient.mockReset();
mockGetSettings.mockReset();
mockGetSettingsForConversation.mockReset();
mockGetSettings.mockResolvedValue({
Expand All @@ -69,18 +84,23 @@ describe("useCreateConversation persists selected repository metadata", () => {
conversationSettings: {},
secretsEncrypted: true,
});
mockCreateHttpClient.mockReturnValue({
get: vi.fn(),
post: mockHttpPost,
patch: vi.fn(),
delete: vi.fn(),
mockCreateConversationClient.mockReturnValue({
createConversation: mockCreateConversation,
// Other methods on the client surface are unused here.
getConversation: vi.fn(),
getConversations: vi.fn(),
searchConversations: vi.fn(),
sendEvent: vi.fn(),
pauseConversation: vi.fn(),
runConversation: vi.fn(),
askAgent: vi.fn(),
updateConversation: vi.fn(),
deleteConversation: vi.fn(),
});
mockHttpPost.mockResolvedValue({
data: {
id: "conv-new",
created_at: "2026-05-05T00:00:00Z",
updated_at: "2026-05-05T00:00:00Z",
},
mockCreateConversation.mockResolvedValue({
id: "conv-new",
created_at: "2026-05-05T00:00:00Z",
updated_at: "2026-05-05T00:00:00Z",
});
});

Expand Down
12 changes: 2 additions & 10 deletions src/api/agent-server-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
AppConversation,
AppConversationPage,
} from "./conversation-service/agent-server-conversation-service.types";
import { createHttpClient, createSkillsClient } from "./typescript-client";
import { createFileClient, createSkillsClient } from "./typescript-client";
import SettingsService from "./settings-service/settings-service.api";
import { getStoredConversationMetadata } from "./conversation-metadata-store";

Expand Down Expand Up @@ -529,15 +529,7 @@ export async function buildStartConversationRequestWithEncryptedSettings(options
}

export async function downloadTextFile(path: string): Promise<string> {
const response = await createHttpClient().get<ArrayBuffer>(
"/api/file/download",
{
params: { path },
responseType: "arrayBuffer",
},
);

return new TextDecoder().decode(response.data);
return createFileClient().downloadTextFile(path);
}

export async function loadSkillsForConversation(
Expand Down
Loading