From af4191f45e3dcfb129551525808268f677c17ce5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:39:07 -0500 Subject: [PATCH] fix: keep workspace notices from suppressing final replies (#16400) Keep stored system notices visible without letting them occupy the final reply slot. Read presentation metadata in live and recovery selection, and cover explicit replies, progress receipts, company boundaries, and native replay. Co-Authored-By: Paperclip --- doc/run-log-events.md | 7 +++ server/src/services/heartbeat-run-summary.ts | 7 +-- .../heartbeat/run-comment-selection.test.ts | 48 +++++++++++++++++++ .../src/services/heartbeat/run-lifecycle.ts | 1 + .../heartbeat/system-notice-response.test.ts | 32 +++++++++++++ .../native-runtime/native-run-finalizer.ts | 2 +- .../paperclip-control-plane-port.test.ts | 8 +++- 7 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 server/src/services/heartbeat/run-comment-selection.test.ts create mode 100644 server/src/services/heartbeat/system-notice-response.test.ts 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,