Files
paperclip/server/src/__tests__/user-profile-routes.test.ts
Dotta a957394420 [codex] Add structured issue-thread interactions (#4244)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Operators supervise that work through issues, comments, approvals,
and the board UI.
> - Some agent proposals need structured board/user decisions, not
hidden markdown conventions or heavyweight governed approvals.
> - Issue-thread interactions already provide a natural thread-native
surface for proposed tasks and questions.
> - This pull request extends that surface with request confirmations,
richer interaction cards, and agent/plugin/MCP helpers.
> - The benefit is that plan approvals and yes/no decisions become
explicit, auditable, and resumable without losing the single-issue
workflow.

## What Changed

- Added persisted issue-thread interactions for suggested tasks,
structured questions, and request confirmations.
- Added board UI cards for interaction review, selection, question
answers, and accept/reject confirmation flows.
- Added MCP and plugin SDK helpers for creating interaction cards from
agents/plugins.
- Updated agent wake instructions, onboarding assets, Paperclip skill
docs, and public docs to prefer structured confirmations for
issue-scoped decisions.
- Rebased the branch onto `public-gh/master` and renumbered branch
migrations to `0063` and `0064`; the idempotency migration uses `ADD
COLUMN IF NOT EXISTS` for old branch users.

## Verification

- `git diff --check public-gh/master..HEAD`
- `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts
packages/mcp-server/src/tools.test.ts
packages/shared/src/issue-thread-interactions.test.ts
ui/src/lib/issue-thread-interactions.test.ts
ui/src/lib/issue-chat-messages.test.ts
ui/src/components/IssueThreadInteractionCard.test.tsx
ui/src/components/IssueChatThread.test.tsx
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
server/src/services/issue-thread-interactions.test.ts` -> 9 files / 79
tests passed
- `pnpm -r typecheck` -> passed, including `packages/db` migration
numbering check

## Risks

- Medium: this adds a new issue-thread interaction model across
db/shared/server/ui/plugin surfaces.
- Migration risk is reduced by placing this branch after current master
migrations (`0063`, `0064`) and making the idempotency column add
idempotent for users who applied the old branch numbering.
- UI interaction behavior is covered by component tests, but this PR
does not include browser screenshots.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-class coding agent runtime. Exact model ID and
context window are not exposed in this Paperclip run; tool use and local
shell/code execution were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-21 20:15:11 -05:00

219 lines
6.4 KiB
TypeScript

import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
authUsers,
companies,
companyMemberships,
costEvents,
createDb,
issueComments,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
let errorHandler: typeof import("../middleware/index.js").errorHandler;
let userProfileRoutes: typeof import("../routes/user-profiles.js").userProfileRoutes;
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres user profile route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let companyId!: string;
let userId!: string;
let agentId!: string;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-user-profile-route-");
db = createDb(tempDb.connectionString);
}, 20_000);
beforeEach(async () => {
vi.resetModules();
vi.doUnmock("../routes/user-profiles.js");
vi.doUnmock("../routes/authz.js");
vi.doUnmock("../middleware/index.js");
const [routes, middleware] = await Promise.all([
vi.importActual<typeof import("../routes/user-profiles.js")>("../routes/user-profiles.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
userProfileRoutes = routes.userProfileRoutes;
errorHandler = middleware.errorHandler;
companyId = randomUUID();
userId = randomUUID();
agentId = randomUUID();
const now = new Date();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `U${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(authUsers).values({
id: userId,
name: "Dotta",
email: "dotta@example.com",
emailVerified: true,
image: null,
createdAt: now,
updatedAt: now,
});
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "owner",
createdAt: now,
updatedAt: now,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Coder",
role: "engineer",
adapterType: "process",
adapterConfig: {},
});
});
afterEach(async () => {
await db.delete(costEvents);
await db.delete(issueComments);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(authUsers);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
function createApp() {
if (!userProfileRoutes || !errorHandler) {
throw new Error("user profile route test dependencies were not loaded");
}
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
source: "local_implicit",
userId,
companyIds: [companyId],
};
next();
});
app.use("/api", userProfileRoutes(db));
app.use(errorHandler);
return app;
}
it("resolves a user slug and returns issue, activity, and attributed cost stats", async () => {
const doneIssueId = randomUUID();
const openIssueId = randomUUID();
const now = new Date();
const older = new Date(now.getTime() - 60_000);
await db.insert(issues).values([
{
id: doneIssueId,
companyId,
title: "Ship profile page",
status: "done",
priority: "high",
createdByUserId: userId,
identifier: "USR-1",
completedAt: now,
createdAt: now,
updatedAt: now,
},
{
id: openIssueId,
companyId,
title: "Review profile copy",
status: "in_progress",
priority: "medium",
assigneeUserId: userId,
identifier: "USR-2",
createdAt: older,
updatedAt: older,
},
]);
await db.insert(issueComments).values({
companyId,
issueId: openIssueId,
authorUserId: userId,
body: "Looks good.",
createdAt: now,
updatedAt: now,
});
await db.insert(activityLog).values({
companyId,
actorType: "user",
actorId: userId,
action: "issue.updated",
entityType: "issue",
entityId: doneIssueId,
createdAt: now,
});
await db.insert(costEvents).values({
companyId,
agentId,
issueId: doneIssueId,
provider: "openai",
biller: "openai",
billingType: "metered_api",
model: "gpt-test",
inputTokens: 120,
cachedInputTokens: 30,
outputTokens: 40,
costCents: 42,
occurredAt: now,
});
const response = await request(createApp()).get(`/api/companies/${companyId}/users/dotta/profile`);
expect(response.status).toBe(200);
expect(response.body.user.slug).toBe("dotta");
expect(response.body.user.membershipRole).toBe("owner");
expect(response.body.stats).toHaveLength(3);
const all = response.body.stats.find((entry: { key: string }) => entry.key === "all");
expect(all).toMatchObject({
touchedIssues: 2,
createdIssues: 1,
completedIssues: 1,
assignedOpenIssues: 1,
commentCount: 1,
activityCount: 1,
costCents: 42,
inputTokens: 120,
cachedInputTokens: 30,
outputTokens: 40,
costEventCount: 1,
});
expect(response.body.recentIssues.map((issue: { identifier: string }) => issue.identifier)).toEqual(["USR-1", "USR-2"]);
expect(response.body.recentActivity[0].action).toBe("issue.updated");
expect(response.body.topAgents[0]).toMatchObject({ agentId, agentName: "Coder", costCents: 42 });
expect(response.body.topProviders[0]).toMatchObject({ provider: "openai", model: "gpt-test", costCents: 42 });
});
});