Files
PaperClipAI/server/src/__tests__/task-title-routes.test.ts
T
DottaandPaperclip 018993140f 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>
2026-09-30 16:48:24 -05:00

198 lines
13 KiB
TypeScript

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 });
});
});