feat: let agents name prompt-only tasks (#14761)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Users create tasks with a title and a description.
> - A required title adds work when the prompt already explains the
request.
> - An agent can name the task once it reads that request.
> - This pull request accepts prompt-only tasks and starts them with a
short prompt slice.
> - A scoped title tool lets the assigned agent replace that slice early
without changing execution state.
> - A live browser eval checks the real agent call, saved title, audit
entry, and preservation of user titles.

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting: task creation, shared contracts, database, server, runner
tools, and board UI.

**Problem or motivation**

Users must currently write a title before they can submit a detailed
task prompt. The agent has enough context to write a useful title
itself.

**Proposed solution**

Make the title optional when a description is present. Save the first
120 characters of the normalized prompt as a provisional title. Ask the
assigned agent to call `set_task_title` early. Use an atomic
provisional-title guard to preserve titles supplied or edited by users.
Keep explicit titles supported.

Related: #14543 and #14556 concern empty-title submission. This change
intentionally enables that submission when a prompt is present, instead
of requiring a title.

## What Changed

- Add the `titleNeedsGeneration` field with an idempotent migration.
Keep existing titles unchanged.
- Add `PUT /api/issues/:id/title` and the native and legacy
`set_task_title` tool. Enforce company access, active-run ownership,
shared, bounded retry receipts across native/HTTP calls, and
transactional audit logging. Refresh external-object links after commit,
with the same feature gate and plugin detectors as ordinary title edits.
- Add early naming guidance in Standard, Ask, and Plan task context.
Preserve the description, status, and assignment.
- Allow prompt-only root and child task creation, plus draft restoration
in the New Task dialog. Keep user titles supported.
- Add an opt-in Product E2E suite for prompt-only Standard and Ask
tasks, plus an explicit-title control. It checks actual provider calls
within the first five tools, persisted state, audit attribution, and the
reloaded UI.
- Preserve a closed vocabulary of API key maintenance phrases in
declared prose while rejecting opaque credential suffixes. Add one
bounded naming retry after wording is rejected, without treating the
rejected call as a saved title.
- Repair the native cleanup receipt check exposed during full
verification: accept matching input digests, retain legacy input checks,
and reject conflicting receipts.

## Verification

- Live Product E2E on `f43478473800e3a46b85c5ee79677efdb15108e7`: **3/3
passed** with native Codex `gpt-5.4-mini`, first attempts only,
automatic retries disabled. Standard and Ask each saved “Rotate expired
API key” on their first tool call, with matching persisted state and a
single same-run audit entry. The explicit-title control retained its
user title with zero title writes. All three verified the reloaded
browser UI.
- Campaign: `local-2026-09-30T21-30-11-021Z`. Earlier failed campaigns
are retained separately; they exposed credential-prose handling and
prompted the naming recovery fix. No failed result was regraded or
deleted.
- Reproduce with `pnpm test:e2e:runner -- --id
task-titles.runner-codex-mini.local.prompt-title-standard --id
task-titles.runner-codex-mini.local.prompt-title-ask --id
task-titles.runner-codex-mini.local.preserve-explicit-title
--max-automatic-retries 0` and an authorized provider key.
- Full `pnpm -r typecheck` and `pnpm build` passed on the latest commit.
The runner build used the configured external eval source tree.
- Product E2E unit suite: **61 files, 818 tests passed**; E2E typecheck
and UI token gates passed.
- Title API/native regressions cover prompt-only and explicit child
creation, user edits, ownership/company isolation, external reference
refresh, cross-surface retry replay, and the 64-key limit without
receipt eviction. All passed. Prompt-context coverage: **44 tests
passed**.
- Rust credential regressions: **35 tests passed**, including benign
maintenance qualifiers and opaque credential rejection in every declared
prose field. Catalog/report reconciliation: **28 tests passed**. Native
recovery: **560 tests passed**.
- Broad local `pnpm test:run`: **14,555 tests passed** in the general
server group; two suites failed to initialize embedded PostgreSQL and
the existing 40,000-file Git streaming stress test exceeded its
300-second macOS timeout. All three suites then passed in isolation (**5
tests passed**) without code or timeout changes. The original full local
command exited nonzero and is not being represented as a clean full run.
- Latest-head GitHub checks are green: **53 passed, 4 skipped, zero
failed or pending**, including all test shards and the canary packaging
dry run. Greptile reviewed the same commit at **5/5**, with zero
unresolved review threads.

## Risks

- The additive database field must reach the server and UI together. The
migration uses `IF NOT EXISTS` and defaults existing tasks to a final
title.
- Title generation depends on the assigned agent running. Tasks without
a run keep their provisional title.
- Live qualification covers the native Codex path in Standard and Ask
modes. API/legacy and Plan behavior have deterministic coverage.
- The credential-prose exception validates the entire suffix against a
closed maintenance vocabulary. Unknown suffixes, assignments, quoted
values, credential prefixes, and diagnostics retain strict checks.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, tool use, and code
execution. The exact deployment ID and context window are not exposed in
this session. The live eval uses the native Codex `gpt-5.4-mini`
profile.

## 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>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-30 16:48:24 -05:00
1 parent d6d67b00d3
commit 018993140f
63 files changed
+2279 -226

No files matched your search

@@ -8,6 +8,19 @@ import {
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
describe("buildPaperclipTaskMarkdown", () => {
it("asks for early naming only while an ordinary task has a provisional title", () => {
const issue = { id: "task-id", identifier: "PAP-1", title: "Please investigate", description: "Please investigate sign-in failures", titleNeedsGeneration: true };
const markdown = buildPaperclipTaskMarkdown({ issue });
expect(markdown).toContain("As one of your first tool calls");
expect(markdown).toContain("Check the title tool result");
expect(markdown).toContain("retry once with a shorter, plain-language title");
expect(markdown).toContain("new idempotency key for changed arguments");
expect(markdown).toContain("set_task_title");
expect(markdown).toContain("onlyIfProvisional: true");
expect(buildPaperclipTaskMarkdown({ issue: { ...issue, titleNeedsGeneration: false } })).not.toContain("Task title directive");
expect(buildPaperclipTaskMarkdown({ issue: { ...issue, conversationAgentId: "agent" } })).not.toContain("Task title directive");
});
it("carries current confirmation IDs and proposal data in every fresh or resumed chat assignment", () => {
const conversationConfirmations = { truncated: false, cards: [{
id: "existing-card", kind: "request_confirmation", status: "pending", title: "Proposal",
@@ -0,0 +1,197 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { and, eq } from "drizzle-orm";
import { activityLog, externalObjectMentions, heartbeatRuns, issues } from "@paperclipai/db";
import { createChildIssueSchema, createIssueSchema, setIssueTitleSchema } from "@paperclipai/shared";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { issueService } from "../services/issues.js";
import { externalObjectService } from "../services/external-objects.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { callProjectTool, projectToolDefinitions } from "../services/project-tools.js";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
describe("task titles", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => {
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
server = await startRunnerApiTestServer();
}, 60_000);
afterAll(async () => {
await server?.close();
if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret;
});
type Fixture = Awaited<ReturnType<typeof server.fixture>>;
const token = (f: Fixture) => createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const request = (f: Fixture, path: string, method: string, body: unknown) => fetch(`${server.apiUrl}/api${path}`, {
method, headers: { Authorization: `Bearer ${token(f)}`, "Content-Type": "application/json" }, body: JSON.stringify(body),
});
const rename = (f: Fixture, input: Record<string, unknown> = {}) => f.authority.execute({
tool: "set_task_title", callId: randomUUID(),
arguments: { idempotencyKey: "initial-title", title: "Fix sign-in redirect", onlyIfProvisional: true, ...input },
});
async function provisional(f: Fixture) {
await server.db.update(issues).set({ title: "Please investigate", description: "Please investigate the sign-in redirect", titleNeedsGeneration: true }).where(eq(issues.id, f.issueId));
}
it("creates a provisional title from the prompt through the API and preserves the full description", async () => {
const f = await server.fixture();
const description = " Investigate\n the sign-in redirect. " + "Keep this detail. ".repeat(20);
const response = await request(f, `/companies/${f.companyId}/issues`, "POST", { description, status: "backlog" });
const result = await response.json();
expect(response.status, JSON.stringify(result)).toBe(201);
expect(result).toMatchObject({ title: description.trim().replace(/\s+/g, " ").slice(0, 120), description, titleNeedsGeneration: true });
expect(await issueService(server.db).create(f.companyId, { title: "Chosen title", description })).toMatchObject({ title: "Chosen title", titleNeedsGeneration: false });
expect(createIssueSchema.safeParse({ title: " ", description: "\n " }).success).toBe(false);
expect(createIssueSchema.safeParse({ description: "A request" }).success).toBe(true);
expect(setIssueTitleSchema.safeParse({ title: " " }).success).toBe(false);
expect(setIssueTitleSchema.safeParse({ title: "x".repeat(241) }).success).toBe(false);
});
it("creates prompt-only children and still accepts explicit child titles", async () => {
const f = await server.fixture();
const description = "Investigate the child sign-in redirect";
const response = await request(f, `/issues/${f.issueId}/children`, "POST", { description, status: "backlog" });
const result = await response.json();
expect(response.status, JSON.stringify(result)).toBe(201);
expect(result).toMatchObject({ parentId: f.issueId, title: description, description, titleNeedsGeneration: true });
const explicit = await request(f, `/issues/${f.issueId}/children`, "POST", { title: "Chosen child title", description, status: "backlog" });
expect(explicit.status).toBe(201);
expect(await explicit.json()).toMatchObject({ title: "Chosen child title", titleNeedsGeneration: false });
expect(createChildIssueSchema.safeParse({ title: " ", description: "\n " }).success).toBe(false);
});
it("bounds title receipts without evicting replay protection across native and HTTP calls", async () => {
const f = await server.fixture();
const input = { title: "Original agent title", onlyIfProvisional: false, idempotencyKey: "original" };
const original = await rename(f, input);
await issueService(server.db).update(f.issueId, { title: "Later user title" });
for (let i = 1; i < 64; i++) {
await rename(f, { idempotencyKey: `bounded-${i}` });
}
await expect(rename(f, { idempotencyKey: "overflow" })).rejects.toThrow(/limit/);
const overflow = await request(f, `/issues/${f.issueId}/title`, "PUT", { ...input, idempotencyKey: "overflow" });
expect(overflow.status).toBe(409);
expect(await rename(f, input)).toEqual(original);
const replay = await request(f, `/issues/${f.issueId}/title`, "PUT", input);
expect(replay.status).toBe(200);
expect(await replay.json()).toEqual(original);
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
expect(Object.keys(run.resultJson?.taskTitleReceipts ?? {})).toHaveLength(64);
expect(await issueService(server.db).getById(f.issueId)).toMatchObject({ title: "Later user title" });
});
it("keeps distinct prompts with the same prefix and replays creation keys after naming", async () => {
const f = await server.fixture();
const prefix = "Please investigate ".repeat(10);
const svc = issueService(server.db);
const first = await svc.create(f.companyId, { description: prefix + "the login flow", allowDuplicate: false, idempotencyKey: "prompt-request" });
const second = await svc.create(f.companyId, { description: prefix + "the signup flow", allowDuplicate: false });
expect(first.title).toBe(second.title);
expect(first.id).not.toBe(second.id);
await svc.update(first.id, { title: "Fix login" });
expect((await svc.create(f.companyId, { description: prefix + "the login flow", idempotencyKey: "prompt-request" })).id).toBe(first.id);
});
it.each(["standard", "ask", "planning"] as const)("names the active task once in %s mode without changing execution", async mode => {
const f = await server.fixture({ mode });
await provisional(f);
expect(f.authority.definitions().some(tool => tool.name === "set_task_title")).toBe(true);
expect(projectToolDefinitions(mode, true).some(tool => tool.name === "set_task_title")).toBe(true);
const [before] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
const first = await rename(f);
expect(first).toMatchObject({ id: f.issueId, title: "Fix sign-in redirect", titleNeedsGeneration: false, changed: true });
expect(await rename(f)).toEqual(first);
const [after] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
expect(after).toMatchObject({ status: before.status, statusVersion: before.statusVersion, workMode: mode, executionRunId: before.executionRunId, assigneeAgentId: before.assigneeAgentId, description: before.description });
const audit = await server.db.select().from(activityLog).where(and(eq(activityLog.entityId, f.issueId), eq(activityLog.action, "issue.updated")));
expect(audit).toHaveLength(1);
expect(audit[0]).toMatchObject({ runId: f.runId, actorId: f.agentId, details: { title: "Fix sign-in redirect", previous: { title: "Please investigate" } } });
await expect(rename(f, { title: "Conflicting retry" })).rejects.toThrow(/idempotency/);
});
it("preserves a user edit and supports intentional renaming through the shared MCP/API path", async () => {
const f = await server.fixture({ mode: "ask" });
await provisional(f);
await issueService(server.db).update(f.issueId, { title: "My chosen title" });
expect(await rename(f)).toMatchObject({ title: "My chosen title", changed: false });
const result = await callProjectTool({ name: "set_task_title", arguments: { title: "Intentional new title", onlyIfProvisional: false, idempotencyKey: "rename" }, apiUrl: server.apiUrl, token: token(f), companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: false });
expect(result).toMatchObject({ title: "Intentional new title", changed: true });
const retry = await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Intentional new title" });
expect(await retry.json()).toMatchObject({ changed: false });
await issueService(server.db).update(f.issueId, { title: "User edit after the response" });
const replay = await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Intentional new title", onlyIfProvisional: false, idempotencyKey: "rename" });
expect(await replay.json()).toEqual(result);
expect(await issueService(server.db).getById(f.issueId)).toMatchObject({ title: "User edit after the response" });
expect((await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Conflicting retry", idempotencyKey: "rename" })).status).toBe(409);
});
it("fences simultaneous initial titles and later retries", async () => {
const f = await server.fixture();
await provisional(f);
const results = await Promise.all([
rename(f, { idempotencyKey: "a", title: "First title" }),
rename(f, { idempotencyKey: "b", title: "Second title" }),
]) as Array<{ changed: boolean; title: string }>;
expect(results.filter(result => result.changed)).toHaveLength(1);
expect(new Set(results.map(result => result.title)).size).toBe(1);
});
it.each(["native", "http"] as const)("shares %s title receipts with the other surface without overwriting later user edits", async firstSurface => {
const f = await server.fixture();
const input = { title: "Intentional rename", onlyIfProvisional: false, idempotencyKey: "shared-title-retry" };
const httpRename = async () => {
const response = await request(f, `/issues/${f.issueId}/title`, "PUT", input);
expect(response.status).toBe(200);
return response.json();
};
const first = await (firstSurface === "native" ? rename(f, input) : httpRename());
await issueService(server.db).update(f.issueId, { title: "Keep this user edit" });
expect(await (firstSurface === "native" ? httpRename() : rename(f, input))).toEqual(first);
expect(await issueService(server.db).getById(f.issueId)).toMatchObject({ title: "Keep this user edit" });
expect((await request(f, `/issues/${f.issueId}/title`, "PUT", { ...input, title: "Conflicting retry" })).status).toBe(409);
await expect(rename(f, { ...input, title: "Conflicting retry" })).rejects.toThrow(/idempotency/);
});
it("refreshes external title links through native and REST renames while preserving description links", async () => {
const f = await server.fixture();
await instanceSettingsService(server.db).updateExperimental({ enableExternalObjects: true });
try {
await server.db.update(issues).set({
title: "Review https://github.com/acme/app/pull/42",
titleNeedsGeneration: true,
description: "Keep https://github.com/acme/app/issues/7",
}).where(eq(issues.id, f.issueId));
await externalObjectService(server.db).syncIssue(f.issueId);
const mentions = () => server.db.select().from(externalObjectMentions).where(eq(externalObjectMentions.sourceIssueId, f.issueId));
expect(await mentions()).toHaveLength(2);
await rename(f);
expect(await mentions()).toMatchObject([{ sourceKind: "description", sanitizedDisplayUrl: "https://github.com/acme/app/issues/7" }]);
const response = await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Review https://github.com/acme/app/pull/43" });
expect(response.status).toBe(200);
expect(await mentions()).toEqual(expect.arrayContaining([
expect.objectContaining({ sourceKind: "title", sanitizedDisplayUrl: "https://github.com/acme/app/pull/43" }),
expect.objectContaining({ sourceKind: "description", sanitizedDisplayUrl: "https://github.com/acme/app/issues/7" }),
]));
expect(await mentions()).toHaveLength(2);
} finally {
await instanceSettingsService(server.db).updateExperimental({ enableExternalObjects: false });
}
});
it("rejects unrelated tasks, stale runs, and malformed title mutations", async () => {
const f = await server.fixture();
await provisional(f);
const other = await issueService(server.db).create(f.companyId, { title: "Unrelated task" });
expect((await request(f, `/issues/${other.id}/title`, "PUT", { title: "Wrong" })).status).toBe(403);
const foreign = await issueService(server.db).create(f.foreignCompanyId, { title: "Foreign task" });
expect([403, 404]).toContain((await request(f, `/issues/${foreign.id}/title`, "PUT", { title: "Wrong" })).status);
expect((await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Wrong", status: "done" })).status).toBe(400);
await expect(rename(f, { title: " " })).rejects.toThrow();
await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
await expect(rename(f)).rejects.toThrow(/authorized/);
expect((await request(f, `/issues/${f.issueId}/title`, "PUT", { title: "Stale" })).status).toBe(403);
expect(await issueService(server.db).getById(f.issueId)).toMatchObject({ title: "Please investigate", titleNeedsGeneration: true });
});
});
+22
View File
@@ -1,3 +1,5 @@
import { setIssueTitle } from "../services/issue-title.js";
import { setIssueTitleSchema } from "@paperclipai/shared";
import { resolveConfirmationFromComment } from "../services/confirmation-comment-resolution.js";
import { createIssueReadTiming } from "../services/issue-read-timing.js";
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
@@ -8512,6 +8514,7 @@ export function issueRoutes(
id: issue.id,
identifier: issue.identifier,
title: issue.title,
titleNeedsGeneration: issue.titleNeedsGeneration,
description: issue.description,
status: issue.status,
workMode: issue.workMode,
@@ -12702,6 +12705,25 @@ export function issueRoutes(
},
);
router.put("/issues/:id/title", validateIssueMutationBody(setIssueTitleSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Issue not found");
if (!existing) return;
if (req.actor.type === "agent") {
const decision = await decideIssueAccess(req, existing, "issue:mutate");
if (!decision.allowed) {
await denyIssueWrite(req, res, existing, issueWriteDenialCodeForDecision(decision));
return;
}
}
const actor = getActorInfo(req);
const { result, publication } = await db.transaction(tx => setIssueTitle(
tx as unknown as Db, existing.companyId, existing.id, req.body, actor,
));
if (publication) publishActivity(publication);
await externalObjectsSvc.syncIssueSafely(existing.id);
res.json(result);
});
router.patch(
"/issues/:id",
validateIssueMutationBody(updateIssueRouteSchema),
+12
View File
@@ -49,6 +49,7 @@ import {
testAdapterEnvironmentSchema,
// Issue
createIssueSchema,
setIssueTitleSchema,
updateIssueSchema,
stalledReviewDecisionSchema,
createIssueLabelSchema,
@@ -3904,6 +3905,7 @@ registry.registerPath({
path: "/api/companies/{companyId}/issues",
tags: ["issues"],
summary: "Create an issue",
description: "Title is optional when description contains the task prompt. The server seeds a provisional title from the first 120 characters of the whitespace-normalized prompt and asks the assigned agent to refine it early. Explicit titles are preserved.",
request: {
params: z.object({ companyId: z.string() }),
body: jsonBody(createIssueSchema),
@@ -3925,6 +3927,16 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
registry.registerPath({
method: "put",
path: "/api/issues/{id}/title",
tags: ["issues"],
summary: "Set a task title",
description: "Changes only the title. onlyIfProvisional preserves an explicit title, including concurrent user edits. Agent callers must own the task's active run. Allowed in Ask and Plan modes.",
request: { params: z.object({ id: z.string() }), body: jsonBody(setIssueTitleSchema) },
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
});
registry.registerPath({
method: "patch",
path: "/api/issues/{id}",
+16
View File
@@ -1,3 +1,4 @@
import { externalObjectService } from "./external-objects.js";
import { isAiAuthenticationBlocked } from "./ai-auth-failure.js";
import { CHAT_COMPLETION_WAKE_REASON, prepareChatCompletionTurn, chatCompletionInstruction, isCompletedOnboardingHandoffWake } from "./chat-completion-delivery.js";
import { AgentDirectoryReuseInvalidatedError, isAgentDirectoryCopy } from "./agent-directory-working-copies.js";
@@ -8554,6 +8555,7 @@ export function buildPaperclipTaskMarkdown(input: {
id: string;
identifier: string | null;
title: string;
titleNeedsGeneration?: boolean;
workMode?: string | null;
conversationAgentId?: string | null;
description?: string | null;
@@ -8732,6 +8734,14 @@ export function buildPaperclipTaskMarkdown(input: {
`- Issue: ${quoteTaskScalar(issue.identifier || issue.id)}`,
`- Title: ${quoteTaskScalar(issue.title)}`,
);
if (issue.titleNeedsGeneration && !issue.conversationAgentId) {
lines.push(
"",
"Task title directive:",
"The current title is a provisional slice of the user's prompt. As one of your first tool calls, use set_task_title with a concise title describing the requested outcome and onlyIfProvisional: true. If that tool is unavailable, PUT /api/issues/" + issue.id + "/title with {title, onlyIfProvisional: true} using your normal Paperclip authentication. Do this in Ask and Plan modes too. Preserve the full task description and any title already chosen by the user; then continue the task.",
"Check the title tool result. If its wording is rejected as credential material, retry once with a shorter, plain-language title that keeps the task's meaning and contains no secret values. Use a new idempotency key for changed arguments. Do not treat a rejected call as a saved title.",
);
}
if (issue.conversationAgentId) {
lines.push("", "Chat mode directive:", AGENT_CHAT_DIRECTIVE, `Current composer mode: ${issue.workMode ?? "standard"}.`);
if (input.conversationConfirmations?.cards.length) {
@@ -10691,6 +10701,7 @@ export function heartbeatService(
id: issues.id,
identifier: issues.identifier,
title: issues.title,
titleNeedsGeneration: issues.titleNeedsGeneration,
description: issues.description,
status: issues.status,
workMode: issues.workMode,
@@ -20976,6 +20987,7 @@ export function heartbeatService(
id: issueRef.id,
identifier: issueRef.identifier,
title: issueRef.title,
titleNeedsGeneration: issueContext?.titleNeedsGeneration,
workMode: issueRef.workMode,
conversationAgentId: issueContext?.conversationAgentId,
description: issueRef.description,
@@ -24608,6 +24620,10 @@ export function heartbeatService(
}
},
enqueueWakeup,
syncIssueExternalObjects: externalObjectService(db, {
pluginWorkerManager: options.pluginWorkerManager,
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
}).syncIssueSafely,
onSpawn: async (meta) => {
markDispatchStarted();
await persistRunProcessMetadata(run.id, meta);
+80
View File
@@ -0,0 +1,80 @@
import { and, eq } from "drizzle-orm";
import { heartbeatRuns, issues, type Db } from "@paperclipai/db";
import { setIssueTitleSchema, type SetIssueTitle } from "@paperclipai/shared";
import { badRequest, conflict, forbidden, notFound } from "../errors.js";
import { persistActivity, type ActivityPublication, type LogActivityInput } from "./activity-log.js";
import { issueReferenceService } from "./issue-references.js";
type TitleActor = Pick<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId" | "agentApiKeyId">;
type TitleResult = { id: string; title: string; titleNeedsGeneration: boolean; changed: boolean };
type TitleReceipt = { issueId: string; title: string; onlyIfProvisional: boolean; result: TitleResult };
const MAX_TASK_TITLE_RECEIPTS_PER_RUN = 64;
/** Caller owns the transaction and publishes the activity only after commit. */
export async function setIssueTitle(
tx: Db,
companyId: string,
issueId: string,
input: SetIssueTitle,
actor: TitleActor,
): Promise<{ result: TitleResult; publication: ActivityPublication | null }> {
const { title, onlyIfProvisional, idempotencyKey } = setIssueTitleSchema.parse(input);
const predicate = and(eq(issues.id, issueId), eq(issues.companyId, companyId));
const [issue] = await tx.select().from(issues).where(predicate).for("update");
if (!issue) throw notFound("Task not found");
let authorizedRun: typeof heartbeatRuns.$inferSelect | undefined;
if (actor.actorType === "agent") {
if (issue.assigneeAgentId !== actor.actorId || !actor.runId) {
throw forbidden("Setting a task title requires its assigned agent's active run");
}
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, actor.runId), eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.agentId, actor.actorId),
)).for("update");
const snapshot = run?.contextSnapshot ?? {};
if (!run || run.status !== "running"
|| (run.nativeIssueId ?? snapshot.issueId ?? snapshot.taskId) !== issueId
|| (issue.executionRunId && issue.executionRunId !== run.id)
|| (issue.checkoutRunId && issue.checkoutRunId !== run.id)
|| (issue.conversationAgentId && Number(snapshot.conversationSessionGeneration ?? 0) !== issue.conversationSessionGeneration)) {
throw forbidden("The active run no longer owns this task");
}
authorizedRun = run;
}
if (idempotencyKey && !authorizedRun) throw badRequest("Title retry keys require an authenticated agent run");
const receipts = (authorizedRun?.resultJson?.taskTitleReceipts ?? {}) as Record<string, TitleReceipt>;
const receiptKey = idempotencyKey ? `title:${idempotencyKey}` : null;
const prior = receiptKey ? receipts[receiptKey] : undefined;
if (prior) {
if (prior.issueId !== issueId || prior.title !== title || prior.onlyIfProvisional !== onlyIfProvisional) {
throw conflict("Task title idempotency key was already used with different arguments");
}
return { result: prior.result, publication: null };
}
// Keep all accepted keys replayable, including after a user edit. Reject new
// keys at the limit instead of evicting receipts and allowing writes to replay.
if (receiptKey && Object.keys(receipts).length >= MAX_TASK_TITLE_RECEIPTS_PER_RUN) {
throw conflict("Task title retry-key limit reached for this run");
}
const saveReceipt = async (result: TitleResult) => {
if (receiptKey && authorizedRun) {
await tx.update(heartbeatRuns).set({
resultJson: { ...authorizedRun.resultJson, taskTitleReceipts: { ...receipts, [receiptKey]: { issueId, title, onlyIfProvisional, result } } },
updatedAt: new Date(),
}).where(eq(heartbeatRuns.id, authorizedRun.id));
}
return result;
};
if ((onlyIfProvisional && !issue.titleNeedsGeneration)
|| (issue.title === title && !issue.titleNeedsGeneration)) {
return { result: await saveReceipt({ id: issue.id, title: issue.title, titleNeedsGeneration: issue.titleNeedsGeneration, changed: false }), publication: null };
}
await tx.update(issues).set({ title, titleNeedsGeneration: false, updatedAt: new Date() }).where(predicate);
await issueReferenceService(tx).syncIssue(issueId, tx);
const { publication } = await persistActivity(tx, {
...actor, companyId, action: "issue.updated", entityType: "issue", entityId: issueId,
details: { title, previous: { title: issue.title }, titleNeedsGeneration: false },
});
return { result: await saveReceipt({ id: issueId, title, titleNeedsGeneration: false, changed: true }), publication };
}
+17 -4
View File
@@ -1929,7 +1929,8 @@ async function assertExecutionTaskParent(db: Db, companyId: string, parentId?: s
}
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId" | "title" | "titleNeedsGeneration"> & {
title?: string;
initialPlan?: string | null;
labelIds?: string[];
blockedByIssueIds?: string[];
@@ -4851,6 +4852,7 @@ const issueListSelect = {
goalId: issues.goalId,
parentId: issues.parentId,
title: issues.title,
titleNeedsGeneration: issues.titleNeedsGeneration,
description: sql<string | null>`
CASE
WHEN ${issues.description} IS NULL THEN NULL
@@ -9753,6 +9755,14 @@ export function issueService(db: Db) {
onDeduplicated,
...issueData
} = data;
const explicitTitle = issueData.title?.trim();
const provisionalTitle = issueData.description?.trim().replace(/\s+/g, " ").slice(0, 120);
const resolvedTitle = explicitTitle || provisionalTitle;
if (!resolvedTitle) throw unprocessable("Provide a title or task description");
const titleNeedsGeneration = !explicitTitle;
// A prompt prefix is not a task identity: distinct requests can share it.
const deduplicateByTitle = allowDuplicate === false && !titleNeedsGeneration;
issueData.title = resolvedTitle;
const isolatedWorkspacesEnabled = (
await instanceSettings.getExperimental()
).enableIsolatedWorkspaces;
@@ -9793,8 +9803,8 @@ export function issueService(db: Db) {
}
}
const idempotencyKey = rawIdempotencyKey?.trim() || null;
const normalizedTitle = normalizeCreateIssueTitle(issueData.title);
if (allowDuplicate === false) {
const normalizedTitle = normalizeCreateIssueTitle(resolvedTitle);
if (deduplicateByTitle) {
const titleGuardKey = `issue-create:title:${companyId}:${issueData.parentId ?? "root"}:${normalizedTitle}`;
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${titleGuardKey}, 0))`,
@@ -9843,7 +9853,7 @@ export function issueService(db: Db) {
.then((rows) => rows.map((row) => row.issues));
if (existingIssue) deduplicationReason = "idempotency_key";
}
if (!existingIssue && allowDuplicate === false) {
if (!existingIssue && deduplicateByTitle) {
[existingIssue] = await tx
.select()
.from(issues)
@@ -10125,6 +10135,7 @@ export function issueService(db: Db) {
const values = {
...issueData,
titleNeedsGeneration,
originRunId: issueData.originRunId ?? actorRunId ?? null,
responsibleUserId,
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
@@ -10622,6 +10633,8 @@ export function issueService(db: Db) {
companyGuard,
...issueData
} = data;
// An explicit edit claims the title, even if it keeps the same text.
if (issueData.title !== undefined) issueData.titleNeedsGeneration = false;
if (
issueData.assigneeAgentId !== undefined &&
issueData.assigneeAgentId !== existing.assigneeAgentId
@@ -2694,6 +2694,9 @@ describe("retained native cleanup activation", () => {
"distinct_provider_account",
"wrong_provider_account",
"wrong_result_digest",
"digest_only_command",
"wrong_command_digest",
"conflicting_command_digest",
"wrong_semantic_input",
"wrong_contract",
"wrong_turn",
@@ -2927,7 +2930,9 @@ describe("retained native cleanup activation", () => {
payload: {
callId: semantic.callId,
operationId: semantic.operationId,
input: semanticInput,
...(mode === "digest_only_command" || mode === "wrong_command_digest"
? { inputDigest: mode === "wrong_command_digest" ? "wrong" : nativeSha256(semanticInput) }
: { input: semanticInput, ...(mode === "conflicting_command_digest" ? { inputDigest: "wrong" } : {}) }),
correlation,
sourceEventId: rawInput.sourceEventId,
sourceEventType: rawInput.eventType,
@@ -3188,6 +3193,8 @@ describe("retained native cleanup activation", () => {
![
"foreign_event",
"wrong_result_digest",
"wrong_command_digest",
"conflicting_command_digest",
"wrong_semantic_input",
"wrong_contract",
"wrong_turn",
@@ -3793,6 +3800,8 @@ describe("retained native cleanup activation", () => {
"foreign_event",
"nonempty_root",
"wrong_result_digest",
"wrong_command_digest",
"conflicting_command_digest",
"wrong_semantic_input",
"wrong_contract",
"wrong_turn",
@@ -3802,6 +3811,7 @@ describe("retained native cleanup activation", () => {
"wrong_provider_account",
].includes(mode);
const succeeds = [
"digest_only_command",
"home_paginated",
"canonical_source",
"canonical_claim_commit_stalled",
@@ -2193,7 +2193,13 @@ export function retainedNativeCleanupJournalMatches(input: {
payload.isError === false &&
payload.sourceEventId === event.sourceEventId &&
payload.sourceEventType === event.eventType &&
canonicalJson(payload.input) === canonicalJson(semantic.input) &&
// Current receipts retain a digest; older journals retain the input.
// If both exist, both must bind to the accepted semantic input.
(payload.inputDigest === undefined
? canonicalJson(payload.input) === canonicalJson(semantic.input)
: payload.inputDigest === nativeSha256(semantic.input) &&
(payload.input === undefined ||
canonicalJson(payload.input) === canonicalJson(semantic.input))) &&
canonicalJson(payload.correlation) ===
canonicalJson(semantic.correlation) &&
record(record(command.result).result).callId === semantic.callId
@@ -7268,6 +7274,7 @@ export async function executePaperclipNativeSession(input: {
runnerRemoteCodexNpmSpec?: string | null;
runnerRemoteProviderPackPath?: string | null;
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise<void>;
syncIssueExternalObjects?: (issueId: string) => Promise<void>;
enqueueWakeup?: (
agentId: string,
options: {
@@ -10641,6 +10648,7 @@ export async function createRunnerdBackend(input: {
toolTrace?: NativeToolTrace;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise<void>;
syncIssueExternalObjects?: (issueId: string) => Promise<void>;
enqueueWakeup?: (
agentId: string,
options: {
@@ -10783,6 +10791,7 @@ async function createRunnerdBackendWithinSessionClaim(
currentWakeComments: currentWakeComments ?? undefined,
chatAttachmentReadScope: input.chatAttachmentReadScope,
stopTaskForReassignment: input.stopTaskForReassignment,
syncIssueExternalObjects: input.syncIssueExternalObjects,
enqueueWakeup: input.enqueueWakeup,
});
const authorityEpoch = new SessionToolAuthorityEpoch(
@@ -99,7 +99,7 @@ describe("PaperclipRunnerToolAuthority", () => {
issueId,
runId,
});
expect(authority.definitions()).toHaveLength(34);
expect(authority.definitions()).toHaveLength(35);
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
expect(questions.description).toContain("ask only the next unanswered question");
expect(questions.description).toContain("Never fabricate answers");
@@ -1,3 +1,7 @@
import { setIssueTitle } from "../issue-title.js";
import { externalObjectService } from "../external-objects.js";
import { instanceSettingsService } from "../instance-settings.js";
import { setIssueTitleSchema } from "@paperclipai/shared";
import { authorizeInstructionCommit } from "../agent-instruction-authorization.js";
import { executeAgentInstructionTool } from "./agent-instruction-tools.js";
import { createReadStream } from "node:fs";
@@ -87,7 +91,7 @@ import {
const IMPLEMENTED_OPERATIONS = new Set([
"read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions",
"search_api", "call_api", "hire_agent",
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title",
"request_human_input",
"create_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
"list_documents", "read_document", "list_document_revisions", "write_document",
@@ -120,6 +124,7 @@ type Binding = {
readRemoteWorkspaceFile?: RemoteWorkspaceFileReader;
currentWakeComments?: CurrentWakeCommentsBinding;
chatAttachmentReadScope?: NativeChatAttachmentReadScope;
syncIssueExternalObjects?: (issueId: string) => Promise<void>;
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise<void>;
enqueueWakeup?: (agentId: string, options: {
source: "assignment";
@@ -547,6 +552,7 @@ export class PaperclipRunnerToolAuthority {
case "create_task": return this.#createTask(input,
(await captureRunIdentity(this.db, this.binding)).context?.id ?? null);
case "reassign_task": return this.#reassignTask(input);
case "set_task_title": return this.#setTaskTitle(input);
case "set_dependencies": return this.#setDependencies(input);
case "register_deliverable": return this.#registerDeliverable(input);
default: throw new Error("paperclip_runner_tool_not_bound");
@@ -1246,6 +1252,25 @@ export class PaperclipRunnerToolAuthority {
return result;
}
async #setTaskTitle(input: Record<string, unknown>): Promise<unknown> {
const titleInput = setIssueTitleSchema.parse(input);
requiredString(titleInput.idempotencyKey);
// Use the shared title receipt so native and HTTP retries have the same
// identity and cannot overwrite a later user edit when switching surfaces.
const { result, publication } = await this.db.transaction(async (tx) => {
await this.#lockAuthorizedMutationContext(tx as unknown as Db);
return setIssueTitle(tx as unknown as Db, this.binding.companyId, this.binding.issueId, titleInput, {
actorType: "agent", actorId: this.binding.agentId, agentId: this.binding.agentId, runId: this.binding.runId,
});
});
if (publication) publishActivity(publication);
const syncExternalObjects = this.binding.syncIssueExternalObjects ?? externalObjectService(this.db, {
enabled: async () => (await instanceSettingsService(this.db).getExperimental()).enableExternalObjects === true,
}).syncIssueSafely;
await syncExternalObjects(this.binding.issueId);
return result;
}
async #setDependencies(input: Record<string, unknown>): Promise<unknown> {
const idempotencyKey = requiredString(input.idempotencyKey);
if (!Array.isArray(input.blockedByTaskIds)) {
@@ -1926,6 +1951,7 @@ function redactedTask(task: typeof issues.$inferSelect) {
companyId: task.companyId,
identifier: task.identifier,
title: task.title,
titleNeedsGeneration: task.titleNeedsGeneration,
description: task.description,
status: task.status,
statusVersion: task.statusVersion,
@@ -42,6 +42,7 @@ function words(text: string): string[] {
}
function dedicatedTools(method: string, path: string): string[] {
if (/\/issues\/\{[^}]+\}\/title$/.test(path) && method === "PUT") return ["set_task_title"];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/file$/.test(path)) return method === "GET" ? ["read_agent_instructions"] : method === "PUT" ? ["update_agent_instructions"] : [];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/history$/.test(path) && method === "GET") return ["get_agent_instruction_history"];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/revision\/\{[^}]+\}$/.test(path) && method === "GET") return ["read_agent_instructions"];
@@ -83,6 +84,7 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
const restriction = runnerApiRestriction(method, path);
const conversationalConfirmation = method === "POST"
&& /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path);
const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path);
const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`];
const protocol = !path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
|| /\/(claude-login|login-sessions|start-authorization|finalize-oauth-access)(\/|$)/.test(path)
@@ -109,7 +111,7 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent")
? "Use hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime; never use call_api to supply adapter, environment, or credential configuration."
: "Use an available dedicated tool for its supported fields. Inspect that tool's advertised schema; call_api may be used for additional API fields, subject to lifecycle restrictions.",
allowedModes: method === "GET" || method === "HEAD" ? ["standard", "ask", "planning", "skill_test"]
allowedModes: method === "GET" || method === "HEAD" || taskTitleUpdate ? ["standard", "ask", "planning", "skill_test"]
: conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"],
...(skillReference ? { skillReference } : {}),
});
@@ -167,6 +169,6 @@ export function searchRunnerApi(value: unknown) {
return {
results, total: matches.length,
nextCursor: offset + limit < matches.length ? `${fingerprint}:${offset + limit}` : null,
guidance: "Prefer an available dedicated tool when it supports the required operation and parameters. API permissions still apply. Protocol endpoints require their existing clients. Ask and Plan permit only reads through call_api.",
guidance: "Prefer an available dedicated tool when it supports the required operation and parameters. API permissions still apply. Protocol endpoints require their existing clients. Ask and Plan permit reads and active-task title updates; Plan also permits conversational confirmation recording. Check each operation's allowedModes.",
};
}
@@ -58,6 +58,17 @@ describe("bounded response capture receipts", () => {
});
describe("runner API catalog", () => {
it.each(["standard", "ask", "planning", "skill_test"])("permits the title-only API in %s mode", workMode => {
const operationId = "PUT /api/issues/{id}/title";
const { operation } = validateRunnerApiCall({
operationId,
pathParams: { id: context.issueId },
body: { title: "Fix sign-in redirect", onlyIfProvisional: true },
}, { ...context, workMode });
expect(operation.allowedModes).toContain(workMode);
expect(operation.dedicatedTools).toEqual(["set_task_title"]);
expect(operation.requestBody?.content["application/json"].schema.additionalProperties).toBe(false);
});
it("advertises the conversational recording exception without general approval authority", () => {
const operationId = "POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment";
const operation = runnerApiOperation(operationId);
+7 -4
View File
@@ -1,4 +1,4 @@
import { createProjectSchema, createIssueSchema } from "@paperclipai/shared";
import { createProjectSchema, createIssueSchema, setIssueTitleSchema } from "@paperclipai/shared";
import { z } from "zod";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../vendor/paperclip-runner/index.js";
import { badRequest } from "../errors.js";
@@ -6,7 +6,7 @@ import { badRequest } from "../errors.js";
export const PROJECT_TOOL_NAMES = ["create_project", "list_project_repositories", "list_projects"];
export function projectToolDefinitions(workMode: string, includeTask = false) {
return CAPABILITY_SEMANTIC_TOOL_CATALOG.filter(tool =>
(PROJECT_TOOL_NAMES.includes(tool.operationId) || includeTask && tool.operationId === "create_task")
(PROJECT_TOOL_NAMES.includes(tool.operationId) || includeTask && ["create_task", "set_task_title"].includes(tool.operationId))
&& tool.allowedModes.includes(workMode as "standard"),
).map(tool => ({ name: tool.operationId, description: tool.description,
inputSchema: tool.operationId === "create_project"
@@ -23,7 +23,10 @@ export async function callProjectTool(input: {
const args = input.arguments;
let path = `/companies/${input.companyId}/projects`;
let body: unknown;
if (input.name === "list_project_repositories") path = `/companies/${input.companyId}/project-repositories`;
if (input.name === "set_task_title") {
path = `/issues/${input.issueId}/title`;
body = setIssueTitleSchema.parse(args);
} else if (input.name === "list_project_repositories") path = `/companies/${input.companyId}/project-repositories`;
else if (input.name === "list_projects") { /* read projects */ }
else if (input.name === "create_project") {
body = createProjectSchema.extend({ idempotencyKey: z.string().min(1).max(255) }).parse(args);
@@ -41,7 +44,7 @@ export async function callProjectTool(input: {
});
} else throw badRequest("Unknown project tool");
const response = await fetch(`${input.apiUrl.replace(/\/+$/, "").replace(/\/api$/, "")}/api${path}`, {
method: body ? "POST" : "GET",
method: input.name === "set_task_title" ? "PUT" : body ? "POST" : "GET",
headers: { Authorization: `Bearer ${input.token}`, "Content-Type": "application/json" },
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(60_000),