Skip to content
Draft
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
66 changes: 61 additions & 5 deletions __tests__/api/agent-server-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,65 @@ describe("buildStartConversationRequest", () => {
expect(payload.agent.agent_context?.secrets).toBeUndefined();
});

describe("project skills injection", () => {
const projectSkills = [
{
name: "custom-codereview-guide",
content: "review carefully",
source: "/workspace/project/agent-canvas/.agents/skills/x.md",
description: "repo guide",
is_agentskills_format: false,
disable_model_invocation: false,
},
];

it("injects pre-loaded project skills into agent_context.skills for the OpenHands agent", () => {
// Project skills (.agents/skills/) are not auto-loaded by the
// AgentContext, so they must be threaded in as explicit skills.
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
projectSkills,
}) as { agent: { agent_context?: Record<string, unknown> } };

expect(payload.agent.agent_context).toEqual({
load_public_skills: true,
load_user_skills: true,
skills: projectSkills,
});
});

it("injects project skills into agent_context.skills for ACP agents too", () => {
// ``skills`` is acp_compatible in the SDK, so the ACP path gets the
// same repo skills as the OpenHands path.
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
agent_kind: "acp",
acp_server: "claude-code",
acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
},
},
projectSkills,
}) as { agent: { agent_context?: { skills?: unknown } } };

expect(payload.agent.agent_context?.skills).toEqual(projectSkills);
});

it("omits the skills key when there are no project skills", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
projectSkills: [],
}) as { agent: { agent_context?: Record<string, unknown> } };

expect(payload.agent.agent_context).toEqual({
load_public_skills: true,
load_user_skills: true,
});
});
});

describe("canvas_ui tool injection", () => {
it("always registers canvas_ui_tool in tool_module_qualnames, even when no user settings supply qualnames", () => {
const payload = buildStartConversationRequest({
Expand Down Expand Up @@ -647,8 +706,7 @@ describe("buildRuntimeServicesSystemSuffix", () => {
url_from_agent: "http://localhost:18001",
api_prefix: "/api/automation",
docs_url: "http://localhost:18001/api/automation/docs",
openapi_url:
"http://localhost:18001/api/automation/openapi.json",
openapi_url: "http://localhost:18001/api/automation/openapi.json",
auth_env_var: "OPENHANDS_AUTOMATION_API_KEY",
},
},
Expand All @@ -660,9 +718,7 @@ describe("buildRuntimeServicesSystemSuffix", () => {
expect(suffix).toContain("dev:automation");
expect(suffix).toContain("http://localhost:18000");
expect(suffix).toContain("http://localhost:18001");
expect(suffix).toContain(
"http://localhost:18001/api/automation/docs",
);
expect(suffix).toContain("http://localhost:18001/api/automation/docs");
expect(suffix).toContain("X-API-Key: $OPENHANDS_AUTOMATION_API_KEY");
expect(suffix).toContain("</RUNTIME_SERVICES>");
// The "don't guess" line should reference the actual agent-server URL
Expand Down
101 changes: 92 additions & 9 deletions __tests__/api/agent-server-conversation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,21 @@ vi.mock("@openhands/typescript-client/clients", async () => {
};
});

vi.mock("@openhands/typescript-client/client/http-client", async (importOriginal) => {
const actual = await importOriginal<typeof import("@openhands/typescript-client/client/http-client")>();
return {
...actual,
HttpClient: vi.fn(function HttpClientMock() {
return { post: mockSdkHttpPost };
}),
};
});
vi.mock(
"@openhands/typescript-client/client/http-client",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@openhands/typescript-client/client/http-client")
>();
return {
...actual,
HttpClient: vi.fn(function HttpClientMock() {
return { post: mockSdkHttpPost };
}),
};
},
);

vi.mock("#/api/agent-server-config", () => ({
DEFAULT_WORKING_DIR: "workspace/project",
Expand All @@ -101,6 +107,14 @@ vi.mock("#/api/settings-service/settings-service.api", () => ({
},
}));

const localBackend: Backend = {
id: "local",
name: "Local",
host: "http://localhost:54928",
apiKey: "test-api-key",
kind: "local",
};

describe("AgentServerConversationService", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -291,6 +305,75 @@ describe("AgentServerConversationService", () => {
`/state/workspaces/${secondHex}`,
);
});

it("loads project skills from the workspace root, not the per-conversation working_dir", async () => {
// Regression guard: project skills (.agents/skills/) live at the
// workspace root. The conversation's working_dir is a per-conversation
// worktree subdir (/state/workspaces/<hex>) that has no .agents/skills/,
// so the /api/skills request must use getAgentServerWorkingDir(), not it.
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
mockGetSettings.mockResolvedValue({
agent_settings: { llm: { model: "gpt-4o" } },
conversation_settings: {},
});
mockGetSettingsForConversation.mockResolvedValue({
agentSettings: { llm: { model: "gpt-4o" } },
conversationSettings: {},
secretsEncrypted: true,
});
mockHttpPost.mockResolvedValue({
data: { id: "x", created_at: "2024-01-01", updated_at: "2024-01-01" },
});
mockSdkHttpPost.mockResolvedValue({ data: { skills: [] } });

await AgentServerConversationService.createConversation();

const skillsCall = mockSdkHttpPost.mock.calls.find(
([url]) => url === "/api/skills",
);
expect(skillsCall?.[1]).toMatchObject({
load_project: true,
load_public: false,
load_user: false,
project_dir: "/workspace/project/agent-canvas",
});
});

it("loads project skills from an explicitly attached workspace override", async () => {
// When the user attaches a workspace, its path is the project root —
// skills must come from there, not the default workspace dir.
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
mockGetSettings.mockResolvedValue({
agent_settings: { llm: { model: "gpt-4o" } },
conversation_settings: {},
});
mockGetSettingsForConversation.mockResolvedValue({
agentSettings: { llm: { model: "gpt-4o" } },
conversationSettings: {},
secretsEncrypted: true,
});
mockHttpPost.mockResolvedValue({
data: { id: "x", created_at: "2024-01-01", updated_at: "2024-01-01" },
});
mockSdkHttpPost.mockResolvedValue({ data: { skills: [] } });

await AgentServerConversationService.createConversation(
undefined,
undefined,
undefined,
null,
"/home/user/my-repo",
);

const skillsCall = mockSdkHttpPost.mock.calls.find(
([url]) => url === "/api/skills",
);
expect(skillsCall?.[1]).toMatchObject({
project_dir: "/home/user/my-repo",
});
});
});

describe("downloadConversation local branch", () => {
Expand Down
126 changes: 126 additions & 0 deletions __tests__/api/skills-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,130 @@ describe("SkillsService.getSkills against the agent-server backend", () => {
});
expect(skills.map((s) => s.name)).toEqual(["alpha"]);
});

it("forwards an explicit project_dir so conversation views can scope the catalog to their own workspace", async () => {
mockGetSkills.mockResolvedValue({ skills: [], sources: {} });

await SkillsService.getSkills("/home/user/my-repo");

expect(mockGetSkills.mock.calls[0]?.[0]).toMatchObject({
load_public: true,
load_user: true,
load_project: true,
project_dir: "/home/user/my-repo",
});
});
});

describe("SkillsService.getProjectSkills", () => {
it("requests only project skills and rebuilds Skill payloads from SkillInfo", async () => {
// Two project skills: a repo skill (no triggers → always active) and a
// legacy knowledge skill (triggers → KeywordTrigger).
mockGetSkills.mockResolvedValue({
skills: [
{
name: "custom-codereview-guide",
type: "repo",
content: "review carefully",
triggers: [],
source: "/workspace/project/agent-canvas/.agents/skills/x.md",
description: "repo guide",
is_agentskills_format: false,
disable_model_invocation: false,
},
{
name: "knowledge-skill",
type: "knowledge",
content: "knowledge body",
triggers: ["deploy", "release"],
source: "user",
is_agentskills_format: false,
},
],
sources: { sandbox: 0, sdk_base: 0, org: 0, project: 2 },
});

const skills = await SkillsService.getProjectSkills(
"/workspace/project/agent-canvas",
);

// Only project skills are requested — public/user are already auto-loaded
// server-side via load_*_skills on the agent_context. The conversation's
// working dir is threaded through as project_dir.
expect(mockGetSkills.mock.calls[0]?.[0]).toMatchObject({
load_public: false,
load_user: false,
load_project: true,
load_org: false,
project_dir: "/workspace/project/agent-canvas",
});

expect(skills).toEqual([
{
name: "custom-codereview-guide",
content: "review carefully",
source: "/workspace/project/agent-canvas/.agents/skills/x.md",
description: "repo guide",
is_agentskills_format: false,
disable_model_invocation: false,
},
{
name: "knowledge-skill",
content: "knowledge body",
source: "user",
description: null,
is_agentskills_format: false,
disable_model_invocation: false,
trigger: { type: "keyword", keywords: ["deploy", "release"] },
},
]);
});

it("rebuilds a TaskTrigger for AgentSkills-format skills with triggers", async () => {
mockGetSkills.mockResolvedValue({
skills: [
{
name: "task-skill",
type: "agentskills",
content: "task body",
triggers: ["/task-skill"],
source: "project",
is_agentskills_format: true,
},
],
sources: { project: 1 },
});

const [skill] = await SkillsService.getProjectSkills();

expect(skill.trigger).toEqual({ type: "task", triggers: ["/task-skill"] });
expect(skill.is_agentskills_format).toBe(true);
});

it("returns [] for cloud backends without calling the skills client", async () => {
setRegisteredBackends([
{
id: "cloud",
name: "Cloud",
host: "https://app",
apiKey: "",
kind: "cloud",
},
]);
setActiveSelection({ backendId: "cloud" });

const skills = await SkillsService.getProjectSkills();

expect(skills).toEqual([]);
expect(mockGetSkills).not.toHaveBeenCalled();
});

it("returns [] when loading project skills fails so conversation start is not blocked", async () => {
mockGetSkills.mockRejectedValue(new Error("boom"));
vi.spyOn(console, "warn").mockImplementation(() => {});

const skills = await SkillsService.getProjectSkills();

expect(skills).toEqual([]);
});
});
Loading
Loading