mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02: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
@@ -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