mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents delegate work by creating follow-up tasks through the issue API, the CLI, and the chat `create_task` tool > - An agent can omit `parentId` when it creates a follow-up from an ordinary execution task. The API accepts the task as a top-level task. The task hierarchy does not show where the task came from, even though the run provenance is recorded > - The operator must read run history to learn why the task exists. This is slow and it hides delegated work from the parent task > - This pull request keeps the parent link for delegated follow-ups by default, keeps intentional standalone tasks possible, and shows an access-checked "Created from <task> by <agent>" note on the task page from the recorded run provenance > - The benefit is that each agent-created task shows its source without log inspection, while structural parentage, provenance, and blockers stay separate concepts ## Linked Issues or Issue Description Refs #13241 (shows tasks created from a task on the source task page; this PR shows the reverse direction on the created task) Refs #9727 (open proposal for creation provenance tracking) **What existing behavior does this improve?** Issue creation by agent runs, and the task detail page. **Subsystem affected** Server (`POST /api/companies/:companyId/issues`, `GET /api/issues/:id`), shared types, UI task detail page, CLI `issue create`, Paperclip skill docs. **Current behavior** When an agent run bound to an execution task creates a task and omits `parentId`, the task is created with no parent. The task page shows no source. The operator must inspect the run log to find the task that the agent was working on. The source run and the privacy parent are recorded, but the UI does not use them. **Proposed behavior** - When an agent run bound to an ordinary execution task omits `parentId`, the server sets `parentId` to that task. The `issue.created` activity records `parentDefaultedFromRunIssue: true`. - An explicit `parentId: null` keeps the task standalone. Conversation runs (chat handoffs), board or user creates, watchdog follow-ups, a run task the agent may not mutate, a default parent that would cause a delegation cycle, and a default parent the agent may not create a child under (for example a protected assignment policy without a grant) also stay standalone. The server runs the same `tasks:assign` decision with the defaulted parent in scope before it accepts the default. No request that succeeded before now fails. An explicit `parentId` is still checked as before. - `GET /api/issues/:id` returns `createdFrom` with the source task, the run, and the agent. The server resolves it from `origin_run_id`, or from the recorded creation activity for historical rows. It returns `null` when the viewer may not read the source task or the run, when the run is from another company, or when provenance is missing. - The task page shows "Created from <task> by <agent>" in the classic breadcrumb area and in the streamlined Tasks tab. The note is not a blocker and it is shown with or without a parent. - The CLI `issue create` command gets `--standalone`, which sends `parentId: null`. **Reason and benefit** Operators see why a task exists on the task itself. Delegated follow-ups stay inside the parent task tree, so parent completion and child wake-ups keep working. Private and cross-company sources stay hidden. **Breaking changes** None for the API schema. Agents that create tasks from a run and relied on the omitted `parentId` to mean "top-level" must now send `parentId: null` (CLI: `--standalone`). The skill and the spec document this. ## What Changed - `server/src/services/issue-creation-source.ts` (new): resolves a task's creation source from the origin run or the creation activity, scoped to the company, and loads the run-bound task by UUID or identifier. - `server/src/routes/issues.ts`: `POST /companies/:companyId/issues` defaults `parentId` to the run's task when the body omits it (with the exclusions above) and records the default in the activity log. `GET /issues/:id` adds the access-checked `createdFrom` field. - `packages/shared/src/types/issue.ts` and index exports: new `IssueCreationSource` type and the optional `createdFrom` field on `Issue`. - `ui/src/components/task-detail/IssueCreatedFromNote.tsx` (new), `TaskDetailTasksPanel.tsx`, and `IssueDetail.tsx`: show the creation source in both task page layouts and count it in the Tasks tab badge. - `cli/src/commands/client/issue.ts`: `issue create --standalone`. - Docs: `doc/SPEC-implementation.md`, `doc/CLI.md`, `skills/paperclip/SKILL.md`. - Tests: `server/src/__tests__/issue-creation-source-routes.test.ts` (new), `TaskDetailTasksPanel.test.tsx`, `IssueDetail.test.tsx`. ## Verification Run the new server route test (embedded Postgres): ```sh cd server && pnpm exec vitest run src/__tests__/issue-creation-source-routes.test.ts ``` It covers: ordinary delegation gets the parent and `createdFrom`; explicit `parentId: null` stays standalone but still shows the source; conversation handoff and board create stay standalone; a would-be delegation cycle falls back to standalone; a protected source task without a grant falls back to standalone while an explicit parent still returns 403; an explicit cycle still returns 409; historical provenance from the creation activity; missing provenance, a private source for an outsider, and a cross-company run all return `null`. Related existing suites that passed locally (11 files, 313 tests): `issue-created-from-routes`, `issue-create-deduplication-routes`, `issue-agent-mutation-ownership-routes`, `chat-project-tools`, `issue-assignee-invokability-routes`, `issue-watchdogs-routes`, `issue-dependency-wakeups-routes`, `public-mcp`, `native-runtime/task-creation-backlog`, `issue-access-grants-routes`, `issue-list-assignee-filter-routes`. UI: ```sh cd ui && pnpm exec vitest run src/components/task-detail/TaskDetailTasksPanel.test.tsx src/pages/IssueDetail.test.tsx pnpm check:token-gates ``` Manual check: as an agent run bound to a task, `POST /api/companies/:companyId/issues` with only a title. The response has `parentId` set to the run's task. Open the new task; the page shows "Created from <task> by <agent>". The related server suites pass locally (`issue-created-from`, `issue-create-deduplication`, `issue-agent-mutation-ownership`, `chat-project-tools`, `issue-watchdogs`, `public-mcp`, `task-creation-backlog`, `issue-access-grants`, `permissions-upgrade-boundary`). The full `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` were not run locally because the sandbox has a 4 GB memory limit and the TypeScript compiler is killed on the server project; the CI gates on this PR cover them. ## Risks - Behavior shift for agent clients: a run-created task without `parentId` now becomes a child of the run's task. Clients that want a top-level task must send `parentId: null`. The watchdog, conversation, and board paths are excluded, and the default never adds a blocker. - The default parent inherits the parent's execution workspace, which run-created tasks already inherited from the run's task. - `createdFrom` adds one run lookup, one issue lookup, one access decision, and one agent lookup to the task detail request. It is skipped when the task has no origin run and no creation activity with a run. - Privacy: the field returns `null` unless the viewer can read the source task and the run, so a private source is indistinguishable from missing provenance. ## Model Used Claude Fable 5.1 (`claude-fable-5-1`), Anthropic, run through Claude Code in a Paperclip-managed execution workspace with tool use, shell execution, and a local embedded Postgres for tests. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
218 lines
13 KiB
TypeScript
218 lines
13 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { activityLog, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { issueRoutes } from "../routes/issues.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
const describePostgres = support.supported ? describe : describe.skip;
|
|
|
|
describePostgres("agent-created follow-ups: parent links and creation source", () => {
|
|
let db: ReturnType<typeof createDb>;
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
const companyId = randomUUID();
|
|
const otherCompanyId = randomUUID();
|
|
const qaAgentId = randomUUID();
|
|
const engineerAgentId = randomUUID();
|
|
const foreignAgentId = randomUUID();
|
|
const sourceId = randomUUID();
|
|
const conversationId = randomUUID();
|
|
const privateSourceId = randomUUID();
|
|
const protectedSourceId = randomUUID();
|
|
const foreignSourceId = randomUUID();
|
|
const runOnSource = randomUUID();
|
|
const runOnConversation = randomUUID();
|
|
const runOnPrivate = randomUUID();
|
|
const runOnProtected = randomUUID();
|
|
const foreignRun = randomUUID();
|
|
const legacyRun = randomUUID();
|
|
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase("paperclip-creation-source-");
|
|
db = createDb(database.connectionString);
|
|
await db.insert(companies).values([
|
|
{ id: companyId, name: "Paperclip", issuePrefix: "PAP", defaultResponsibleUserId: "board-user", requireBoardApprovalForNewAgents: false },
|
|
{ id: otherCompanyId, name: "Other", issuePrefix: "OTH", defaultResponsibleUserId: "board-user", requireBoardApprovalForNewAgents: false },
|
|
]);
|
|
await db.insert(agents).values([
|
|
{ id: qaAgentId, companyId, name: "Paperclip QA", role: "qa", adapterType: "codex_local", status: "active" },
|
|
{ id: engineerAgentId, companyId, name: "Engineer", role: "engineer", adapterType: "codex_local", status: "active" },
|
|
{ id: foreignAgentId, companyId: otherCompanyId, name: "Other QA", role: "qa", adapterType: "codex_local", status: "active" },
|
|
]);
|
|
await db.insert(issues).values([
|
|
{ id: sourceId, companyId, title: "Verify Tailscale fix", identifier: "PAP-168", issueNumber: 168, status: "in_progress", assigneeAgentId: qaAgentId, createdByAgentId: engineerAgentId },
|
|
{ id: conversationId, companyId, title: "Chat with QA", identifier: "PAP-170", issueNumber: 170, status: "in_review", assigneeAgentId: qaAgentId, conversationAgentId: qaAgentId, conversationUserId: "board-user", conversationState: "waiting" },
|
|
{ id: privateSourceId, companyId, title: "Private finding", identifier: "PAP-171", issueNumber: 171, status: "in_progress", visibility: "private", privacyRootIssueId: privateSourceId, assigneeAgentId: qaAgentId },
|
|
{ id: protectedSourceId, companyId, title: "Protected task", identifier: "PAP-172", issueNumber: 172, status: "in_progress", assigneeAgentId: qaAgentId, executionPolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } },
|
|
{ id: foreignSourceId, companyId: otherCompanyId, title: "Foreign source", identifier: "OTH-1", issueNumber: 1 },
|
|
]);
|
|
await db.insert(heartbeatRuns).values([
|
|
{ id: runOnSource, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: sourceId, nativeIssueId: sourceId, status: "running", contextSnapshot: { issueId: sourceId } },
|
|
{ id: runOnConversation, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: conversationId, status: "running", contextSnapshot: { issueId: conversationId } },
|
|
{ id: runOnPrivate, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: privateSourceId, status: "running", contextSnapshot: { issueId: privateSourceId } },
|
|
{ id: runOnProtected, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: protectedSourceId, status: "running", contextSnapshot: { issueId: protectedSourceId } },
|
|
{ id: foreignRun, companyId: otherCompanyId, agentId: foreignAgentId, scopeKind: "issue", issueId: foreignSourceId, status: "running" },
|
|
{ id: legacyRun, companyId, agentId: qaAgentId, status: "succeeded", contextSnapshot: { taskKey: "PAP-168" } },
|
|
]);
|
|
}, 30_000);
|
|
afterAll(async () => { await database?.cleanup(); });
|
|
|
|
const boardActor = { type: "board", userId: "board-user", source: "local_implicit", isInstanceAdmin: true } as Express.Request["actor"];
|
|
function agentActor(agentId: string, runId?: string): Express.Request["actor"] {
|
|
return { type: "agent", companyId, agentId, runId, source: "agent_jwt", onBehalfOfUserId: null, onBehalfOfMemberships: [] } as Express.Request["actor"];
|
|
}
|
|
function app(actor: Express.Request["actor"]) {
|
|
const instance = express();
|
|
instance.use(express.json());
|
|
instance.use((req, _res, next) => { req.actor = actor; next(); });
|
|
instance.use("/api", issueRoutes(db, {} as never));
|
|
instance.use(errorHandler);
|
|
return instance;
|
|
}
|
|
async function createdActivity(issueId: string) {
|
|
const [row] = await db.select().from(activityLog)
|
|
.where(and(eq(activityLog.entityId, issueId), eq(activityLog.action, "issue.created")));
|
|
return row ?? null;
|
|
}
|
|
|
|
it("parents an ordinary delegated follow-up under the run's task when parentId is omitted", async () => {
|
|
const created = await request(app(agentActor(qaAgentId, runOnSource)))
|
|
.post(`/api/companies/${companyId}/issues`)
|
|
.send({ title: "Fix regression found during verification" });
|
|
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
|
expect(created.body.parentId).toBe(sourceId);
|
|
expect(created.body.originRunId).toBe(runOnSource);
|
|
expect(created.body.blockedBy ?? []).toEqual([]);
|
|
const activity = await createdActivity(created.body.id);
|
|
expect(activity?.details).toMatchObject({ parentId: sourceId, parentDefaultedFromRunIssue: true });
|
|
|
|
const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`);
|
|
expect(detail.status).toBe(200);
|
|
expect(detail.body.createdFrom).toEqual({
|
|
issue: { id: sourceId, identifier: "PAP-168", title: "Verify Tailscale fix", status: "in_progress" },
|
|
run: { id: runOnSource, agentId: qaAgentId },
|
|
agent: { id: qaAgentId, name: "Paperclip QA" },
|
|
});
|
|
});
|
|
|
|
it("keeps an explicit parentId: null standalone but still exposes the source task", async () => {
|
|
const created = await request(app(agentActor(qaAgentId, runOnSource)))
|
|
.post(`/api/companies/${companyId}/issues`)
|
|
.send({ title: "Intentional top-level follow-up", parentId: null });
|
|
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
|
expect(created.body.parentId).toBeNull();
|
|
const activity = await createdActivity(created.body.id);
|
|
expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue");
|
|
|
|
const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`);
|
|
expect(detail.status).toBe(200);
|
|
expect(detail.body.parentId).toBeNull();
|
|
expect(detail.body.createdFrom).toMatchObject({
|
|
issue: { id: sourceId, identifier: "PAP-168" },
|
|
agent: { name: "Paperclip QA" },
|
|
});
|
|
});
|
|
|
|
it("never parents conversation handoffs or board-created tasks", async () => {
|
|
const handoff = await request(app(agentActor(qaAgentId, runOnConversation)))
|
|
.post(`/api/companies/${companyId}/issues`)
|
|
.send({ title: "Execution task from chat" });
|
|
expect(handoff.status, JSON.stringify(handoff.body)).toBe(201);
|
|
expect(handoff.body.parentId).toBeNull();
|
|
|
|
const manual = await request(app(boardActor))
|
|
.post(`/api/companies/${companyId}/issues`)
|
|
.send({ title: "Board task" });
|
|
expect(manual.status, JSON.stringify(manual.body)).toBe(201);
|
|
expect(manual.body.parentId).toBeNull();
|
|
const detail = await request(app(boardActor)).get(`/api/issues/${manual.body.id}`);
|
|
expect(detail.body.createdFrom).toBeNull();
|
|
});
|
|
|
|
it("stays standalone instead of failing when the default parent would be a delegation cycle", async () => {
|
|
const created = await request(app(agentActor(qaAgentId, runOnSource)))
|
|
.post(`/api/companies/${companyId}/issues`)
|
|
.send({ title: "Bug for the engineer who delegated to QA", assigneeAgentId: engineerAgentId });
|
|
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
|
expect(created.body.parentId).toBeNull();
|
|
expect(created.body.assigneeAgentId).toBe(engineerAgentId);
|
|
const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`);
|
|
expect(detail.body.createdFrom).toMatchObject({ issue: { id: sourceId } });
|
|
});
|
|
|
|
it("stays standalone instead of failing when the default parent is protected without a grant", async () => {
|
|
// The agent runs the protected task, so it may mutate it, but a protected
|
|
// assignment policy denies child creation without an explicit grant.
|
|
const created = await request(app(agentActor(qaAgentId, runOnProtected)))
|
|
.post(`/api/companies/${companyId}/issues`)
|
|
.send({ title: "Follow-up from a protected task" });
|
|
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
|
expect(created.body.parentId).toBeNull();
|
|
const activity = await createdActivity(created.body.id);
|
|
expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue");
|
|
const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`);
|
|
expect(detail.body.createdFrom).toMatchObject({ issue: { id: protectedSourceId, identifier: "PAP-172" } });
|
|
|
|
const explicit = await request(app(agentActor(qaAgentId, runOnProtected)))
|
|
.post(`/api/companies/${companyId}/issues`)
|
|
.send({ title: "Explicit child of a protected task", parentId: protectedSourceId });
|
|
expect(explicit.status, JSON.stringify(explicit.body)).toBe(403);
|
|
});
|
|
|
|
it("still honors an explicit parent and rejects an explicit delegation cycle", async () => {
|
|
const [other] = await db.insert(issues).values({ companyId, title: "Another parent", status: "todo" }).returning();
|
|
const created = await request(app(agentActor(qaAgentId, runOnSource)))
|
|
.post(`/api/companies/${companyId}/issues`)
|
|
.send({ title: "Explicit child", parentId: other!.id });
|
|
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
|
expect(created.body.parentId).toBe(other!.id);
|
|
const activity = await createdActivity(created.body.id);
|
|
expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue");
|
|
|
|
const cycle = await request(app(agentActor(qaAgentId, runOnSource)))
|
|
.post(`/api/companies/${companyId}/issues`)
|
|
.send({ title: "Explicit cycle", parentId: sourceId, assigneeAgentId: engineerAgentId });
|
|
expect(cycle.status).toBe(409);
|
|
});
|
|
|
|
it("resolves historical provenance from the creation activity's run", async () => {
|
|
const historicalId = randomUUID();
|
|
await db.insert(issues).values({ id: historicalId, companyId, title: "Historical follow-up", status: "todo", createdByAgentId: qaAgentId });
|
|
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: qaAgentId, runId: legacyRun, action: "issue.created", entityType: "issue", entityId: historicalId });
|
|
const detail = await request(app(boardActor)).get(`/api/issues/${historicalId}`);
|
|
expect(detail.status).toBe(200);
|
|
expect(detail.body.createdFrom).toMatchObject({ issue: { id: sourceId, identifier: "PAP-168" }, run: { id: legacyRun } });
|
|
});
|
|
|
|
it("returns null when provenance is missing, inaccessible, or from another company", async () => {
|
|
const orphanId = randomUUID();
|
|
const fromPrivateId = randomUUID();
|
|
const fromForeignId = randomUUID();
|
|
await db.insert(issues).values([
|
|
{ id: orphanId, companyId, title: "No provenance", status: "todo", createdByAgentId: qaAgentId },
|
|
{ id: fromPrivateId, companyId, title: "Created from a private task", status: "todo", originRunId: runOnPrivate, createdByAgentId: qaAgentId },
|
|
{ id: fromForeignId, companyId, title: "Origin run in another company", status: "todo", originRunId: foreignRun },
|
|
]);
|
|
await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: qaAgentId, runId: null, action: "issue.created", entityType: "issue", entityId: orphanId });
|
|
|
|
const orphan = await request(app(boardActor)).get(`/api/issues/${orphanId}`);
|
|
expect(orphan.body.createdFrom).toBeNull();
|
|
|
|
const foreign = await request(app(boardActor)).get(`/api/issues/${fromForeignId}`);
|
|
expect(foreign.status).toBe(200);
|
|
expect(foreign.body.createdFrom).toBeNull();
|
|
|
|
const asOutsider = await request(app(agentActor(engineerAgentId))).get(`/api/issues/${fromPrivateId}`);
|
|
expect(asOutsider.status, JSON.stringify(asOutsider.body)).toBe(200);
|
|
expect(asOutsider.body.createdFrom).toBeNull();
|
|
|
|
const asParticipant = await request(app(agentActor(qaAgentId))).get(`/api/issues/${fromPrivateId}`);
|
|
expect(asParticipant.status).toBe(200);
|
|
expect(asParticipant.body.createdFrom).toMatchObject({ issue: { id: privateSourceId, identifier: "PAP-171" } });
|
|
});
|
|
});
|