Files
PaperClipAI/server/src/__tests__/issue-created-from-routes.test.ts
T
DottaandPaperclip 7b829efdf6 feat: show tasks created from a task by project (#13241)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A task can cause an agent to create more tasks.
> - Those tasks can belong to other projects or have another parent.
> - The subtask view does not show all work created from the current
task.
> - This pull request adds a Tasks tab with separate subtask and
creation groups.
> - Operators can follow created work without changing its parent or
project.

## Linked Issues or Issue Description

**Problem or motivation**

Operators need to see all work that an agent creates while running for a
task. Parentage alone does not describe this relationship. Legacy and
native runs must follow the same rules.

**Proposed solution**

Show all subtasks in one section. Separately group tasks created from
the source task by their current project, with a No project group when
needed. A created subtask appears in both sections. Use saved run
context and recorded creation activity to find the source task.

**Alternatives considered**

Making all created tasks children would change their hierarchy. Removing
overlap between sections would hide the creation relationship. This
change keeps the two memberships separate.

**Roadmap alignment**

This extends the existing Activity log & action attribution capability.
It does not add a new roadmap area. Related PR: #9727 adds a stored
source-task field and inbound attribution UI. This PR adds the outgoing
task list using existing run and activity records and does not require
that schema change.

## What Changed

- Add a company-scoped createdFromIssueId filter to issue lists.
- Save the actor run during task creation, including legacy child-helper
calls.
- Recover historical run attribution from creation activity when the
origin run is absent.
- Render the production Tasks panel with all subtasks and independently
grouped created work.
- Keep progress only for subtasks. Add folding, hover fades and project
links.
- Fetch all result pages and refresh on issue activity. Show load
failures with Retry.
- Add database, API, UI and pagination tests, design-guide examples and
Storybook pages.

## Verification

- Before rebase: 158 targeted tests passed. Workspace typecheck,
UI/server builds, Storybook build and token gates passed.
- After rebase: full workspace typecheck and build passed. The cursor
fix passes 26 focused tests and UI/server typechecks.
- The full local test command completed its general-server group with
10,563 passing tests and two failures: a missing native-runner fixture
and a concurrency-test timeout. Building the fixture and rerunning both
affected files passed all 41 tests. The local command stopped before its
remaining groups; all corresponding GitHub test shards passed on the
submitted head.
- GitHub checks on commit 3b4bf0bd8: 31 passed, including the aggregate
CI gate, all server/workspace/browser test shards, typecheck, build,
runner verification, canary dry run, policy and security checks. Two
conditional Storybook jobs were skipped by the workflow.
- Greptile reviewed commit 3b4bf0bd8 at 5/5 with no unresolved comments.
- Open Storybook at UX Labs / Tasks Created From a Task / Full Task
Page. Check that a created subtask appears in both sections. Fold each
group and use a project link. Check the No project group and first-task
arrival story.

## Risks

- Old tasks without a saved origin run or attributed creation activity
cannot be linked to a source. The code does not infer a source from a
shared creator or a comment.
- The new list filter reads run and activity records. Source, run,
activity and result stay within the requested company.
- Task creation now saves the actor run when no explicit origin run is
supplied. Existing explicit origins remain unchanged. No database
migration is required.

## Model Used

- OpenAI GPT-6 through Codex. The runtime identifies the model family as
GPT-6 but does not expose a more specific model ID or context-window
size. Used reasoning, repository tools, code execution and browser
inspection.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-11 12:39:08 -05:00

157 lines
8.7 KiB
TypeScript

import { randomUUID } from "node:crypto";
import express from "express";
import { eq } from "drizzle-orm";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { activityLog, agents, companies, createDb, heartbeatRuns, issues, projects } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { issueRoutes } from "../routes/issues.js";
import { issueService } from "../services/issues.js";
import { errorHandler } from "../middleware/index.js";
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
describePostgres("tasks created from an issue", () => {
let db: ReturnType<typeof createDb>;
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const sourceId = randomUUID();
const otherSourceId = randomUUID();
const agentId = randomUUID();
const legacyRunId = randomUUID();
const nativeRunId = randomUUID();
const taskIdRunId = randomUUID();
const taskKeyRunId = randomUUID();
const unrelatedRunId = randomUUID();
const foreignRunId = randomUUID();
const missingContextRunId = randomUUID();
const foreignSourceId = randomUUID();
const expected = new Set<string>();
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-created-from-");
db = createDb(database.connectionString);
await db.insert(companies).values([
{ id: companyId, name: "Origin", issuePrefix: "ORG", defaultResponsibleUserId: "board-user" },
{ id: otherCompanyId, name: "Other", issuePrefix: "OTH", defaultResponsibleUserId: "board-user" },
]);
const foreignAgentId = randomUUID();
await db.insert(agents).values([
{ id: agentId, companyId, name: "Coder", role: "engineer", adapterType: "codex_local" },
{ id: foreignAgentId, companyId: otherCompanyId, name: "Other coder", role: "engineer", adapterType: "codex_local" },
]);
await db.insert(issues).values([
{ id: sourceId, companyId, title: "Source", identifier: "ORG-1", issueNumber: 1 },
{ id: otherSourceId, companyId, title: "Other source", identifier: "ORG-2", issueNumber: 2 },
{ id: foreignSourceId, companyId: otherCompanyId, title: "Foreign source", identifier: "OTH-1" },
]);
await db.insert(heartbeatRuns).values([
{ id: legacyRunId, companyId, agentId, contextSnapshot: { issueId: sourceId }, responsibleUserId: "board-user" },
{ id: nativeRunId, companyId, agentId, runtimeMode: "native", nativeIssueId: sourceId, contextSnapshot: { issueId: otherSourceId } },
{ id: taskIdRunId, companyId, agentId, contextSnapshot: { taskId: sourceId } },
{ id: taskKeyRunId, companyId, agentId, contextSnapshot: { taskKey: "ORG-1" } },
{ id: unrelatedRunId, companyId, agentId, contextSnapshot: { issueId: otherSourceId, taskKey: "ORG-1" } },
{ id: foreignRunId, companyId: otherCompanyId, agentId: foreignAgentId, contextSnapshot: { issueId: sourceId } },
{ id: missingContextRunId, companyId, agentId, contextSnapshot: {} },
]);
const projectId = randomUUID();
await db.insert(projects).values({ id: projectId, companyId, name: "Cross-project" });
const rows = [
{ title: "Legacy top-level", originRunId: legacyRunId, projectId },
{ title: "Native child", originRunId: nativeRunId, parentId: sourceId },
{ title: "Different parent", originRunId: taskIdRunId, parentId: otherSourceId },
{ title: "No project, completed", originRunId: taskKeyRunId, status: "done" },
].map((row) => ({ id: randomUUID(), companyId, createdByAgentId: agentId, ...row }));
rows.forEach((row) => expected.add(row.id));
const historicalId = randomUUID();
const commentedId = randomUUID();
expected.add(historicalId);
await db.insert(issues).values([
...rows,
{ id: historicalId, companyId, title: "Historical child helper", parentId: otherSourceId },
{ id: commentedId, companyId, title: "Only commented on by source" },
{ companyId, title: "Manual child", parentId: sourceId },
{ companyId, title: "Same agent, unrelated run", createdByAgentId: agentId, originRunId: unrelatedRunId },
{ companyId, title: "Hidden", originRunId: legacyRunId, hiddenAt: new Date() },
{ companyId, title: "No context", originRunId: missingContextRunId },
{ companyId, title: "Wrong run company", originRunId: foreignRunId },
{ companyId: otherCompanyId, title: "Wrong issue company", originRunId: legacyRunId },
]);
await db.insert(activityLog).values([
{ companyId, actorType: "agent", actorId: agentId, runId: legacyRunId, action: "issue.child_created", entityType: "issue", entityId: historicalId },
{ companyId, actorType: "agent", actorId: agentId, runId: legacyRunId, action: "issue.comment_added", entityType: "issue", entityId: commentedId },
]);
}, 30_000);
afterAll(async () => { await database?.cleanup(); });
function app() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", userId: "board-user", source: "local_implicit", isInstanceAdmin: true };
next();
});
app.use("/api", issueRoutes(db, {} as never));
app.use(errorHandler);
return app;
}
it("returns both runners' created tasks, regardless of parent, project or completion", async () => {
for (const view of [undefined, "compact"]) {
const result = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: sourceId, ...(view ? { view } : {}) });
expect(result.status, JSON.stringify(result.body)).toBe(200);
expect(new Set(result.body.map((row: { id: string }) => row.id))).toEqual(expected);
}
});
it("keeps cursor pages complete when activity changes or an earlier task disappears", async () => {
const first = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: sourceId, sortField: "id", sortDir: "asc", limit: 2 });
expect(first.status).toBe(200);
const sortedIds = [...expected].sort();
expect(first.body.map((row: { id: string }) => row.id)).toEqual(sortedIds.slice(0, 2));
await db.update(issues).set({ updatedAt: new Date("2099-01-01"), priority: "critical" }).where(eq(issues.id, sortedIds.at(-1)!));
await db.update(issues).set({ hiddenAt: new Date() }).where(eq(issues.id, sortedIds[0]!));
try {
const remaining = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: sourceId, sortField: "id", sortDir: "asc", afterId: first.body.at(-1).id, limit: 500 });
expect(remaining.status).toBe(200);
expect(remaining.body.map((row: { id: string }) => row.id)).toEqual(sortedIds.slice(2));
} finally {
await db.update(issues).set({ hiddenAt: null }).where(eq(issues.id, sortedIds[0]!));
}
});
it("rejects cursors without matching ID order", async () => {
for (const query of [{ afterId: sourceId }, { afterId: "bad", sortField: "id", sortDir: "asc" }, { afterId: sourceId, sortField: "id", sortDir: "desc" }, { afterId: sourceId, sortField: "id", sortDir: "asc", offset: 2 }]) {
const result = await request(app()).get(`/api/companies/${companyId}/issues`).query(query);
expect(result.status).toBe(422);
}
});
it("does not include manual children in creation provenance", async () => {
const children = await issueService(db).list(companyId, { parentId: sourceId });
expect(children.map((row) => row.title)).toContain("Manual child");
expect(children.map((row) => row.title)).toContain("Native child");
});
it("rejects malformed filters and never matches a source from another company", async () => {
const invalid = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: "not-an-id" });
expect(invalid.status).toBe(422);
const foreign = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: foreignSourceId });
expect(foreign.status).toBe(200);
expect(foreign.body).toEqual([]);
});
it("saves legacy child-helper creation provenance from the actor run", async () => {
const created = await issueService(db).createChild(otherSourceId, {
title: "Created while working on source, under another parent",
actorRunId: legacyRunId,
createdByAgentId: agentId,
});
expect(created.issue.originRunId).toBe(legacyRunId);
const matches = await issueService(db).list(companyId, { createdFromIssueId: sourceId });
expect(matches.map((row) => row.id)).toContain(created.issue.id);
});
});