mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +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();
|
||||
|
||||
Reference in new issue
Block a user