Add feedback voting and thumbs capture flow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-02 09:11:49 -05:00
parent 3db6bdfc3c
commit c0d0d03bce
66 changed files with 18988 additions and 78 deletions

View file

@ -131,7 +131,7 @@ function makeAgent(adapterType: string) {
describe("agent skill routes", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: makeAgent("claude_local"),

View file

@ -29,6 +29,12 @@ vi.mock("../services/index.js", () => ({
agentService: () => ({
getById: vi.fn(),
}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(),
listFeedbackTraces: vi.fn(),
getFeedbackTraceById: vi.fn(),
saveIssueVote: vi.fn(),
}),
logActivity: vi.fn(),
}));

View file

@ -34,6 +34,12 @@ const mockCompanyPortabilityService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(),
listFeedbackTraces: vi.fn(),
getFeedbackTraceById: vi.fn(),
saveIssueVote: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
@ -41,6 +47,7 @@ vi.mock("../services/index.js", () => ({
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));
@ -78,9 +85,7 @@ function createApp(actor: Record<string, unknown>) {
describe("PATCH /api/companies/:companyId/branding", () => {
beforeEach(() => {
mockCompanyService.update.mockReset();
mockAgentService.getById.mockReset();
mockLogActivity.mockReset();
vi.resetAllMocks();
});
it("rejects non-CEO agent callers", async () => {

View file

@ -32,6 +32,12 @@ const mockCompanyPortabilityService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockFeedbackService = vi.hoisted(() => ({
listIssueVotesForUser: vi.fn(),
listFeedbackTraces: vi.fn(),
getFeedbackTraceById: vi.fn(),
saveIssueVote: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
@ -39,6 +45,7 @@ vi.mock("../services/index.js", () => ({
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
feedbackService: () => mockFeedbackService,
logActivity: mockLogActivity,
}));

File diff suppressed because it is too large Load diff

View file

@ -35,6 +35,7 @@ describe("instance settings routes", () => {
vi.clearAllMocks();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
@ -44,6 +45,7 @@ describe("instance settings routes", () => {
id: "instance-settings-1",
general: {
censorUsernameInLogs: true,
feedbackDataSharingPreference: "allowed",
},
});
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
@ -110,15 +112,22 @@ describe("instance settings routes", () => {
const getRes = await request(app).get("/api/instance/settings/general");
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ censorUsernameInLogs: false });
expect(getRes.body).toEqual({
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
});
const patchRes = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
.send({
censorUsernameInLogs: true,
feedbackDataSharingPreference: "allowed",
});
expect(patchRes.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
censorUsernameInLogs: true,
feedbackDataSharingPreference: "allowed",
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
@ -148,7 +157,7 @@ describe("instance settings routes", () => {
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
.send({ feedbackDataSharingPreference: "not_allowed" });
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();

View file

@ -35,8 +35,22 @@ vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,

View file

@ -32,11 +32,16 @@ vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
documentService: () => mockDocumentsService,
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
getExperimental: vi.fn(async () => ({})),
getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,

View file

@ -36,11 +36,25 @@ vi.mock("../services/index.js", () => ({
executionWorkspaceService: () => ({
getById: vi.fn(),
}),
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => mockGoalService,
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),

View file

@ -228,6 +228,42 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
});
it("accepts issue identifiers through getById", async () => {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
issueNumber: 1064,
identifier: "PAP-1064",
title: "Feedback votes error",
status: "todo",
priority: "medium",
createdByUserId: "user-1",
});
const issue = await svc.getById("PAP-1064");
expect(issue).toEqual(
expect.objectContaining({
id: issueId,
identifier: "PAP-1064",
}),
);
});
it("returns null instead of throwing for malformed non-uuid issue refs", async () => {
await expect(svc.getById("not-a-uuid")).resolves.toBeNull();
});
it("filters issues by execution workspace id", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
@ -357,18 +393,8 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
},
]);
await svc.archiveInbox(
companyId,
archivedIssueId,
userId,
new Date("2026-03-26T12:30:00.000Z"),
);
await svc.archiveInbox(
companyId,
resurfacedIssueId,
userId,
new Date("2026-03-26T13:00:00.000Z"),
);
await svc.archiveInbox(companyId, archivedIssueId, userId, new Date("2026-03-26T12:30:00.000Z"));
await svc.archiveInbox(companyId, resurfacedIssueId, userId, new Date("2026-03-26T13:00:00.000Z"));
await db.insert(issueComments).values({
companyId,

View file

@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
createAppMock,
createDbMock,
detectPortMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
} = vi.hoisted(() => {
const createAppMock = vi.fn(async () => ((_: unknown, __: unknown) => {}) as never);
const createDbMock = vi.fn(() => ({}) as never);
const detectPortMock = vi.fn(async (port: number) => port);
const feedbackExportServiceMock = {
flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 0, sent: 0, failed: 0 })),
};
const feedbackServiceFactoryMock = vi.fn(() => feedbackExportServiceMock);
const fakeServer = {
once: vi.fn().mockReturnThis(),
off: vi.fn().mockReturnThis(),
listen: vi.fn((_port: number, _host: string, callback?: () => void) => {
callback?.();
return fakeServer;
}),
close: vi.fn(),
};
return {
createAppMock,
createDbMock,
detectPortMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
};
});
vi.mock("node:http", () => ({
createServer: vi.fn(() => fakeServer),
}));
vi.mock("detect-port", () => ({
default: detectPortMock,
}));
vi.mock("@paperclipai/db", () => ({
createDb: createDbMock,
ensurePostgresDatabase: vi.fn(),
getPostgresDataDirectory: vi.fn(),
inspectMigrations: vi.fn(async () => ({ status: "upToDate" })),
applyPendingMigrations: vi.fn(),
reconcilePendingMigrationHistory: vi.fn(async () => ({ repairedMigrations: [] })),
formatDatabaseBackupResult: vi.fn(() => "ok"),
runDatabaseBackup: vi.fn(),
authUsers: {},
companies: {},
companyMemberships: {},
instanceUserRoles: {},
}));
vi.mock("../app.js", () => ({
createApp: createAppMock,
}));
vi.mock("../config.js", () => ({
loadConfig: vi.fn(() => ({
deploymentMode: "authenticated",
deploymentExposure: "private",
host: "127.0.0.1",
port: 3210,
allowedHostnames: [],
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
authDisableSignUp: false,
databaseMode: "postgres",
databaseUrl: "postgres://paperclip:paperclip@127.0.0.1:5432/paperclip",
embeddedPostgresDataDir: "/tmp/paperclip-test-db",
embeddedPostgresPort: 54329,
databaseBackupEnabled: false,
databaseBackupIntervalMinutes: 60,
databaseBackupRetentionDays: 30,
databaseBackupDir: "/tmp/paperclip-test-backups",
serveUi: false,
uiDevMiddleware: false,
secretsProvider: "local_encrypted",
secretsStrictMode: false,
secretsMasterKeyFilePath: "/tmp/paperclip-master.key",
storageProvider: "local_disk",
storageLocalDiskBaseDir: "/tmp/paperclip-storage",
storageS3Bucket: "paperclip-test",
storageS3Region: "us-east-1",
storageS3Endpoint: undefined,
storageS3Prefix: "",
storageS3ForcePathStyle: false,
feedbackExportBackendUrl: "https://telemetry.example.com",
feedbackExportBackendToken: "telemetry-token",
heartbeatSchedulerEnabled: false,
heartbeatSchedulerIntervalMs: 30000,
companyDeletionEnabled: false,
})),
}));
vi.mock("../middleware/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("../realtime/live-events-ws.js", () => ({
setupLiveEventsWebSocketServer: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
feedbackService: feedbackServiceFactoryMock,
heartbeatService: vi.fn(() => ({
reapOrphanedRuns: vi.fn(async () => undefined),
resumeQueuedRuns: vi.fn(async () => undefined),
tickTimers: vi.fn(async () => ({ enqueued: 0 })),
})),
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),
routineService: vi.fn(() => ({
tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })),
})),
}));
vi.mock("../storage/index.js", () => ({
createStorageServiceFromConfig: vi.fn(() => ({ id: "storage-service" })),
}));
vi.mock("../services/feedback-share-client.js", () => ({
createFeedbackTraceShareClientFromConfig: vi.fn(() => ({ id: "feedback-share-client" })),
}));
vi.mock("../startup-banner.js", () => ({
printStartupBanner: vi.fn(),
}));
vi.mock("../board-claim.js", () => ({
getBoardClaimWarningUrl: vi.fn(() => null),
initializeBoardClaimChallenge: vi.fn(async () => undefined),
}));
vi.mock("../auth/better-auth.js", () => ({
createBetterAuthHandler: vi.fn(() => undefined),
createBetterAuthInstance: vi.fn(() => ({})),
deriveAuthTrustedOrigins: vi.fn(() => []),
resolveBetterAuthSession: vi.fn(async () => null),
resolveBetterAuthSessionFromHeaders: vi.fn(async () => null),
}));
import { startServer } from "../index.ts";
describe("startServer feedback export wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.BETTER_AUTH_SECRET = "test-secret";
});
it("passes the feedback export service into createApp so pending traces flush in runtime", async () => {
const started = await startServer();
expect(started.server).toBe(fakeServer);
expect(feedbackServiceFactoryMock).toHaveBeenCalledTimes(1);
expect(createAppMock).toHaveBeenCalledTimes(1);
expect(createAppMock.mock.calls[0]?.[1]).toMatchObject({
feedbackExportService: feedbackExportServiceMock,
storageService: { id: "storage-service" },
serverPort: 3210,
});
});
});

View file

@ -51,7 +51,7 @@ async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", args, { cwd });
}
async function createTempRepo() {
async function createTempRepo(defaultBranch = "main") {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-"));
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
@ -59,7 +59,7 @@ async function createTempRepo() {
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
await runGit(repoRoot, ["checkout", "-B", "main"]);
await runGit(repoRoot, ["checkout", "-B", defaultBranch]);
return repoRoot;
}
@ -658,13 +658,7 @@ describe("realizeExecutionWorkspace", () => {
it("auto-detects the default branch when baseRef is not configured", async () => {
// Create a repo with "master" as default branch (not "main")
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-master-"));
await runGit(repoRoot, ["init", "-b", "master"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
const repoRoot = await createTempRepo("master");
// Set up a bare remote and push master so refs/remotes/origin/master
// exists locally. Note: refs/remotes/origin/HEAD is NOT set by a manual
@ -716,13 +710,7 @@ describe("realizeExecutionWorkspace", () => {
it("auto-detects the default branch via symbolic-ref when origin/HEAD is set", async () => {
// Create a repo with "master" as default branch
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-symref-"));
await runGit(repoRoot, ["init", "-b", "master"]);
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
const repoRoot = await createTempRepo("master");
// Set up a bare remote and push
const bareRemote = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bare-symref-"));