From 32745f4e84b95957f26bfe84d1f58656529bdb5b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 10 Oct 2026 00:40:08 -0700 Subject: [PATCH] feat(issues): keep follow-up parents and show the creation source (#15762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 by " 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 by " 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 by ". 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 --- cli/src/commands/client/issue.ts | 10 +- doc/CLI.md | 4 +- doc/SPEC-implementation.md | 2 + doc/observability.md | 5 +- packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/issue.ts | 18 ++ .../issue-creation-source-routes.test.ts | 217 ++++++++++++++++++ server/src/routes/issues.ts | 156 ++++++++++++- server/src/services/issue-creation-source.ts | 111 +++++++++ server/src/services/issue-read-timing.ts | 2 +- skills/paperclip/SKILL.md | 4 +- .../task-detail/IssueCreatedFromNote.tsx | 45 ++++ .../task-detail/TaskDetailTasksPanel.test.tsx | 26 +++ .../task-detail/TaskDetailTasksPanel.tsx | 12 +- ui/src/pages/IssueDetail.test.tsx | 12 +- ui/src/pages/IssueDetail.tsx | 22 +- 17 files changed, 628 insertions(+), 20 deletions(-) create mode 100644 server/src/__tests__/issue-creation-source-routes.test.ts create mode 100644 server/src/services/issue-creation-source.ts create mode 100644 ui/src/components/task-detail/IssueCreatedFromNote.tsx diff --git a/cli/src/commands/client/issue.ts b/cli/src/commands/client/issue.ts index 2e4cdf6ddc0..e2c067a1593 100644 --- a/cli/src/commands/client/issue.ts +++ b/cli/src/commands/client/issue.ts @@ -59,6 +59,7 @@ interface IssueCreateOptions extends BaseClientOptions { projectId?: string; goalId?: string; parentId?: string; + standalone?: boolean; requestDepth?: string; billingCode?: string; } @@ -286,10 +287,17 @@ export function registerIssueCommands(program: Command): void { .option("--project-id ", "Project ID") .option("--goal-id ", "Goal ID") .option("--parent-id ", "Parent issue ID") + .option( + "--standalone", + "Create a top-level issue. Without --parent-id, an agent run's issue otherwise becomes the parent.", + ) .option("--request-depth ", "Request depth integer") .option("--billing-code ", "Billing code") .action(async (opts: IssueCreateOptions) => { try { + if (opts.standalone && opts.parentId) { + throw new Error("--standalone cannot be combined with --parent-id"); + } const ctx = resolveCommandContext(opts, { requireCompany: true }); const payload = createIssueSchema.parse({ title: opts.title, @@ -299,7 +307,7 @@ export function registerIssueCommands(program: Command): void { assigneeAgentId: opts.assigneeAgentId, projectId: opts.projectId, goalId: opts.goalId, - parentId: opts.parentId, + parentId: opts.standalone ? null : opts.parentId, requestDepth: parseOptionalInt(opts.requestDepth), billingCode: opts.billingCode, }); diff --git a/doc/CLI.md b/doc/CLI.md index c1a6da5659b..7efb1eb5801 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -424,7 +424,7 @@ Notes: ```sh npx paperclipai issue list --company-id [--status todo,in_progress] [--assignee-agent-id ] [--match text] npx paperclipai issue get -npx paperclipai issue create --company-id --title "..." [--description "..."] [--status todo] [--priority high] +npx paperclipai issue create --company-id --title "..." [--description "..."] [--status todo] [--priority high] [--parent-id | --standalone] npx paperclipai issue update [--status in_progress] [--comment "..."] npx paperclipai issue delete --yes npx paperclipai issue comment --body "..." [--attachment-id ] [--reopen] @@ -440,6 +440,8 @@ npx paperclipai issue release npx paperclipai issue force-release ``` +`issue create` sent from an agent run bound to an ordinary execution task is parented under that task when `--parent-id` is omitted; pass `--standalone` to create intentionally top-level work (`parentId: null`). The two flags are mutually exclusive. + Issue subresources are exposed as Paperclip API wrappers. Commands that map to broad server schemas accept JSON payloads and validate them with shared schemas before sending. ```sh diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index d5c9334627e..ce2344ddeb4 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -277,6 +277,8 @@ retain their titles and default to no generation request. - identifier fields: `issue_number`, `identifier` - origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint` - Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=` selects tasks created by runs bound to that source task, using native run issue identity or persisted legacy task context. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped. +- Delegated follow-ups keep their structural parent. When an agent run bound to an ordinary execution task creates an issue and omits `parentId`, the server defaults `parentId` to that run's task and records `parentDefaultedFromRunIssue: true` on the `issue.created` activity. An explicit `parentId: null` keeps the task standalone. The default never applies to conversation runs (chat handoffs stay top-level), to watchdog follow-ups, to board/user creates, to a run task the agent may not mutate, when the default parent would create a delegation cycle with the requested assignee, or when the agent may not create a child under the default parent (for example a protected assignment policy without a grant); those requests stay standalone instead of failing. An explicitly requested parent is still subject to the ordinary assignment check. Structural parentage, creation provenance and blockers stay distinct: the default adds no blocker. +- `GET /api/issues/:id` returns `createdFrom` (`{ issue: { id, identifier, title, status }, run: { id, agentId }, agent: { id, name } | null } | null`): the task the origin run was executing when it created this issue, resolved from `origin_run_id` or, for historical rows, the run on the recorded creation activity. It is present whether or not `parentId` is set, and only when the viewer may read the source task and its run; a private, hidden-from-viewer or cross-company source yields `null`, indistinguishable from missing provenance. - Relation lists can use `sortField=id&sortDir=asc&afterId=` for stable pagination. The cursor excludes earlier IDs and cannot be combined with an offset or activity-based order. - The streamlined task page's Tasks tab keeps two independent memberships: the existing subtask tree, and created tasks grouped by their current project (or No project). A created subtask appears in both. Only Subtasks has completion progress; groups collapse independently and unfinished tasks sort above finished tasks. - `request_depth` int not null default 0 diff --git a/doc/observability.md b/doc/observability.md index 56c550161bc..021c26ec87c 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -48,8 +48,9 @@ There are no custom attributes, identifiers, content, or exception messages. The closed phase names in `server/src/services/issue-read-timing.ts` are: `lookup`, `authorization`, `project_goal`, `ancestors`, `mentions`, `documents`, `relations`, `blockers`, `review`, `references`, `handoff`, `retry`, `recovery`, -`cases`, `inbox`, `channel`, `workspace`, `work_products`, `execution_blocker`, -`relation_recovery`, `revalidate_recovery`, and `mentioned_projects`. +`cases`, `inbox`, `channel`, `workspace`, `work_products`, `created_from`, +`execution_blocker`, `relation_recovery`, `revalidate_recovery`, and +`mentioned_projects`. Without an OTLP endpoint, spans remain no-ops. This adds no first-party Telemetry events or run-log events. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 110381b71a3..9dba828880f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1245,6 +1245,7 @@ export type { DocumentFormat, LegacyPlanDocument, IssueAttachment, + IssueCreationSource, IssueLabel, IssueTreeControlPreview, IssueTreeHold, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index ec590e8e140..be561795fda 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -804,6 +804,7 @@ export type { IssueAncestorProject, IssueAncestorGoal, IssueAttachment, + IssueCreationSource, IssueLabel, IssueWatchdog, IssueWatchdogStatus, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 31a9468a3f5..78d60aafbbd 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -128,6 +128,22 @@ export interface IssueLabel { updatedAt: Date; } +/** + * The task a run was executing when it created this issue, from recorded run + * provenance. Independent of the structural parent and of blockers. Only + * present when the viewer may read the source task and its run. + */ +export interface IssueCreationSource { + issue: { + id: string; + identifier: string | null; + title: string; + status: string; + }; + run: { id: string; agentId: string }; + agent: { id: string; name: string } | null; +} + export interface IssueAssigneeAdapterOverrides { adapterConfig?: Record; useProjectWorkspace?: boolean; @@ -839,6 +855,8 @@ export interface Issue { /** Immediate task from which private access flows downward, including chat handoffs. */ privacyParentIssueId?: string | null; ancestors?: IssueAncestor[]; + /** Detail responses only: access-checked creation provenance, or null. */ + createdFrom?: IssueCreationSource | null; title: string; description: string | null; descriptionTruncated?: boolean; diff --git a/server/src/__tests__/issue-creation-source-routes.test.ts b/server/src/__tests__/issue-creation-source-routes.test.ts new file mode 100644 index 00000000000..1d259b374e1 --- /dev/null +++ b/server/src/__tests__/issue-creation-source-routes.test.ts @@ -0,0 +1,217 @@ +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; + let database: Awaited>; + 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" } }); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f738e91abac..149fef0a791 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3,6 +3,11 @@ import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from " import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssueMonitor, summarizeIssueMonitor } from "../services/issue-monitors.js"; import type { IssuePrivacyConstraints } from "@paperclipai/shared"; import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; +import { + loadCreationSourceAgent, + loadRunBoundIssue, + resolveIssueCreationSource, +} from "../services/issue-creation-source.js"; import { activeIssueInteractionCondition, readTaskQuestionContext } from "../services/issue-question-context.js"; import { setIssueTitle } from "../services/issue-title.js"; import { setIssueTitleSchema } from "@paperclipai/shared"; @@ -113,6 +118,7 @@ import { isMarkdownArtifactWorkProduct, isMarkdownAttachmentContent, isUuidLike, + type IssueCreationSource, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, type CompactIssue, type CompanySearchExtractQuery, @@ -3755,6 +3761,124 @@ export function issueRoutes( ); } + /** + * Default structural parent for a delegated follow-up: the task the agent's + * run is executing. Returns null when the new task should stay standalone: + * no run-bound task, a conversation (chat handoffs are top-level by design), + * a task the agent may not mutate, a parent that would create a delegation + * cycle with the requested assignee, or a parent the agent may not create + * children under (for example a protected assignment policy without a + * grant). The default must never turn a previously allowed standalone + * create into a denial; an explicitly requested parent is still rejected by + * the ordinary assignment check. Callers only use this when the request + * omitted `parentId`; an explicit `parentId: null` keeps the task standalone. + */ + async function resolveRunDelegationParentDefault( + req: Request, + companyId: string, + rawCreateBody: Omit< + Parameters[0], + "companyId" | "parentId" + > & { assigneeUserId?: string | null }, + assigneeAgentId: string | null, + ): Promise { + if ( + req.actor.type !== "agent" || + !req.actor.agentId || + !req.actor.runId || + !isUuidLike(req.actor.runId) + ) + return null; + const run = await db + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, req.actor.runId), + eq(heartbeatRuns.companyId, companyId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!run || run.agentId !== req.actor.agentId) return null; + const source = await loadRunBoundIssue(db, run); + if (!source || isConversation(source)) return null; + const decision = await decideIssueAccess(req, source, "issue:mutate"); + if (!decision.allowed) return null; + if (assigneeAgentId && assigneeAgentId !== req.actor.agentId) { + const ancestor = await svc.findOpenAncestorCreatedByAgent( + source.id, + assigneeAgentId, + ); + if (ancestor) return null; + } + // Mirror the assignment check the create path runs with the defaulted + // parent in scope. A protected parent (or project it supplies) denies + // child creation without a grant; that must fall back to standalone work + // rather than fail a request that succeeded before the default existed. + const assignmentScope: TaskAssignmentAuthorizationScope = { + projectId: await resolveCreateAssignmentProjectId({ + ...rawCreateBody, + companyId, + parentId: source.id, + }), + parentIssueId: source.id, + assigneeAgentId, + assigneeUserId: + typeof rawCreateBody.assigneeUserId === "string" + ? rawCreateBody.assigneeUserId + : null, + }; + const assignment = await access.decide({ + actor: req.actor, + action: "tasks:assign", + resource: { + type: "issue", + companyId, + issueId: null, + projectId: assignmentScope.projectId ?? null, + parentIssueId: source.id, + assigneeAgentId, + assigneeUserId: assignmentScope.assigneeUserId ?? null, + }, + scope: assignmentScope, + }); + if (!assignment.allowed) return null; + return source.id; + } + + /** + * Creation provenance the viewer may see: the source task must be readable + * and the run must be visible to the actor. Returns null otherwise, so a + * private or foreign source is indistinguishable from no provenance. + */ + async function resolveVisibleIssueCreationSource( + req: Request, + issue: { id: string; companyId: string; originRunId: string | null }, + ): Promise { + const source = await resolveIssueCreationSource(db, issue); + if (!source) return null; + const { run, sourceIssue } = source; + const readable = await decideIssueAccess(req, sourceIssue, "issue:read"); + if (!readable.allowed) return null; + const runVisible = await canActorReadHeartbeatRun(db, access, req.actor, { + companyId: run.companyId, + scopeKind: run.scopeKind, + issueId: run.issueId ?? run.nativeIssueId ?? sourceIssue.id, + }); + if (!runVisible) return null; + const agent = await loadCreationSourceAgent(db, issue.companyId, run.agentId); + return { + issue: { + id: sourceIssue.id, + identifier: sourceIssue.identifier, + title: sourceIssue.title, + status: sourceIssue.status, + }, + run: { id: run.id, agentId: run.agentId }, + agent, + }; + } + async function resolveAgentTrustForIssue( input: { agentId: string | null | undefined; @@ -9146,6 +9270,7 @@ export function issueRoutes( externalChannelBinding, currentExecutionWorkspace, workProducts, + createdFrom, ] = await Promise.all([ timing.time("project_goal", () => resolveIssueProjectAndGoal(issue)), timing.time("ancestors", () => svc.getAncestors(issue.id)), @@ -9169,6 +9294,7 @@ export function issueRoutes( ? executionWorkspacesSvc.getById(issue.executionWorkspaceId) : Promise.resolve(null)), timing.time("work_products", () => workProductsSvc.listForIssue(issue.id)), + timing.time("created_from", () => resolveVisibleIssueCreationSource(req, issue)), ]); const [recoveryActionsByRelationIssue, revalidatedActiveRecoveryAction, mentionedProjects] = await Promise.all([ timing.time("relation_recovery", () => relationRecoveryActionMap(recoveryActionsSvc, issue.companyId, relations)), @@ -9218,6 +9344,7 @@ export function issueRoutes( workProducts, linkedCases, externalChannelBinding, + createdFrom, }); }); @@ -12061,9 +12188,27 @@ export function issueRoutes( watchdogDiscovery, ); if (watchdogProductBugFollowUp === false) return; + const normalizedAssigneeAgentId = + await normalizeIssueAssigneeAgentReference( + companyId, + rawCreateBody.assigneeAgentId as string | null | undefined, + { actorType: req.actor.type }, + ); + // A delegated follow-up from an ordinary execution task keeps that task as + // its structural parent when the agent omits `parentId`. An explicit + // `parentId: null` is an intentional standalone task and is honored. + const runDelegationParentId = + !watchdogProductBugFollowUp && rawCreateBody.parentId === undefined + ? await resolveRunDelegationParentDefault( + req, + companyId, + rawCreateBody, + normalizedAssigneeAgentId ?? null, + ) + : null; const effectiveParentId = watchdogProductBugFollowUp ? null - : rawCreateBody.parentId; + : (runDelegationParentId ?? rawCreateBody.parentId); let createParent: Awaited> | null = null; if (req.actor.type === "agent" && effectiveParentId) { createParent = await svc.getById(effectiveParentId); @@ -12087,12 +12232,6 @@ export function issueRoutes( )) ) return; - const normalizedAssigneeAgentId = - await normalizeIssueAssigneeAgentReference( - companyId, - rawCreateBody.assigneeAgentId as string | null | undefined, - { actorType: req.actor.type }, - ); await assertNoAgentDelegationCycle({ actorType: req.actor.type, actorAgentId: req.actor.agentId, @@ -12328,6 +12467,9 @@ export function issueRoutes( details: { title: issue.title, identifier: issue.identifier, + ...(runDelegationParentId && issue.parentId === runDelegationParentId + ? { parentId: issue.parentId, parentDefaultedFromRunIssue: true } + : {}), ...(watchdogProductBugFollowUp ? { watchdogDiscovery: { diff --git a/server/src/services/issue-creation-source.ts b/server/src/services/issue-creation-source.ts new file mode 100644 index 00000000000..42f5ce816a7 --- /dev/null +++ b/server/src/services/issue-creation-source.ts @@ -0,0 +1,111 @@ +import { and, asc, eq, inArray, isNotNull } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db"; +import { isUuidLike } from "@paperclipai/shared"; + +type IssueRow = typeof issues.$inferSelect; +type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect; + +/** The task a run was executing when it created another task. */ +export interface IssueCreationSourceRecord { + run: HeartbeatRunRow; + sourceIssue: IssueRow; +} + +const CREATION_ACTIVITY_ACTIONS = ["issue.created", "issue.child_created"]; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +/** + * The issue a heartbeat run is bound to: native run identity first, then the + * run's issue scope, then the persisted legacy task context. Returns a UUID or + * an issue identifier such as `PAP-168`; the caller resolves identifiers. + */ +export function runBoundIssueReference(run: Pick): string | null { + const context = run.contextSnapshot && typeof run.contextSnapshot === "object" + ? (run.contextSnapshot as Record) + : null; + const paperclipIssue = context?.paperclipIssue && typeof context.paperclipIssue === "object" + ? (context.paperclipIssue as Record) + : null; + return ( + run.nativeIssueId ?? + run.issueId ?? + readNonEmptyString(context?.issueId) ?? + readNonEmptyString(context?.taskId) ?? + readNonEmptyString(paperclipIssue?.id) ?? + readNonEmptyString(context?.taskKey) + ); +} + +/** Loads the run's bound issue within the run's company, or null. */ +export async function loadRunBoundIssue( + db: Db, + run: Pick, +): Promise { + const reference = runBoundIssueReference(run); + if (!reference) return null; + const condition = isUuidLike(reference) + ? eq(issues.id, reference) + : eq(issues.identifier, reference); + const [issue] = await db + .select() + .from(issues) + .where(and(condition, eq(issues.companyId, run.companyId))) + .limit(1); + return issue ?? null; +} + +/** + * Resolves the task from which an issue was created, using recorded run + * provenance: the issue's origin run, or for historical rows without one, the + * run on its creation activity. Company-scoped; a run or source task from + * another company never resolves. Does not check viewer access. + */ +export async function resolveIssueCreationSource( + db: Db, + issue: Pick, +): Promise { + let runId = issue.originRunId; + if (!runId) { + const [creation] = await db + .select({ runId: activityLog.runId }) + .from(activityLog) + .where(and( + eq(activityLog.companyId, issue.companyId), + eq(activityLog.entityType, "issue"), + eq(activityLog.entityId, issue.id), + inArray(activityLog.action, CREATION_ACTIVITY_ACTIONS), + isNotNull(activityLog.runId), + )) + .orderBy(asc(activityLog.createdAt)) + .limit(1); + runId = creation?.runId ?? null; + } + if (!runId || !isUuidLike(runId)) return null; + const [run] = await db + .select() + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId))) + .limit(1); + if (!run) return null; + const sourceIssue = await loadRunBoundIssue(db, run); + if (!sourceIssue || sourceIssue.id === issue.id) return null; + return { run, sourceIssue }; +} + +/** Company-scoped agent summary for a creation source. */ +export async function loadCreationSourceAgent( + db: Db, + companyId: string, + agentId: string, +): Promise<{ id: string; name: string } | null> { + const [agent] = await db + .select({ id: agents.id, name: agents.name }) + .from(agents) + .where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))) + .limit(1); + return agent ?? null; +} diff --git a/server/src/services/issue-read-timing.ts b/server/src/services/issue-read-timing.ts index 332bf3d97fc..a3244e6f653 100644 --- a/server/src/services/issue-read-timing.ts +++ b/server/src/services/issue-read-timing.ts @@ -5,7 +5,7 @@ import { SpanStatusCode, trace } from "@opentelemetry/api"; type IssueReadPhase = "lookup" | "authorization" | "project_goal" | "ancestors" | "mentions" | "documents" | "relations" | "blockers" | "review" | "references" | "handoff" | "retry" | "recovery" | "cases" | "inbox" - | "channel" | "workspace" | "work_products" | "execution_blocker" + | "channel" | "workspace" | "work_products" | "created_from" | "execution_blocker" | "relation_recovery" | "revalidate_recovery" | "mentioned_projects"; export function createIssueReadTiming() { diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index f2ec350c7da..c3b6993bfd0 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -219,7 +219,7 @@ Before ending any heartbeat, apply this final-disposition checklist: - `done`: the requested work is complete, verification is recorded, and no follow-up remains on this issue. - `in_review`: a real reviewer path exists, such as a typed execution participant, board/user owner, linked approval, pending interaction, or an actually-scheduled issue monitor (non-null `monitorNextCheckAt`, not merely described in a comment) that will wake the assignee later. Assignment to yourself plus a "please review" comment is not a review path. - `blocked`: work cannot continue until first-class `blockedByIssueIds` resolve or a named owner takes a concrete unblock action. -- Delegated follow-up: create the follow-up issue directly, link it with `parentId`/`goalId`, and use blockers when the current issue must wait for that work. +- Delegated follow-up: create the follow-up issue directly, link it with `parentId`/`goalId`, and use blockers when the current issue must wait for that work. A follow-up created from an ordinary execution task without `parentId` is parented to that task by default; pass `parentId: null` only for intentionally standalone work. - Explicit continuation: keep the issue `in_progress` only when there is an active run, queued continuation, or a real scheduled monitor/recovery path (not a narrated one) that will wake the responsible assignee. Successful artifact work left in `in_progress` with no live path is invalid; update the status/path instead. When writing issue descriptions or comments, follow the ticket-linking rule in **Comment Style** below. @@ -264,7 +264,7 @@ Because of that, follow these rules: - **Never imply a live watcher on a task you are marking `done`.** `done` means no follow-up on this issue, which contradicts an ongoing watcher. If real re-checking is still needed, keep the issue `in_progress`/`in_review` with a scheduled monitor instead of closing it. - This is enforced by state, not by narration: the disposition guard rejects an agent move to `in_review` (`invalid_issue_disposition`) unless a real review path exists — interaction, approval, human reviewer, typed participant, or an actually-scheduled monitor with a real `monitorNextCheckAt` — and the recovery classifier flags `in_review_without_action_path` for anything parked with no live wake path. Keep your comments consistent with that real state. -**Step 9 — Delegate if needed.** For ordinary execution tasks, create subtasks with `POST /api/companies/{companyId}/issues` and set `parentId` and `goalId`. For conversation tasks, use the project handoff above instead. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. +**Step 9 — Delegate if needed.** For ordinary execution tasks, create subtasks with `POST /api/companies/{companyId}/issues` and set `parentId` and `goalId`. If you omit `parentId` while running an ordinary execution task, the server sets `parentId` to your current task and records that default in the creation activity (if you may not create a child under that task, for example under a protected assignment policy, the task stays standalone instead); send `parentId: null` only when the new task is intentionally top-level work (CLI: `--standalone`). Either way the follow-up keeps run provenance, and the task page shows "Created from by " without implying a blocker. For conversation tasks, use the project handoff above instead. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. ### Delegating review tasks diff --git a/ui/src/components/task-detail/IssueCreatedFromNote.tsx b/ui/src/components/task-detail/IssueCreatedFromNote.tsx new file mode 100644 index 00000000000..839cdb9b2eb --- /dev/null +++ b/ui/src/components/task-detail/IssueCreatedFromNote.tsx @@ -0,0 +1,45 @@ +import type { IssueCreationSource } from "@paperclipai/shared"; +import { GitBranch } from "lucide-react"; +import { + createIssueDetailPath, + rememberIssueDetailLocationState, +} from "@/lib/issueDetailBreadcrumb"; +import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; + +export interface IssueCreatedFromNoteProps { + /** Access-checked creation provenance from the issue detail response. */ + createdFrom: IssueCreationSource | null | undefined; + issueLinkState?: unknown; + className?: string; +} + +/** + * "Created from PAP-168 by Paperclip QA": the task a run was executing when it + * created this one. Provenance only; it is not the structural parent and it is + * not a blocker. Renders nothing when the viewer may not see the source. + */ +export function IssueCreatedFromNote({ createdFrom, issueLinkState, className }: IssueCreatedFromNoteProps) { + if (!createdFrom) return null; + const { issue, agent } = createdFrom; + const ref = issue.identifier ?? issue.id; + return ( +

+ + Created from + rememberIssueDetailLocationState(ref, issueLinkState)} + className="min-w-0 truncate hover:text-foreground transition-colors" + title={issue.title} + > + {issue.identifier ? `${issue.identifier} ${issue.title}` : issue.title} + + {agent && by {agent.name}} +

+ ); +} diff --git a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx index 8da9642cf0c..866efd786db 100644 --- a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx +++ b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx @@ -98,4 +98,30 @@ describe("TaskDetailTasksPanel", () => { expect(retry).toHaveBeenCalledOnce(); expect(container.querySelector('[data-task-id="child"]')).not.toBeNull(); }); + + it("shows the access-checked creation source even when the task has no parent", () => { + render({ + createdFrom: { + issue: { id: "source", identifier: "PAP-168", title: "Verify Tailscale fix", status: "in_progress" }, + run: { id: "run-1", agentId: "qa" }, + agent: { id: "qa", name: "Paperclip QA" }, + }, + subtasks: [], createdTasks: [], projects: [], + }); + const group = container.querySelector('section[aria-label="Created from"]')!; + expect(group).not.toBeNull(); + const note = group.querySelector('[data-testid="issue-created-from"]')!; + expect(note.textContent).toContain("Created from"); + expect(note.textContent).toContain("PAP-168 Verify Tailscale fix"); + expect(note.textContent).toContain("by Paperclip QA"); + expect(note.querySelector('a')?.getAttribute("href")).toBe("/issues/PAP-168"); + expect(container.querySelector('section[aria-label="Ancestors"]')).toBeNull(); + expect(container.textContent).not.toContain("No tasks yet."); + }); + + it("omits the creation source when the viewer may not see it", () => { + render({ createdFrom: null, subtasks: [], createdTasks: [], projects: [] }); + expect(container.querySelector('section[aria-label="Created from"]')).toBeNull(); + expect(container.textContent).toContain("No tasks yet."); + }); }); diff --git a/ui/src/components/task-detail/TaskDetailTasksPanel.tsx b/ui/src/components/task-detail/TaskDetailTasksPanel.tsx index cb48ebe5193..8f3f91738f4 100644 --- a/ui/src/components/task-detail/TaskDetailTasksPanel.tsx +++ b/ui/src/components/task-detail/TaskDetailTasksPanel.tsx @@ -7,6 +7,7 @@ import { issueStatusOrder } from "@/lib/issue-filters"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { RelationNavigationList, TaskDetailSubtasksPanel, TaskDetailTaskList } from "./TaskDetailRelationsPanel"; +import { IssueCreatedFromNote } from "./IssueCreatedFromNote"; function TaskGroup({ name, projectPath, children }: { name: string; projectPath?: string; children: ReactNode }) { return ( @@ -36,6 +37,8 @@ function TaskGroup({ name, projectPath, children }: { name: string; projectPath? export interface TaskDetailTasksPanelProps { /** API order: immediate parent first. Displayed root first. */ ancestors?: NonNullable; + /** Access-checked creation provenance; shown even when the source is not the parent. */ + createdFrom?: Issue["createdFrom"]; subtasks: Issue[]; createdTasks: Issue[]; projects: Project[]; @@ -45,7 +48,7 @@ export interface TaskDetailTasksPanelProps { issueLinkState?: unknown; } -export function TaskDetailTasksPanel({ ancestors = [], subtasks, createdTasks, projects, isLoading, hasError, onRetry, issueLinkState }: TaskDetailTasksPanelProps) { +export function TaskDetailTasksPanel({ ancestors = [], createdFrom = null, subtasks, createdTasks, projects, isLoading, hasError, onRetry, issueLinkState }: TaskDetailTasksPanelProps) { const sortedSubtasks = sortTasks(subtasks); const groups = new Map(); for (const item of sortTasks(createdTasks)) { @@ -63,6 +66,11 @@ export function TaskDetailTasksPanel({ ancestors = [], subtasks, createdTasks, p } return (
+ {createdFrom && ( + + + + )} {ancestors.length > 0 && ( Retry} )} - {!isLoading && !hasError && ancestors.length === 0 && subtasks.length === 0 && createdTasks.length === 0 && ( + {!isLoading && !hasError && !createdFrom && ancestors.length === 0 && subtasks.length === 0 && createdTasks.length === 0 && (

No tasks yet.

)}
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 69d2043dc49..7249989b805 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -2471,7 +2471,12 @@ describe("IssueDetail", () => { it("loads ancestors, subtask membership and created work independently and refreshes on issue activity", async () => { const ancestors = [{ id: "parent-task", identifier: "PAP-0", title: "Parent task", status: "in_progress" }] as Issue["ancestors"]; - const source = createIssue({ ancestors }); + const createdFrom: Issue["createdFrom"] = { + issue: { id: "origin-task", identifier: "PAP-168", title: "Origin task", status: "in_progress" }, + run: { id: "run-1", agentId: "qa-agent" }, + agent: { id: "qa-agent", name: "Paperclip QA" }, + }; + const source = createIssue({ ancestors, createdFrom }); const child = createIssue({ id: "manual-child", parentId: source.id, title: "Manual child" }); const created = createIssue({ id: "created-task", parentId: null, title: "Created elsewhere" }); mockIssuesApi.get.mockResolvedValue(source); @@ -2485,7 +2490,8 @@ describe("IssueDetail", () => { expect(taskProjection()?.content.props.subtasks.map((row: Issue) => row.id)).toEqual([child.id]); expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toEqual([created.id]); expect(taskProjection()?.content.props.ancestors).toEqual(ancestors); - expect(taskProjection()?.count).toBe(3); + expect(taskProjection()?.content.props.createdFrom).toEqual(createdFrom); + expect(taskProjection()?.count).toBe(4); const next = createIssue({ id: "new-created-task", parentId: source.id }); mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) => @@ -2493,7 +2499,7 @@ describe("IssueDetail", () => { ); await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(source.companyId) }); }); await flushReact(); - expect(taskProjection()?.count).toBe(4); + expect(taskProjection()?.count).toBe(5); expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toContain(next.id); }); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 7b5dbcd5413..a7b7ff66094 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -16,6 +16,7 @@ import { agentDetailHref } from "./agent-detail-navigation"; import { ExecutionBlockerNotice } from "../components/ExecutionBlockerNotice"; import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover"; import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel"; +import { IssueCreatedFromNote } from "@/components/task-detail/IssueCreatedFromNote"; import { EmailThreadProvider } from "../components/EmailMessageCard"; import { EmailTaskActivity } from "../components/EmailTaskActivity"; import { TaskChatScrollNavigation, taskChatScrollEntry } from "@/components/task-chat/scroll-navigation"; @@ -3654,11 +3655,17 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS const createdTasks = createdTasksQuery.data ?? EMPTY_ISSUES; const hasError = createdTasksQuery.isError || childIssuesError; return { - count: new Set([...(issue?.ancestors ?? []), ...childIssues, ...createdTasks].map((task) => task.id)).size, + count: new Set([ + ...(issue?.createdFrom ? [issue.createdFrom.issue] : []), + ...(issue?.ancestors ?? []), + ...childIssues, + ...createdTasks, + ].map((task) => task.id)).size, hasError, content: ( ) : null; + // Creation provenance ("Created from PAP-168 by Paperclip QA"). Distinct from + // the parent chain above; the streamlined Tasks tab renders it instead. + const createdFromNote = + !streamlinedTaskDetailEnabled && issue.createdFrom ? ( + + ) : null; const issueStatusControl = ( {ancestorsNav} + {createdFromNote} {issueHeaderBlock} {pluginOutletsBlock} @@ -7620,6 +7639,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS > {/* Parent chain breadcrumb (redesign: rendered inside the thread viewport) */} {taskChatShellEnabled ? null : ancestorsNav} + {taskChatShellEnabled ? null : createdFromNote}