feat: make chat-style tasks the default experience (#11101)

This commit is contained in:
scotttong authored and GitHub committed 2026-08-11 09:06:21 -07:00
1 parent 2da6a248c3
commit 815e49bb7c
80 files changed
+3858 -1497

No files matched your search

+38 -18
View File
@@ -569,7 +569,7 @@ describeEmbeddedPostgres("built-in agents", () => {
});
});
it("auto-provisions a paused Reflection Coach bundle with skill sync and a disabled routine", async () => {
it("reconciles an enabled Reflection Coach bundle with skill sync and a disabled routine", async () => {
const companyId = await seedCompany({ requireApproval: false });
const root = await agentService(db).create(companyId, {
name: "CEO",
@@ -581,6 +581,17 @@ describeEmbeddedPostgres("built-in agents", () => {
permissions: {},
});
// The Reflection Coach is opt-in (not auto-created). Enabling it on demand
// materializes its managed bundle in a single pass.
const enabled = await builtInAgentService(db).ensure(companyId, "reflection-coach");
expect(enabled.agent?.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
});
expect(enabled.agent?.adapterConfig).not.toMatchObject({ model: "gpt-5.4", apiKey: "do-not-copy" });
// Startup reconcile keeps the enabled bundle tracking stock and re-grants
// the root/company default permissions.
const result = await reconcileBuiltInAgentsOnStartup(db);
expect(result.autoEnsured).toBeGreaterThanOrEqual(1);
expect(result.defaultGrantsEnsured).toBeGreaterThanOrEqual(4);
@@ -606,11 +617,6 @@ describeEmbeddedPostgres("built-in agents", () => {
},
},
});
expect(state.agent?.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
});
expect(state.agent?.adapterConfig).not.toMatchObject({ model: "gpt-5.4", apiKey: "do-not-copy" });
expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus])).toEqual([
["instructions", "stock_current"],
["skill", "stock_current"],
@@ -693,7 +699,7 @@ describeEmbeddedPostgres("built-in agents", () => {
)).size).toBe(3);
});
it("preserves new-agent approval gates during automatic Reflection Coach provisioning", async () => {
it("preserves new-agent approval gates during on-demand Reflection Coach provisioning", async () => {
const companyId = await seedCompany({ requireApproval: true });
const root = await agentService(db).create(companyId, {
name: "CEO",
@@ -710,12 +716,11 @@ describeEmbeddedPostgres("built-in agents", () => {
applyInSeparateFollowUpRun: true,
};
const result = await reconcileBuiltInAgentsOnStartup(db);
expect(result).toMatchObject({
autoEnsured: 2,
pendingApprovals: 2,
});
// The Reflection Coach is opt-in, so it's enabled on demand. With board
// approval required, provisioning it must leave a pending agent + a
// hire_agent approval rather than an active agent.
const provisioned = await builtInAgentService(db).provision(companyId, "reflection-coach");
expect(provisioned.approval).not.toBeNull();
const state = await builtInAgentService(db).get(companyId, "reflection-coach");
expect(state).toMatchObject({
status: "pending_approval",
@@ -751,7 +756,7 @@ describeEmbeddedPostgres("built-in agents", () => {
});
const pendingReconcile = await reconcileBuiltInAgentsOnStartup(db);
expect(pendingReconcile.pendingApprovals).toBe(2);
expect(pendingReconcile.pendingApprovals).toBe(1);
const stillPending = await builtInAgentService(db).get(companyId, "reflection-coach");
expect(stillPending).toMatchObject({
status: "pending_approval",
@@ -787,7 +792,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const agentRows = await db.select().from(agents).where(eq(agents.companyId, companyId));
expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1);
const approvalRows = await db.select().from(approvals).where(eq(approvals.companyId, companyId));
expect(approvalRows).toHaveLength(2);
expect(approvalRows).toHaveLength(1);
});
it("preserves Reflection Coach instruction drift on reconcile and restores it on reset", async () => {
@@ -1062,7 +1067,21 @@ describeEmbeddedPostgres("built-in agents", () => {
const { olderId, newerId } = await seedLegacyDuplicateBriefs(affectedCompanyId);
// A second company created after the affected one — previously skipped
// entirely because the duplicate error escaped the reconciliation loop.
// Give it a drifted built-in row so we can prove reconcile still reached it.
const healthyCompanyId = await seedCompany({ requireApproval: false });
const healthyBriefsId = randomUUID();
await db.insert(agents).values({
id: healthyBriefsId,
companyId: healthyCompanyId,
name: "Stale Briefs",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
runtimeConfig: {},
permissions: {},
metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }),
});
const result = await reconcileBuiltInAgentsOnStartup(db);
expect(result.companyFailures).toBe(0);
@@ -1077,9 +1096,10 @@ describeEmbeddedPostgres("built-in agents", () => {
),
).toHaveLength(1);
// The company after the affected one still had its bundled agents provisioned.
const healthyCoach = await builtInAgentService(db).get(healthyCompanyId, "reflection-coach");
expect(healthyCoach.agentId).toBeTruthy();
// The company after the affected one was still reconciled (its drifted
// built-in row was repaired to stock) rather than skipped.
const [healthyBriefs] = await db.select().from(agents).where(eq(agents.id, healthyBriefsId));
expect(healthyBriefs?.name).toBe("Briefs Agent");
});
it("automatically materializes the Reflection Coach bundle without enabling background work", async () => {
+17 -12
View File
@@ -24,7 +24,7 @@ import {
} from "./helpers/embedded-postgres.js";
import { companyService } from "../services/companies.js";
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
import { reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js";
import { builtInAgentService, reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -81,19 +81,28 @@ describeEmbeddedPostgres("companyService", () => {
expect(rows.map((row) => row.issuePrefix).sort()).toEqual(["ARO", "AROA"]);
});
it("auto-provisions one paused Reflection Coach bundle for a freshly created company", async () => {
it("does not auto-provision bundled built-in agents for a freshly created company", async () => {
const created = await companyService(db).create({
name: "Fresh Company",
});
// A new company starts clean: the Reflection Coach and Summarizer are
// opt-in, not seeded by default for a new user.
const agentRows = await db.select().from(agents).where(eq(agents.companyId, created.id));
const reflectionRows = agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach");
expect(reflectionRows).toHaveLength(1);
expect(reflectionRows[0]).toMatchObject({
expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata))).toHaveLength(0);
// Startup reconcile leaves a fresh company untouched — nothing is created.
await reconcileBuiltInAgentsOnStartup(db);
const afterReconcileRows = await db.select().from(agents).where(eq(agents.companyId, created.id));
expect(afterReconcileRows.filter((row) => readBuiltInAgentMarker(row.metadata))).toHaveLength(0);
// The Reflection Coach remains available to enable on demand, and enabling
// it materializes its bundled skill + paused routine.
const enabled = await builtInAgentService(db).ensure(created.id, "reflection-coach");
expect(enabled.agent).toMatchObject({
name: "Reflection Coach",
status: "paused",
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
});
const [skill] = await db
@@ -110,10 +119,10 @@ describeEmbeddedPostgres("companyService", () => {
const [routine] = await db
.select()
.from(routines)
.where(and(eq(routines.companyId, created.id), eq(routines.assigneeAgentId, reflectionRows[0]!.id)));
.where(and(eq(routines.companyId, created.id), eq(routines.assigneeAgentId, enabled.agentId!)));
expect(routine).toMatchObject({
status: "paused",
assigneeAgentId: reflectionRows[0]!.id,
assigneeAgentId: enabled.agentId,
originKind: "built_in_agent_bundle",
originId: "reflection-coach:recent-agent-reflection",
});
@@ -122,10 +131,6 @@ describeEmbeddedPostgres("companyService", () => {
kind: "schedule",
enabled: false,
});
await reconcileBuiltInAgentsOnStartup(db);
const afterReconcileRows = await db.select().from(agents).where(eq(agents.companyId, created.id));
expect(afterReconcileRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1);
});
it("archives companies by pausing runnable agents and cancelling active runs", async () => {
@@ -29,7 +29,7 @@ describe("instance settings service", () => {
enableStreamlinedLeftNavigation: true,
enableApps: false,
enableConferenceRoomChat: false,
enableTaskChatRedesign: false,
enableClassicTaskInterface: false,
enableExternalObjects: false,
enableSmokeLab: false,
enablePipelines: false,
@@ -72,6 +72,20 @@ describe("instance settings service", () => {
).toBe(false);
});
it("defaults enableClassicTaskInterface to false for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableClassicTaskInterface).toBe(false);
expect(normalizeExperimentalSettings({}).enableClassicTaskInterface).toBe(false);
// The retired enableTaskChatRedesign key must not bleed into the new flag:
// an install that had the chat redesign ON opted into chat-style, which is
// now the default — not into the classic view.
expect(
normalizeExperimentalSettings({ enableTaskChatRedesign: true }).enableClassicTaskInterface,
).toBe(false);
expect(
normalizeExperimentalSettings({ enableClassicTaskInterface: true }).enableClassicTaskInterface,
).toBe(true);
});
it("defaults enableSimplifiedEnglishInteractions to false for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableSimplifiedEnglishInteractions).toBe(false);
expect(normalizeExperimentalSettings({}).enableSimplifiedEnglishInteractions).toBe(false);
@@ -0,0 +1,168 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
agentWakeupRequests,
companies,
createDb,
issueComments,
issues,
} from "@paperclipai/db";
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { actorMiddleware } from "../middleware/auth.js";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres onboarding first-task route tests on this host: ${
embeddedPostgresSupport.reason ?? "unsupported environment"
}`,
);
}
describeEmbeddedPostgres("issue create onboarding first-task routes", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-onboarding-first-task-routes-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(agentWakeupRequests);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
function createApp() {
const app = express();
app.use(express.json());
app.use(actorMiddleware(db, { deploymentMode: "local_trusted" }));
app.use("/api", issueRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `D${companyId.replace(/-/g, "").slice(0, 5).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
return companyId;
}
async function seedAgent(companyId: string) {
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "CEO",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
return agentId;
}
function listOnboardingIssues(companyId: string) {
return db
.select()
.from(issues)
.where(and(
eq(issues.companyId, companyId),
eq(issues.originKind, ONBOARDING_FIRST_TASK_ORIGIN_KIND),
));
}
it("stamps the onboarding origin and seeds the agent-attributed greeting on the first task", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent(companyId);
const app = createApp();
const created = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({ title: "Get started", onboardingFirstTask: true, assigneeAgentId: agentId })
.expect(201);
expect(created.body.originKind).toBe(ONBOARDING_FIRST_TASK_ORIGIN_KIND);
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, created.body.id));
expect(comments).toHaveLength(1);
expect(comments[0]).toMatchObject({ authorType: "agent", authorAgentId: agentId });
});
it("fails closed to an ordinary issue when the onboarding origin is already claimed", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent(companyId);
const app = createApp();
// Simulate the losing side of the count-vs-create race: the winning issue
// already claimed the onboarding origin but is hidden, so the zero-count
// fast path still passes and only issues_onboarding_first_task_uq rejects
// the privileged insert.
await db.insert(issues).values({
companyId,
title: "Race winner",
status: "todo",
priority: "medium",
originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND,
hiddenAt: new Date(),
});
const created = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({ title: "Race loser", onboardingFirstTask: true, assigneeAgentId: agentId })
.expect(201);
expect(created.body.originKind).toBe("manual");
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, created.body.id));
expect(comments).toHaveLength(0);
expect(await listOnboardingIssues(companyId)).toHaveLength(1);
});
it("allows at most one onboarding first task across concurrent creates", async () => {
const companyId = await seedCompany();
const app = createApp();
const responses = await Promise.all(
["Kick off A", "Kick off B", "Kick off C"].map((title) =>
request(app)
.post(`/api/companies/${companyId}/issues`)
.send({ title, onboardingFirstTask: true }),
),
);
for (const response of responses) expect(response.status).toBe(201);
expect(await listOnboardingIssues(companyId)).toHaveLength(1);
});
});
@@ -24,6 +24,7 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
import { instanceSettingsService } from "../services/instance-settings.js";
import { issueService } from "../services/issues.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
@@ -1262,6 +1263,190 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
expect(interactions.find((interaction) => interaction.id === otherKind.id)?.status).toBe("pending");
});
it("supersedes an agent's own older pending ask_user_questions without crossing agent, kind, or issue", async () => {
const { companyId, goalId, issueId } = await seedConfirmationIssue("Question supersedes older sibling");
const otherIssueId = randomUUID();
await db.insert(issues).values({
id: otherIssueId,
companyId,
goalId,
title: "Other issue",
status: "in_progress",
priority: "medium",
});
const probingAgentId = randomUUID();
const otherAgentId = randomUUID();
await db.insert(agents).values([
{
id: probingAgentId,
companyId,
name: "Probing agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId,
name: "Other agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
const question = (prompt: string) => ({
kind: "ask_user_questions" as const,
payload: {
version: 1 as const,
questions: [{
id: "q",
prompt,
selectionMode: "single" as const,
options: [{ id: "opt", label: "Option" }],
}],
},
});
const older = await interactionsSvc.create(
{ id: issueId, companyId }, question("Older question"), { agentId: probingAgentId },
);
const otherKind = await interactionsSvc.create({ id: issueId, companyId }, {
kind: "request_confirmation",
payload: { version: 1, prompt: "Approve the draft?" },
}, { agentId: probingAgentId });
const otherAgentQuestion = await interactionsSvc.create(
{ id: issueId, companyId }, question("Other agent question"), { agentId: otherAgentId },
);
const otherIssueQuestion = await interactionsSvc.create(
{ id: otherIssueId, companyId }, question("Other issue question"), { agentId: probingAgentId },
);
const replacement = await interactionsSvc.create(
{ id: issueId, companyId }, question("Newer question"), { agentId: probingAgentId },
);
const interactions = await interactionsSvc.listForIssue(issueId);
expect(interactions.find((interaction) => interaction.id === older.id)).toMatchObject({
status: "expired",
resolvedByAgentId: probingAgentId,
result: {
answers: [],
expirationReason: "superseded_by_newer_interaction",
supersededByInteractionId: replacement.id,
},
});
expect(interactions.find((interaction) => interaction.id === replacement.id)?.status).toBe("pending");
// A different agent's pending question is untouched.
expect(interactions.find((interaction) => interaction.id === otherAgentQuestion.id)?.status).toBe("pending");
// A different kind from the same agent is untouched.
expect(interactions.find((interaction) => interaction.id === otherKind.id)?.status).toBe("pending");
// The same agent's question on a different issue is untouched.
const otherIssueInteractions = await interactionsSvc.listForIssue(otherIssueId);
expect(otherIssueInteractions.find((interaction) => interaction.id === otherIssueQuestion.id)?.status)
.toBe("pending");
});
it("leaves exactly one pending ask_user_questions on the onboarding first task after probe cards and the real question arrive", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Chief of staff",
role: "chief_of_staff",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(goals).values({
id: goalId,
companyId,
title: "Your first task",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Your first task",
status: "in_progress",
priority: "medium",
originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND,
assigneeAgentId: agentId,
});
// Reproduces PAP-436: the assigned agent posts two throwaway schema probes
// (title/prompt/option "t"/"p"/"L") before the genuine question.
const probe = (prompt: string) => ({
kind: "ask_user_questions" as const,
payload: {
version: 1 as const,
questions: [{
id: "q",
prompt,
selectionMode: "single" as const,
options: [{ id: "L", label: "L" }],
}],
},
});
await interactionsSvc.create({ id: issueId, companyId }, probe("t"), { agentId });
await interactionsSvc.create({ id: issueId, companyId }, probe("p"), { agentId });
await interactionsSvc.create({ id: issueId, companyId }, {
kind: "ask_user_questions",
payload: {
version: 1,
questions: [{
id: "focus",
prompt: "What would you like your team to focus on first?",
selectionMode: "single",
options: [
{ id: "mvp", label: "Ship the MVP" },
{ id: "bugs", label: "Fix bugs" },
],
}],
},
}, { agentId });
const interactions = await interactionsSvc.listForIssue(issueId);
const pendingQuestions = interactions.filter(
(interaction) => interaction.kind === "ask_user_questions" && interaction.status === "pending",
);
expect(pendingQuestions).toHaveLength(1);
expect(pendingQuestions[0]?.kind).toBe("ask_user_questions");
const [remaining] = pendingQuestions;
if (remaining?.kind === "ask_user_questions") {
expect(remaining.payload.questions[0]?.prompt).toContain("focus on first");
}
// Both probe cards auto-expired with the sibling-supersede reason.
const expiredQuestions = interactions.filter(
(interaction) => interaction.kind === "ask_user_questions" && interaction.status === "expired",
);
expect(expiredQuestions).toHaveLength(2);
for (const card of expiredQuestions) {
expect(card.result).toMatchObject({ expirationReason: "superseded_by_newer_interaction" });
}
});
it("sweeps historical confirmation pile-ups idempotently per issue, kind, and agent", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Historical confirmation sweep");
const firstAgentId = randomUUID();
+106 -4
View File
@@ -56,6 +56,7 @@ import {
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
ISSUE_WATCHDOG_DISCOVERY_KINDS,
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
ONBOARDING_FIRST_TASK_ORIGIN_KIND,
rejectIssueThreadInteractionSchema,
restoreIssueDocumentRevisionSchema,
respondIssueThreadInteractionSchema,
@@ -166,6 +167,10 @@ import {
SVG_CONTENT_TYPE,
} from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
import {
buildOnboardingGreeting,
ONBOARDING_GREETING_AUTHORIZATION_REASON,
} from "../services/onboarding-greeting.js";
import {
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
buildIssueBlockersResolvedWakeIdempotencyKey,
@@ -592,6 +597,27 @@ function authenticatedActorResponsibleUserId(req: Request) {
return req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : undefined;
}
// Matches the partial unique index that guarantees at most one onboarding
// first-task issue per company (packages/db/src/schema/issues.ts).
function isOnboardingFirstTaskConflict(error: unknown): boolean {
for (
let current = error, depth = 0;
current && typeof current === "object" && depth < 5;
current = (current as { cause?: unknown }).cause, depth += 1
) {
const candidate = current as { code?: string; constraint?: string; message?: string };
if (
candidate.code === "23505" &&
(candidate.constraint === "issues_onboarding_first_task_uq" ||
(typeof candidate.message === "string" &&
candidate.message.includes("issues_onboarding_first_task_uq")))
) {
return true;
}
}
return false;
}
function issueWriteAuthorizationReason(
req: Request,
decision: true | { reason?: string | null },
@@ -7625,7 +7651,34 @@ export function issueRoutes(
surface: "issues.create",
});
if (!sanitizedBody) return;
const { watchdogDiscovery: rawWatchdogDiscovery, ...rawCreateBody } = sanitizedBody;
const {
watchdogDiscovery: rawWatchdogDiscovery,
onboardingFirstTask: rawOnboardingFirstTask,
...rawCreateBody
} = sanitizedBody;
// The onboarding first-task marker grants privileged, server-owned behavior:
// it stamps the onboarding origin (which suppresses the seeded description in
// the UI) and seeds a comment authored *as the assigned agent*. Honor it only
// when the request is genuinely the onboarding wizard creating a company's
// very first task, verified server-side so a client marker alone cannot
// trigger it:
// 1. the caller is a human board/user session (the wizard never runs as an
// agent), and
// 2. the company has no existing issues yet — i.e. this really is the first
// task. An established company creating an ordinary issue can never reach
// the greeting/description-suppression path, so no board caller can
// fabricate a statement attributed to an assigned agent on a normal task.
// Fails closed: if it is not verifiably the first task, the flag is ignored
// and an ordinary issue is created. The zero-count read below is only a
// fast-path gate — overlapping requests could both observe zero — so the
// partial unique index issues_onboarding_first_task_uq is what atomically
// enforces at most one onboarding first task per company; the create call
// handles losing that race by degrading to an ordinary issue.
const onboardingFirstTaskRequested =
rawOnboardingFirstTask === true && req.actor.type === "board";
let isOnboardingFirstTask = onboardingFirstTaskRequested
? (await svc.count(companyId)) === 0
: false;
const watchdogDiscovery = normalizeWatchdogDiscovery(rawWatchdogDiscovery);
const watchdogProductBugFollowUp = await resolveTaskWatchdogProductBugFollowUp(
req,
@@ -7680,6 +7733,9 @@ export function issueRoutes(
...(runWorkspaceInheritanceSourceIssueId
? { inheritExecutionWorkspaceFromIssueId: runWorkspaceInheritanceSourceIssueId }
: {}),
...(isOnboardingFirstTask && !watchdogProductBugFollowUp
? { originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }
: {}),
...(watchdogProductBugFollowUp
? {
description: appendWatchdogDiscoveryContext({
@@ -7734,7 +7790,7 @@ export function issueRoutes(
executionPolicy,
}, actor);
let deduplicationReason: "idempotency_key" | "recent_open_title" | null = null;
const issue = await svc.create(companyId, {
const createInput = {
...createBody,
...(taskBridgeOriginForActor(req) ?? {}),
id: issueId,
@@ -7747,10 +7803,23 @@ export function issueRoutes(
actorResponsibleUserId: authenticatedActorResponsibleUserId(req),
trustExplicitResponsibleUserId: actor.actorType === "user",
watchdogActorRunId: actor.runId,
onDeduplicated: (reason) => {
onDeduplicated: (reason: "idempotency_key" | "recent_open_title") => {
deduplicationReason = reason;
},
});
};
let issue: Awaited<ReturnType<typeof svc.create>>;
try {
issue = await svc.create(companyId, createInput);
} catch (error) {
// Concurrent onboarding creates can both pass the zero-count fast path;
// the issues_onboarding_first_task_uq index rejects the loser here. Fail
// closed: drop the privileged origin (and with it the agent-attributed
// greeting) and create an ordinary issue instead.
if (!(isOnboardingFirstTask && isOnboardingFirstTaskConflict(error))) throw error;
isOnboardingFirstTask = false;
const { originKind: _onboardingOriginKind, ...ordinaryCreateInput } = createInput;
issue = await svc.create(companyId, ordinaryCreateInput);
}
if (deduplicationReason) {
const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
res.status(200).json({
@@ -7849,6 +7918,39 @@ export function issueRoutes(
});
}
// Seed the onboarding first-task greeting as an agent-authored comment so the
// user lands on a waiting greeting (instead of a right-aligned "user" bubble
// showing the seeded description). Deterministic template — no LLM call — and
// best-effort: a greeting failure must not fail issue creation.
if (isOnboardingFirstTask && issue.assigneeAgentId) {
try {
const [company, goal, assigneeAgent] = await Promise.all([
companiesSvc.getById(companyId),
createBody.goalId ? goalsSvc.getById(createBody.goalId) : Promise.resolve(null),
agentsSvc.getById(issue.assigneeAgentId),
]);
const greetingBody = buildOnboardingGreeting({
agentName: assigneeAgent?.name ?? null,
teamName: company?.name ?? null,
goals: goal?.description ?? goal?.title ?? null,
});
await svc.addComment(
issue.id,
greetingBody,
{ agentId: issue.assigneeAgentId },
{
authorType: "agent",
authorizationReason: ONBOARDING_GREETING_AUTHORIZATION_REASON,
},
);
} catch (err) {
logger.warn(
{ err, issueId: issue.id, companyId },
"failed to seed onboarding first-task greeting",
);
}
}
void queueIssueAssignmentWakeup({
heartbeat,
issue,
+16
View File
@@ -471,6 +471,12 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
const DEFINITIONS_BY_KEY = new Map(DEFINITIONS.map((definition) => [definition.key, definition]));
// Bundled built-in agents that should be provisioned automatically when a
// company is created (and re-ensured on startup reconcile). Empty by default so
// a new user starts clean — the Reflection Coach and Summarizer are opt-in, not
// seeded. Add a definition key here to restore automatic provisioning.
const AUTO_PROVISION_ON_COMPANY_CREATE_KEYS = new Set<string>([]);
const ROOT_AGENT_DEFAULT_CHANGE_GRANTS: PermissionKey[] = ["agents:configure", "skills:create"];
const BUILT_IN_AGENT_DEFAULT_GRANTS: Record<string, PermissionKey[]> = {
"reflection-coach": ["agents:suggest-changes", "skills:suggest-changes"],
@@ -1917,7 +1923,17 @@ export function builtInAgentService(db: Db) {
const company = await ensureCompany(companyId);
let autoEnsured = 0;
let pendingApprovals = 0;
// A fresh company starts with only its own lead agent — the Reflection
// Coach and Summarizer are no longer auto-created for new users. They stay
// available to enable on demand (via ensure / provision / the built-in
// bundle panel). We still reconcile any bundled agent that already exists
// (e.g. one an operator enabled) so its instructions/skill/routine keep
// tracking stock. Add a key to AUTO_PROVISION_ON_COMPANY_CREATE_KEYS to
// restore automatic creation for that definition.
for (const definition of DEFINITIONS.filter((entry) => entry.bundle)) {
const existing = await findSingleAgent(companyId, definition);
const shouldProvision = existing !== null || AUTO_PROVISION_ON_COMPANY_CREATE_KEYS.has(definition.key);
if (!shouldProvision) continue;
if (company.requireBoardApprovalForNewAgents) {
const result = await provision(companyId, definition.key);
if (result.approval) pendingApprovals += 1;
+2 -2
View File
@@ -214,7 +214,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enablePipelines: parsed.data.enablePipelines ?? false,
enableCases: parsed.data.enableCases ?? false,
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
enableTaskChatRedesign: parsed.data.enableTaskChatRedesign ?? false,
enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false,
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
@@ -250,7 +250,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enablePipelines: false,
enableCases: false,
enableConferenceRoomChat: false,
enableTaskChatRedesign: false,
enableClassicTaskInterface: false,
enableTaskWatchdogs: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
@@ -565,6 +565,21 @@ function buildSupersededByNewerRequestResult(replacementInteractionId: string) {
} as const;
}
// An agent that posts a fresh ask_user_questions while its own earlier ones on
// the same issue are still pending has replaced them — the newer card carries
// the real ask, so the stale siblings auto-expire (PAP-437). Mirrors the
// `superseded_by_comment` shape (ask_user_questions results key expiry off
// `expirationReason`, not `outcome`) so the UI can hide them cleanly.
function buildSupersededByNewerInteractionResult(replacementInteractionId: string) {
return {
version: 1,
answers: [],
expirationReason: "superseded_by_newer_interaction",
supersededByInteractionId: replacementInteractionId,
summaryMarkdown: null,
} as const;
}
function buildAdministrativeOutcomeResult(
row: IssueThreadInteractionRow,
outcome: "withdrawn" | "issue_closed" | "addressee_deleted",
@@ -1888,16 +1903,27 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
})
.returning();
if (data.kind !== "request_confirmation" || !actor.agentId) {
// An agent replacing its own still-pending card supersedes the older
// one so the thread never accumulates stale sibling cards. This covers
// request_confirmation drafts and ask_user_questions (PAP-437: probe
// question cards that agents never withdrew). Each kind keeps its own
// result shape. Scoped strictly to the same agent + issue + kind, so
// other agents' or other kinds' pending cards are untouched.
const canSupersedeSiblingCards =
data.kind === "request_confirmation" || data.kind === "ask_user_questions";
if (!actor.agentId || !canSupersedeSiblingCards) {
return { row, supersededRows: [] };
}
const now = new Date();
const supersededResult = data.kind === "ask_user_questions"
? buildSupersededByNewerInteractionResult(row.id)
: buildSupersededByNewerRequestResult(row.id);
const supersededRows = await tx
.update(issueThreadInteractions)
.set({
status: "expired",
result: buildSupersededByNewerRequestResult(row.id),
result: supersededResult,
resolvedByAgentId: actor.agentId,
resolvedByUserId: actor.userId ?? null,
resolvedAt: now,
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { buildOnboardingGreeting } from "./onboarding-greeting.js";
describe("buildOnboardingGreeting", () => {
it("introduces the agent by name as the user's first teammate and reflects the goals", () => {
const greeting = buildOnboardingGreeting({
agentName: "Nova",
teamName: "Acme",
goals: "Launch a marketplace for local makers.",
});
expect(greeting).toContain(
"Welcome! I'm Nova, your first agent teammate on Paperclip.",
);
expect(greeting).toContain("Here's what I understand you're aiming for:");
expect(greeting).toContain("> Launch a marketplace for local makers.");
expect(greeting).toContain("propose a team of agents");
expect(greeting).toContain("few focused questions");
});
it("falls back to a generic teammate intro when no agent name is set", () => {
const greeting = buildOnboardingGreeting({ agentName: null, goals: null });
expect(greeting).toContain(
"Welcome! I'm your first agent teammate on Paperclip.",
);
});
it("collapses whitespace in the reflected goals", () => {
const greeting = buildOnboardingGreeting({
goals: " Build\n\n a SaaS product. ",
});
expect(greeting).toContain("> Build a SaaS product.");
});
it("omits the reflect-back block when no goals are provided", () => {
const greeting = buildOnboardingGreeting({ agentName: "Nova", goals: null });
expect(greeting).not.toContain("aiming for");
expect(greeting).toContain("propose a team of agents");
});
});
@@ -0,0 +1,39 @@
// Deterministic, template-driven greeting seeded as an agent-authored comment on
// the onboarding first task. No LLM call: it reflects back the onboarding context
// (team name + goals) so the user lands on a waiting greeting instead of a
// right-aligned "user" bubble showing the agent's own seeded instructions.
export const ONBOARDING_GREETING_AUTHORIZATION_REASON = "onboarding first-task greeting";
export function buildOnboardingGreeting(input: {
agentName?: string | null;
teamName?: string | null;
goals?: string | null;
}): string {
const agentName = input.agentName?.trim();
const goals = input.goals?.replace(/\s+/g, " ").trim();
// Introduce the agent by the name the user chose in onboarding when we have
// it, so the first message reads as coming from *their* first teammate rather
// than a generic agent. Fall back to the generic phrasing otherwise.
const identity = agentName
? `Welcome! I'm ${agentName}, your first agent teammate on Paperclip.`
: "Welcome! I'm your first agent teammate on Paperclip.";
const lines: string[] = [];
lines.push(identity);
if (goals) {
lines.push("");
lines.push("Here's what I understand you're aiming for:");
lines.push("");
lines.push(`> ${goals}`);
}
lines.push("");
lines.push(
"I want to gather more context so I can come up with a plan and propose a team of agents to help execute it. I'm putting together a few focused questions so we can settle on a concrete goal to tackle first. Please give me one moment...",
);
return lines.join("\n");
}