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