diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 9e2b998e3e5..edb8bee4b6c 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -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.", "", ); diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index de1b80056ef..37183a2afb3 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -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(); diff --git a/server/src/__tests__/paperclip-conference-wake-prompt.test.ts b/server/src/__tests__/paperclip-conference-wake-prompt.test.ts index 573fb234ae6..985befebf5b 100644 --- a/server/src/__tests__/paperclip-conference-wake-prompt.test.ts +++ b/server/src/__tests__/paperclip-conference-wake-prompt.test.ts @@ -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`"); }); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index eca074e2923..6ccab880e41 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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 }; }