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 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 mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), hasPermission: vi.fn(), })); const mockSecretService = vi.hoisted(() => ({ resolveAdapterConfigForRuntime: vi.fn(), normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), })); const mockLogActivity = vi.hoisted(() => vi.fn()); vi.mock("../services/index.js", () => ({ agentService: () => mockAgentService, agentInstructionsService: () => mockAgentInstructionsService, accessService: () => mockAccessService, approvalService: () => ({}), companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), budgetService: () => ({}), heartbeatService: () => ({}), issueApprovalService: () => ({}), issueService: () => ({}), logActivity: mockLogActivity, secretService: () => mockSecretService, syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), workspaceOperationService: () => ({}), })); vi.mock("../adapters/index.js", () => ({ findServerAdapter: vi.fn(), 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() { return { id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", name: "Agent", role: "engineer", title: "Engineer", status: "active", reportsTo: null, capabilities: null, adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: null, updatedAt: new Date(), }; } describe("agent instructions bundle routes", () => { beforeEach(() => { vi.clearAllMocks(); mockAgentService.getById.mockResolvedValue(makeAgent()); mockAgentService.update.mockImplementation(async (_id: string, patch: Record) => ({ ...makeAgent(), adapterConfig: patch.adapterConfig ?? {}, })); mockAgentInstructionsService.getBundle.mockResolvedValue({ agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1", mode: "managed", rootPath: "/tmp/agent-1", managedRootPath: "/tmp/agent-1", entryFile: "AGENTS.md", resolvedEntryPath: "/tmp/agent-1/AGENTS.md", editable: true, warnings: [], legacyPromptTemplateActive: false, legacyBootstrapPromptTemplateActive: false, files: [{ path: "AGENTS.md", size: 12, language: "markdown", markdown: true, isEntryFile: true, editable: true, deprecated: false, virtual: false, }], }); mockAgentInstructionsService.readFile.mockResolvedValue({ path: "AGENTS.md", size: 12, language: "markdown", markdown: true, isEntryFile: true, editable: true, deprecated: false, virtual: false, content: "# Agent\n", }); mockAgentInstructionsService.writeFile.mockResolvedValue({ bundle: null, file: { path: "AGENTS.md", size: 18, language: "markdown", markdown: true, isEntryFile: true, editable: true, deprecated: false, virtual: false, content: "# Updated Agent\n", }, adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: "/tmp/agent-1", instructionsEntryFile: "AGENTS.md", instructionsFilePath: "/tmp/agent-1/AGENTS.md", }, }); }); it("returns bundle metadata", async () => { const res = await request(createApp()) .get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1"); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(res.body).toMatchObject({ mode: "managed", rootPath: "/tmp/agent-1", managedRootPath: "/tmp/agent-1", entryFile: "AGENTS.md", }); expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled(); }); it("writes a bundle file and persists compatibility config", async () => { const res = await request(createApp()) .put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1") .send({ path: "AGENTS.md", content: "# Updated Agent\n", clearLegacyPromptTemplate: true, }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockAgentInstructionsService.writeFile).toHaveBeenCalledWith( expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }), "AGENTS.md", "# Updated Agent\n", { clearLegacyPromptTemplate: true }, ); expect(mockAgentService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ adapterConfig: expect.objectContaining({ instructionsBundleMode: "managed", instructionsRootPath: "/tmp/agent-1", instructionsEntryFile: "AGENTS.md", instructionsFilePath: "/tmp/agent-1/AGENTS.md", }), }), expect.any(Object), ); }); it("preserves managed instructions config when switching adapters", async () => { mockAgentService.getById.mockResolvedValue({ ...makeAgent(), adapterType: "codex_local", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: "/tmp/agent-1", instructionsEntryFile: "AGENTS.md", instructionsFilePath: "/tmp/agent-1/AGENTS.md", model: "gpt-5.4", }, }); const res = await request(createApp()) .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") .send({ adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-4", }, }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockAgentService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ adapterType: "claude_local", adapterConfig: expect.objectContaining({ model: "claude-sonnet-4", instructionsBundleMode: "managed", instructionsRootPath: "/tmp/agent-1", instructionsEntryFile: "AGENTS.md", instructionsFilePath: "/tmp/agent-1/AGENTS.md", }), }), expect.any(Object), ); }); it("merges same-adapter config patches so instructions metadata is not dropped", async () => { mockAgentService.getById.mockResolvedValue({ ...makeAgent(), adapterType: "codex_local", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: "/tmp/agent-1", instructionsEntryFile: "AGENTS.md", instructionsFilePath: "/tmp/agent-1/AGENTS.md", model: "gpt-5.4", }, }); const res = await request(createApp()) .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") .send({ adapterConfig: { command: "codex --profile engineer", }, }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockAgentService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ adapterConfig: expect.objectContaining({ command: "codex --profile engineer", model: "gpt-5.4", instructionsBundleMode: "managed", instructionsRootPath: "/tmp/agent-1", instructionsEntryFile: "AGENTS.md", instructionsFilePath: "/tmp/agent-1/AGENTS.md", }), }), expect.any(Object), ); }); it("replaces adapter config when replaceAdapterConfig is true", async () => { mockAgentService.getById.mockResolvedValue({ ...makeAgent(), adapterType: "codex_local", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: "/tmp/agent-1", instructionsEntryFile: "AGENTS.md", instructionsFilePath: "/tmp/agent-1/AGENTS.md", model: "gpt-5.4", }, }); const res = await request(createApp()) .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") .send({ replaceAdapterConfig: true, adapterConfig: { command: "codex --profile engineer", }, }); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockAgentService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ adapterConfig: expect.objectContaining({ command: "codex --profile engineer", }), }), expect.any(Object), ); expect(res.body.adapterConfig).toMatchObject({ command: "codex --profile engineer", }); expect(res.body.adapterConfig.instructionsBundleMode).toBeUndefined(); expect(res.body.adapterConfig.instructionsRootPath).toBeUndefined(); expect(res.body.adapterConfig.instructionsEntryFile).toBeUndefined(); expect(res.body.adapterConfig.instructionsFilePath).toBeUndefined(); }); });