mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Merge origin/master into provider routing connections
Co-Authored-By: Paperclip <noreply@paperclip.ing> * origin/master: fix(ui): let a selected saved subscription be used without a tile click (#14996) fix: report pending execution phase at Stop timeout (#14990) fix: retain resolved ACP execution timeout metadata (#14986) fix(agents): reduce hiring templates to role descriptions (#14985)
This commit is contained in:
commit
cba9868517
60 files changed
+2587
-1065
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import express from "express";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -113,7 +114,9 @@ vi.mock("../services/instance-settings.js", async (importOriginal) => ({
|
||||
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(() => mockAdapter),
|
||||
findActiveServerAdapter: vi.fn(() => mockAdapter),
|
||||
findActiveServerAdapter: vi.fn((adapterType: string) => adapterType === "paperclip_runner"
|
||||
? { ...mockAdapter, supportsInstructionsBundle: true }
|
||||
: mockAdapter),
|
||||
listAdapterModels: vi.fn(),
|
||||
detectAdapterModel: vi.fn(),
|
||||
}));
|
||||
@@ -156,7 +159,9 @@ function registerModuleMocks() {
|
||||
|
||||
vi.doMock("../adapters/index.js", () => ({
|
||||
findServerAdapter: vi.fn(() => mockAdapter),
|
||||
findActiveServerAdapter: vi.fn(() => mockAdapter),
|
||||
findActiveServerAdapter: vi.fn((adapterType: string) => adapterType === "paperclip_runner"
|
||||
? { ...mockAdapter, supportsInstructionsBundle: true }
|
||||
: mockAdapter),
|
||||
listAdapterModels: vi.fn(),
|
||||
detectAdapterModel: vi.fn(),
|
||||
}));
|
||||
@@ -1117,34 +1122,68 @@ describe.sequential("agent skill routes", () => {
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("materializes the bundled CEO instruction set for default CEO agents", async () => {
|
||||
it.each(["claude_local", "paperclip_runner"].flatMap((adapterType) =>
|
||||
["agents", "agent-hires"].map((route) => ({ adapterType, route })),
|
||||
))("materializes only the CEO entry file for $adapterType via $route", async ({ adapterType, route }) => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
||||
enableBetaSkills: false,
|
||||
enableNativeRunner: true,
|
||||
});
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.post(`/api/companies/company-1/${route}`)
|
||||
.send({
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
adapterType,
|
||||
adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {},
|
||||
}));
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
const createdAgentId = expectResponseId(res.body.id);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: createdAgentId,
|
||||
role: "ceo",
|
||||
adapterType: "claude_local",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
"AGENTS.md": expect.stringContaining("You are the CEO."),
|
||||
"HEARTBEAT.md": expect.stringContaining("CEO Heartbeat Checklist"),
|
||||
"SOUL.md": expect.stringContaining("CEO Persona"),
|
||||
"TOOLS.md": expect.stringContaining("# Tools"),
|
||||
}),
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
const createdAgentId = expectResponseId(route === "agents" ? res.body.id : res.body.agent.id);
|
||||
const entry = await readFile(new URL("../onboarding-assets/ceo/AGENTS.md", import.meta.url), "utf8");
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ id: createdAgentId, role: "ceo", adapterType }),
|
||||
{ "AGENTS.md": entry },
|
||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["claude_local", "paperclip_runner"].flatMap((adapterType) =>
|
||||
["agents", "agent-hires"].map((route) => ({ adapterType, route })),
|
||||
))("preserves an explicit CEO instruction bundle for $adapterType via $route", async ({ adapterType, route }) => {
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
||||
enableBetaSkills: false,
|
||||
enableNativeRunner: true,
|
||||
});
|
||||
const customFiles = {
|
||||
"AGENTS.md": "You lead the bespoke research company. Read NOTES.md for its current focus.",
|
||||
"NOTES.md": "Focus on the board's research questions.",
|
||||
};
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post(`/api/companies/company-1/${route}`)
|
||||
.send({
|
||||
name: "Research Lead",
|
||||
role: "ceo",
|
||||
adapterType,
|
||||
adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {},
|
||||
instructionsBundle: { entryFile: "AGENTS.md", files: customFiles },
|
||||
}));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
const createdAgentId = expectResponseId(route === "agents" ? res.body.id : res.body.agent.id);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ id: createdAgentId, role: "ceo", adapterType }),
|
||||
customFiles,
|
||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||
);
|
||||
const returnedAgent = route === "agents" ? res.body : res.body.agent;
|
||||
expect(returnedAgent.adapterConfig).toMatchObject({
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: `/tmp/${createdAgentId}/instructions/AGENTS.md`,
|
||||
});
|
||||
expect(returnedAgent.adapterConfig).not.toHaveProperty("promptTemplate");
|
||||
});
|
||||
|
||||
it("materializes the bundled default instruction set for non-CEO agents with no prompt template", async () => {
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
|
||||
@@ -35,7 +35,11 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK", () => {
|
||||
it("keeps unconfirmed Stop context off unrelated events and drops arbitrary error fields", async () => {
|
||||
it.each([
|
||||
{ phase: "workspace_restore", phaseElapsedMs: 60_123, expectedPhase: "workspace_restore", expectedElapsedMs: 60_123 },
|
||||
{ phase: "private-provider-phase", phaseElapsedMs: 100, expectedPhase: "unknown", expectedElapsedMs: null },
|
||||
{ phase: "workspace_restore", phaseElapsedMs: Infinity, expectedPhase: "workspace_restore", expectedElapsedMs: null },
|
||||
])("keeps bounded unconfirmed Stop context off unrelated events ($expectedPhase)", async ({ phase, phaseElapsedMs, expectedPhase, expectedElapsedMs }) => {
|
||||
const Sentry = sentryPackage!;
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
vi.stubEnv("SENTRY_DSN_BACKEND", "https://public@example.invalid/1");
|
||||
@@ -55,6 +59,7 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
|
||||
const timeout = Object.assign(new AdapterStopTimeoutError(60_000, {
|
||||
runId: "11111111-1111-4111-8111-111111111111", adapterType: "cursor",
|
||||
runtimeMode: "legacy", abortRequested: true,
|
||||
phase, phaseElapsedMs,
|
||||
}), {
|
||||
cause: new Error("private-provider-cause"),
|
||||
providerResponse: { headers: "private-provider-headers", body: "private-provider-body" },
|
||||
@@ -72,6 +77,7 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
|
||||
contexts: { adapter_stop: {
|
||||
runId: "11111111-1111-4111-8111-111111111111", adapterType: "cursor",
|
||||
runtimeMode: "legacy", abortRequested: true, timeoutMs: 60_000,
|
||||
phase: expectedPhase, phaseElapsedMs: expectedElapsedMs,
|
||||
} },
|
||||
});
|
||||
expect(JSON.stringify(events)).not.toContain("private-provider-");
|
||||
|
||||
@@ -136,6 +136,7 @@ describe("captureException", () => {
|
||||
const error = new AdapterStopTimeoutError(60_000, {
|
||||
runId: "11111111-1111-4111-8111-111111111111",
|
||||
adapterType: "cursor", runtimeMode: "legacy", abortRequested: true,
|
||||
phase: "instruction_collection", phaseElapsedMs: 60_321,
|
||||
});
|
||||
Object.assign(error, { providerResponse: "private fixture payload" });
|
||||
captureException(error);
|
||||
@@ -144,7 +145,8 @@ describe("captureException", () => {
|
||||
expect(sdk.captureException.mock.calls[0]).toEqual([
|
||||
expect.objectContaining({ message: error.message, stack: error.stack }),
|
||||
{ tags: { error_code: "adapter_stop_unconfirmed" }, fingerprint: ["{{ default }}"], contexts: {
|
||||
adapter_stop: { runId: "11111111-1111-4111-8111-111111111111", adapterType: "cursor", runtimeMode: "legacy", abortRequested: true, timeoutMs: 60_000 },
|
||||
adapter_stop: { runId: "11111111-1111-4111-8111-111111111111", adapterType: "cursor", runtimeMode: "legacy", abortRequested: true, timeoutMs: 60_000,
|
||||
phase: "instruction_collection", phaseElapsedMs: 60_321 },
|
||||
} },
|
||||
]);
|
||||
expect(JSON.stringify(sdk.captureException.mock.calls[0])).not.toContain("private fixture payload");
|
||||
@@ -159,10 +161,12 @@ describe("captureException", () => {
|
||||
await sentryReady;
|
||||
captureException(new AdapterStopTimeoutError(NaN, {
|
||||
runId: "private fixture payload", adapterType: "private fixture payload", runtimeMode: "private fixture payload",
|
||||
phase: "private fixture payload", phaseElapsedMs: Infinity,
|
||||
}));
|
||||
expect(JSON.stringify(sdk.captureException.mock.calls)).not.toContain("private fixture payload");
|
||||
expect(sdk.captureException.mock.calls[0]).toEqual([expect.any(Error), expect.objectContaining({ contexts: {
|
||||
adapter_stop: { runId: null, adapterType: "unknown", runtimeMode: "unknown", abortRequested: null, timeoutMs: null },
|
||||
adapter_stop: { runId: null, adapterType: "unknown", runtimeMode: "unknown", abortRequested: null, timeoutMs: null,
|
||||
phase: "unknown", phaseElapsedMs: null },
|
||||
} })]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { parseFrontmatterMarkdown } from "@paperclipai/shared/frontmatter";
|
||||
import type { CatalogTeam } from "@paperclipai/shared";
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
@@ -34,12 +36,13 @@ vi.mock("../services/activity-log.js", () => ({
|
||||
|
||||
const {
|
||||
collectCatalogTeamSkillPreparations,
|
||||
listCatalogTeams,
|
||||
readCatalogTeamProvenance,
|
||||
teamsCatalogService,
|
||||
} = await import("../services/teams-catalog.js");
|
||||
|
||||
const CORE_EXEC_TEAM_ID = "paperclipai:bundled:company-defaults:core-exec-team";
|
||||
const CORE_EXEC_TEAM_HASH = "sha256:0f20e9d56124c1dc90a1e4b128fabd863538bcc935117220f719d9620f7c89f1";
|
||||
const CORE_EXEC_TEAM_HASH = (await listCatalogTeams()).find((team) => team.id === CORE_EXEC_TEAM_ID)!.contentHash;
|
||||
|
||||
function agentWithCatalogTeam(originHash: string | null, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -113,6 +116,39 @@ describe("teamsCatalogService", () => {
|
||||
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"engineering-manager\""));
|
||||
});
|
||||
|
||||
it.each(["core-exec-team", "product-engineering", "product-design", "content-machine"])(
|
||||
"preserves %s role files and canonical skill references through catalog import",
|
||||
async (teamRef) => {
|
||||
const svc = teamsCatalogService({} as any);
|
||||
const prepared = await svc.prepareCatalogTeamSource("company-1", teamRef);
|
||||
expect(prepared.errors).toEqual([]);
|
||||
for (const slug of prepared.team.agentSlugs) {
|
||||
const filePath = `agents/${slug}/AGENTS.md`;
|
||||
const original = parseFrontmatterMarkdown(await readFile(
|
||||
new URL(`../../../packages/teams-catalog/${prepared.team.path}/${filePath}`, import.meta.url),
|
||||
"utf8",
|
||||
));
|
||||
const imported = parseFrontmatterMarkdown(prepared.source.files[filePath] as string);
|
||||
const requiredSkills = (original.frontmatter.skills as string[]).map((ref) => {
|
||||
const requirement = prepared.team.requiredSkills.find((skill) => skill.ref === ref && skill.agentSlugs.includes(slug));
|
||||
expect(requirement, `${filePath}: ${ref}`).toMatchObject({ resolved: true });
|
||||
return requirement?.type === "catalog" ? requirement.catalogSkillKey : ref;
|
||||
});
|
||||
expect(imported.frontmatter, filePath).toEqual({ ...original.frontmatter, skills: requiredSkills });
|
||||
expect(imported.body, filePath).toBe(original.body);
|
||||
}
|
||||
|
||||
await svc.installCatalogTeam("company-1", teamRef);
|
||||
const [importInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!;
|
||||
expect(importInput.source.files).toEqual(prepared.source.files);
|
||||
const requiredCatalogIds = prepared.skillPreparations
|
||||
.filter((skill) => skill.action === "catalog_install_required")
|
||||
.map((skill) => skill.catalogSkillId).sort();
|
||||
expect(mockCompanySkillService.installFromCatalog.mock.calls.map((call) => call[1].catalogSkillId).sort())
|
||||
.toEqual(requiredCatalogIds);
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves target-manager slug against same-company agents before rendering reparent metadata", async () => {
|
||||
mockAgentService.list.mockResolvedValue([
|
||||
{ id: "manager-1", companyId: "company-1", name: "CEO" },
|
||||
|
||||
Reference in new issue
Block a user