mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Fix conference room wake recovery loop
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1697d49ab4
commit
0cd4e7c2a6
4 files changed
+182
No files matched your search
@@ -771,6 +771,7 @@ export function renderPaperclipWakePrompt(
|
||||
"Answer the board directly by posting an issue comment on this same issue.",
|
||||
"Do not run normal inbox or assignment triage for this wake, and do not delegate unless the board explicitly asks you to create or assign work.",
|
||||
"Keep the reply conversational, concise, and useful to the board.",
|
||||
"Once you have answered the current board message, treat the chat turn as complete; Paperclip will mark the chat issue done until the board sends another message.",
|
||||
"If the issue title is still `New chat`, update it to a short descriptive conversation title before or alongside your first substantive reply.",
|
||||
"",
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createServer } from "node:http";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { CONFERENCE_ROOM_ORIGIN_KIND } from "@paperclipai/shared";
|
||||
import {
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
@@ -1485,6 +1486,128 @@ describe("heartbeat comment wake batching", () => {
|
||||
await gateway.close();
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("auto-completes conference room turns after the agent replies", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {
|
||||
url: gateway.url,
|
||||
headers: {
|
||||
"x-openclaw-token": "gateway-token",
|
||||
},
|
||||
payloadTemplate: {
|
||||
message: "wake now",
|
||||
},
|
||||
waitTimeoutMs: 2_000,
|
||||
},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "New chat",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
originKind: CONFERENCE_ROOM_ORIGIN_KIND,
|
||||
originId: agentId,
|
||||
});
|
||||
|
||||
const firstRun = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: "issue_commented",
|
||||
source: "conference_room.comment",
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "user-1",
|
||||
});
|
||||
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: agentId,
|
||||
authorUserId: null,
|
||||
createdByRunId: firstRun!.id,
|
||||
body: "Here is the CEO response.",
|
||||
});
|
||||
|
||||
gateway.releaseFirstWait();
|
||||
|
||||
await waitFor(async () => {
|
||||
const run = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, firstRun!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "succeeded" && run.issueCommentStatus === "satisfied";
|
||||
});
|
||||
|
||||
const completedIssue = await db
|
||||
.select({
|
||||
status: issues.status,
|
||||
checkoutRunId: issues.checkoutRunId,
|
||||
executionRunId: issues.executionRunId,
|
||||
completedAt: issues.completedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(completedIssue?.status).toBe("done");
|
||||
expect(completedIssue?.checkoutRunId).toBeNull();
|
||||
expect(completedIssue?.executionRunId).toBeNull();
|
||||
expect(completedIssue?.completedAt).toBeInstanceOf(Date);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(1);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
} finally {
|
||||
gateway.releaseFirstWait();
|
||||
await gateway.close();
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("treats the automatic run summary as fallback-only when the run already posted a comment", async () => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
|
||||
@@ -28,6 +28,7 @@ describe("conference room wake prompt", () => {
|
||||
|
||||
expect(prompt).toContain("## Conference Room Mode");
|
||||
expect(prompt).toContain("This issue is a board chat with you");
|
||||
expect(prompt).toContain("Paperclip will mark the chat issue done until the board sends another message");
|
||||
expect(prompt).toContain("If the issue title is still `New chat`");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, lte, notInArr
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
CONFERENCE_ROOM_ORIGIN_KIND,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
MODEL_PROFILE_KEYS,
|
||||
isEnvironmentDriverSupportedForAdapter,
|
||||
@@ -3238,6 +3239,57 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function completeConferenceRoomTurnAfterReply(input: {
|
||||
run: typeof heartbeatRuns.$inferSelect;
|
||||
issueId: string;
|
||||
commentId: string;
|
||||
}) {
|
||||
if (input.run.status !== "succeeded") return null;
|
||||
|
||||
const issue = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
status: issues.status,
|
||||
originKind: issues.originKind,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.run.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!issue) return null;
|
||||
if (issue.originKind !== CONFERENCE_ROOM_ORIGIN_KIND) return null;
|
||||
if (issue.status !== "in_progress") return null;
|
||||
if (issue.assigneeAgentId !== input.run.agentId) return null;
|
||||
|
||||
const updated = await issuesSvc.update(input.issueId, {
|
||||
status: "done",
|
||||
});
|
||||
if (!updated) return null;
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: "system",
|
||||
actorId: "heartbeat",
|
||||
agentId: input.run.agentId,
|
||||
runId: input.run.id,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
status: "done",
|
||||
previousStatus: issue.status,
|
||||
source: "conference_room.auto_complete",
|
||||
satisfiedByCommentId: input.commentId,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function refreshContinuationSummaryForRun(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
@@ -3438,6 +3490,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
issueCommentSatisfiedByCommentId: postedComment.id,
|
||||
issueCommentRetryQueuedAt: null,
|
||||
});
|
||||
await completeConferenceRoomTurnAfterReply({
|
||||
run,
|
||||
issueId,
|
||||
commentId: postedComment.id,
|
||||
});
|
||||
return { outcome: "satisfied" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user