diff --git a/doc/run-log-events.md b/doc/run-log-events.md index e8b60c76481..906e9fb6da2 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -280,6 +280,13 @@ settlement record. The record does not imply exactly-once emission across controller recovery, and failure to write it is logged without changing the already-settled outcome. +## System notices and final replies + +Automatic comments with a stored `system_notice` presentation, including +workspace-ready notices, remain available as progress information. They do not +occupy the run's final reply slot. Final response selection still preserves +ordinary explicit agent comments and excludes receipt-bound generated progress. + ## ACP terminal failure diagnostics The shared ACP adapter engine preserves typed terminal session failures in the diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index d12661e89be..84b233f21b2 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -575,13 +575,14 @@ export function buildHeartbeatRunIssueComment( return resolveHeartbeatRunResponse({ resultJson }).text; } -export function findHeartbeatRunCompletionComment( +export function findHeartbeatRunCompletionComment( comments: T[], resultJson: Record | null | undefined, ): T | null { + const completionComments = comments.filter(comment => record(comment.presentation).kind !== "system_notice"); const receipts = resultJson?.semanticToolReceipts; if (!receipts || typeof receipts !== "object" || Array.isArray(receipts)) { - return comments[0] ?? null; + return completionComments[0] ?? null; } const generatedCommentIds = new Set(); @@ -616,6 +617,6 @@ export function findHeartbeatRunCompletionComment( } return ( - comments.find((comment) => !generatedCommentIds.has(comment.id)) ?? null + completionComments.find((comment) => !generatedCommentIds.has(comment.id)) ?? null ); } diff --git a/server/src/services/heartbeat/run-comment-selection.test.ts b/server/src/services/heartbeat/run-comment-selection.test.ts new file mode 100644 index 00000000000..05dd2702eea --- /dev/null +++ b/server/src/services/heartbeat/run-comment-selection.test.ts @@ -0,0 +1,48 @@ +import { agents, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, issueComments, issues } from "@paperclipai/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from "./run-lifecycle.js"; +import { createHeartbeatRunPreparation } from "./run-preparation.js"; +import { createHeartbeatRunState } from "./run-state.js"; + +const support = await getEmbeddedPostgresTestSupport(); +describe.skipIf(!support.supported)("persisted run completion comment selection", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-run-comment-selection-"); + db = createDb(database.connectionString); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + + it("retains system notices while selecting only a same-company task reply", async () => { + const [company] = await db.insert(companies).values({ name: "Notice selection", issuePrefix: "NSL" }).returning(); + const [otherCompany] = await db.insert(companies).values({ name: "Other company", issuePrefix: "OTH" }).returning(); + const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Agent", adapterType: "process" }).returning(); + const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Reply Hi", assigneeAgentId: agent.id }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, issueId: issue.id, status: "running" }).returning(); + const [notice] = await db.insert(issueComments).values({ companyId: company.id, issueId: issue.id, + authorAgentId: agent.id, createdByRunId: run.id, body: "## Workspace Ready", + presentation: { kind: "system_notice", tone: "info", title: "Workspace ready", density: "compact", detailsDefaultOpen: false }, + }).returning(); + const dependencies = { + ...createHeartbeatRunState(db), ...createHeartbeatRunPreparation(db), + issuesSvc: { addComment: vi.fn(), listReviewAttention: vi.fn() }, + recovery: { escalateStrandedAssignedIssue: vi.fn() }, + companySkills: { completeTestRunForIssue: vi.fn() }, + budgets: { getInvocationBlock: vi.fn(async () => null) }, + treeControlSvc: { getActivePauseHoldGate: vi.fn(async () => null) }, + enqueueWakeup: vi.fn(), getCurrentUserRedactionOptions: vi.fn(async () => ({ enabled: false })), + getAgentInvokability: vi.fn(async () => ({ invokable: true })), + emitTerminalAgentTaskRun: vi.fn(), budgetHooks: {}, + } satisfies HeartbeatLifecycleDependencies; + const lifecycle = createHeartbeatLifecycle(db, dependencies); + expect(await lifecycle.findRunIssueComment(run.id, company.id, issue.id, {})).toBeNull(); + const [reply] = await db.insert(issueComments).values({ companyId: company.id, issueId: issue.id, + authorAgentId: agent.id, createdByRunId: run.id, body: "Hi", + }).returning(); + expect(await lifecycle.findRunIssueComment(run.id, company.id, issue.id, {})).toMatchObject({ id: reply.id, body: "Hi" }); + expect(await lifecycle.findRunIssueComment(run.id, otherCompany.id, issue.id, {})).toBeNull(); + expect(await db.select().from(issueComments)).toEqual(expect.arrayContaining([expect.objectContaining({ id: notice.id }), expect.objectContaining({ id: reply.id })])); + }); +}); diff --git a/server/src/services/heartbeat/run-lifecycle.ts b/server/src/services/heartbeat/run-lifecycle.ts index b92b8054fbd..b9df9f435e5 100644 --- a/server/src/services/heartbeat/run-lifecycle.ts +++ b/server/src/services/heartbeat/run-lifecycle.ts @@ -2184,6 +2184,7 @@ export function createHeartbeatLifecycle(db: Db, dependencies: HeartbeatLifecycl .select({ id: issueComments.id, body: issueComments.body, + presentation: issueComments.presentation, }) .from(issueComments) .where( diff --git a/server/src/services/heartbeat/system-notice-response.test.ts b/server/src/services/heartbeat/system-notice-response.test.ts new file mode 100644 index 00000000000..5163f6f8810 --- /dev/null +++ b/server/src/services/heartbeat/system-notice-response.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { findHeartbeatRunCompletionComment, resolveHeartbeatRunResponse } from "../heartbeat-run-summary.js"; + +describe("system notices and final run replies", () => { + const notice = { id: "workspace-ready", body: "## Workspace Ready", presentation: { kind: "system_notice" } }; + const final = { id: "final-reply", body: "Hi" }; + + it.each([undefined, {}, { semanticToolReceipts: {} }])("does not use a system notice as an explicit reply with %j", resultJson => { + const existingComment = findHeartbeatRunCompletionComment([notice], resultJson); + expect(existingComment).toBeNull(); + expect(resolveHeartbeatRunResponse({ resultJson, existingComment, + finalAgentMessage: { text: "Hi", channel: "final", sourceEventId: "final-event" }, + })).toMatchObject({ text: "Hi", decision: { commentAction: "create" } }); + }); + + it.each([[notice, final], [final, notice]])("keeps an explicit reply when notices are also present", (...comments) => { + expect(findHeartbeatRunCompletionComment(comments, {})).toEqual(final); + }); + + it("uses the stored presentation type instead of the comment text", () => { + const explicit = { id: "explicit", body: notice.body }; + expect(findHeartbeatRunCompletionComment([explicit], {})).toEqual(explicit); + expect(findHeartbeatRunCompletionComment([{ ...notice, body: "A different generated notice" }], {})).toBeNull(); + }); + + it("still excludes receipt-bound progress alongside a system notice", () => { + const progress = { id: "progress", body: "Working" }; + const resultJson = { semanticToolReceipts: { progress: { operationId: "report_progress", result: { commentId: progress.id } } } }; + expect(findHeartbeatRunCompletionComment([notice, progress], resultJson)).toBeNull(); + expect(findHeartbeatRunCompletionComment([notice, progress, final], resultJson)).toEqual(final); + }); +}); diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index f37b9f4e969..ea95cce91ed 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -986,7 +986,7 @@ export async function repairCommittedNativeChatResponse( }), }); if (!finalAgentMessage) return false; - const comments = await tx.select({ id: issueComments.id, body: issueComments.body }) + const comments = await tx.select({ id: issueComments.id, body: issueComments.body, presentation: issueComments.presentation }) .from(issueComments).where(and( eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId), diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts index ff6807c6bfa..ca8889804ed 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts @@ -1024,6 +1024,12 @@ describe("PaperclipControlPlanePort conformance", () => { .toBeGreaterThan(stored.findIndex((row) => row.eventType === "item.completed")); // A workspace recovery owner can commit before the live heartbeat reaches // presentation. The file-preparation comment must not hide the real reply. + const [workspaceNotice] = await db.insert(issueComments).values({ + companyId: identity.companyId, issueId: taskIssueId, + authorAgentId: identity.agentId, authorType: "agent", createdByRunId: taskRunId, + body: "## Workspace Ready", + presentation: { kind: "system_notice", tone: "info", title: "Workspace ready", density: "compact", detailsDefaultOpen: false }, + }).returning(); const [prepared] = await db.insert(issueComments).values({ companyId: identity.companyId, issueId: taskIssueId, authorAgentId: identity.agentId, authorType: "agent", createdByRunId: taskRunId, @@ -1052,7 +1058,7 @@ describe("PaperclipControlPlanePort conformance", () => { expect(await repair()).toBe(false); expect(await db.select({ body: issueComments.body }).from(issueComments) .where(eq(issueComments.createdByRunId, taskRunId)).orderBy(asc(issueComments.createdAt))) - .toEqual([{ body: prepared!.body }, { body: finalText }]); + .toEqual([{ body: workspaceNotice!.body }, { body: prepared!.body }, { body: finalText }]); await expect(port.completeRun({ result: taskResult, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL,