mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
8d9d2f9ddf
commit
af4191f45e
7 files changed
+100
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -575,13 +575,14 @@ export function buildHeartbeatRunIssueComment(
|
||||
return resolveHeartbeatRunResponse({ resultJson }).text;
|
||||
}
|
||||
|
||||
export function findHeartbeatRunCompletionComment<T extends { id: string }>(
|
||||
export function findHeartbeatRunCompletionComment<T extends { id: string; presentation?: unknown }>(
|
||||
comments: T[],
|
||||
resultJson: Record<string, unknown> | 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<string>();
|
||||
@@ -616,6 +617,6 @@ export function findHeartbeatRunCompletionComment<T extends { id: string }>(
|
||||
}
|
||||
|
||||
return (
|
||||
comments.find((comment) => !generatedCommentIds.has(comment.id)) ?? null
|
||||
completionComments.find((comment) => !generatedCommentIds.has(comment.id)) ?? null
|
||||
);
|
||||
}
|
||||
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
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<HeartbeatLifecycleDependencies["getAgentInvokability"]>(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 })]));
|
||||
});
|
||||
});
|
||||
@@ -2184,6 +2184,7 @@ export function createHeartbeatLifecycle(db: Db, dependencies: HeartbeatLifecycl
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
body: issueComments.body,
|
||||
presentation: issueComments.presentation,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user