mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
feat: make chat-style tasks the default experience (#11101)
This commit is contained in:
1 parent
2da6a248c3
commit
815e49bb7c
80 files changed
+3858
-1497
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user