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

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