import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { agentRoutes } from "../routes/agents.js"; import { errorHandler } from "../middleware/index.js"; const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), update: vi.fn(), resolveByReference: vi.fn(), })); const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), hasPermission: vi.fn(), })); const mockApprovalService = vi.hoisted(() => ({})); const mockBudgetService = vi.hoisted(() => ({})); const mockHeartbeatService = vi.hoisted(() => ({})); const mockIssueApprovalService = vi.hoisted(() => ({})); const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockAgentInstructionsService = vi.hoisted(() => ({ getBundle: vi.fn(), readFile: vi.fn(), updateBundle: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn(), exportFiles: vi.fn(), ensureManagedBundle: vi.fn(), materializeManagedBundle: vi.fn(), })); const mockCompanySkillService = vi.hoisted(() => ({ listRuntimeSkillEntries: vi.fn(), })); const mockSecretService = vi.hoisted(() => ({ resolveAdapterConfigForRuntime: vi.fn(), normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), })); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockAdapter = vi.hoisted(() => ({ listSkills: vi.fn(), syncSkills: vi.fn(), })); vi.mock("../services/index.js", () => ({ agentService: () => mockAgentService, agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => mockApprovalService, companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, heartbeatService: () => mockHeartbeatService, issueApprovalService: () => mockIssueApprovalService, issueService: () => ({}), logActivity: mockLogActivity, secretService: () => mockSecretService, syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), workspaceOperationService: () => mockWorkspaceOperationService, })); vi.mock("../adapters/index.js", () => ({ findServerAdapter: vi.fn(() => mockAdapter), listAdapterModels: vi.fn(), })); function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { (req as any).actor = { type: "board", userId: "local-board", companyIds: ["company-1"], source: "local_implicit", isInstanceAdmin: false, }; next(); }); app.use("/api", agentRoutes({} as any)); app.use(errorHandler); return app; } function makeAgent(adapterType: string) { return { id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", name: "Agent", role: "engineer", title: "Engineer", status: "active", reportsTo: null, capabilities: null, adapterType, adapterConfig: {}, runtimeConfig: {}, permissions: null, updatedAt: new Date(), }; } describe("agent skill routes", () => { beforeEach(() => { vi.clearAllMocks(); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: makeAgent("claude_local"), }); mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValue({ config: { env: {} } }); mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([ { key: "paperclipai/paperclip/paperclip", runtimeName: "paperclip", source: "/tmp/paperclip", required: true, requiredReason: "required", }, ]); mockAdapter.listSkills.mockResolvedValue({ adapterType: "claude_local", supported: true, mode: "ephemeral", desiredSkills: ["paperclipai/paperclip/paperclip"], entries: [], warnings: [], }); mockAdapter.syncSkills.mockResolvedValue({ adapterType: "claude_local", supported: true, mode: "ephemeral", desiredSkills: ["paperclipai/paperclip/paperclip"], entries: [], warnings: [], }); mockAgentService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeAgent("claude_local"), adapterConfig: patch.adapterConfig ?? {}, })); mockLogActivity.mockResolvedValue(undefined); }); it("skips runtime materialization when listing Claude skills", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); const res = await request(createApp()) .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { materializeMissing: false, }); expect(mockAdapter.listSkills).toHaveBeenCalledWith( expect.objectContaining({ adapterType: "claude_local", config: expect.objectContaining({ paperclipRuntimeSkills: expect.any(Array), }), }), ); }); it("keeps runtime materialization for persistent skill adapters", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("codex_local")); mockAdapter.listSkills.mockResolvedValue({ adapterType: "codex_local", supported: true, mode: "persistent", desiredSkills: ["paperclipai/paperclip/paperclip"], entries: [], warnings: [], }); const res = await request(createApp()) .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { materializeMissing: true, }); }); it("skips runtime materialization when syncing Claude skills", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("claude_local")); const res = await request(createApp()) .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") .send({ desiredSkills: ["paperclipai/paperclip/paperclip"] }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { materializeMissing: false, }); expect(mockAdapter.syncSkills).toHaveBeenCalled(); }); });