From b9c8677635d43fe41d56655a24f4478cdc0b0fe9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 20 Oct 2026 21:13:22 -0500 Subject: [PATCH] fix: authorize final answer selection for continued chat work Share the origin-check predicate with the live completion caller. Prove that a real same-agent dispatch selects its final answer over an earlier bookkeeping comment without treating candidacy as publication authority. Co-Authored-By: Paperclip --- .../__tests__/heartbeat-run-summary.test.ts | 14 ++++++++++++++ .../chat-monitor-continuation.test.ts | 17 ++++++++++++++++- server/src/services/heartbeat-run-summary.ts | 19 ++++++++++++------- .../src/services/heartbeat/run-completion.ts | 9 ++------- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/server/src/__tests__/heartbeat-run-summary.test.ts b/server/src/__tests__/heartbeat-run-summary.test.ts index b446d49a509..c47eeef0c98 100644 --- a/server/src/__tests__/heartbeat-run-summary.test.ts +++ b/server/src/__tests__/heartbeat-run-summary.test.ts @@ -7,6 +7,7 @@ import { projectHistoricalHeartbeatRunComment, findHeartbeatRunCompletionComment, isExternalChatPresentationContext, + isChatOriginPublicationCandidate, mergeHeartbeatRunResultJson, readCompletedAssistantMessageCandidate, resolveHeartbeatRunResponse, @@ -61,6 +62,19 @@ describe("scheduled continuation presentation", () => { }); }); +describe("continuation origin verification", () => { + it.each(["native_status_decision", "issue.monitor", "tool_action_review", "issue.comment", "issue.comment.created", "issue.update"]) + ("checks %s without treating candidacy as publication authority", source => { + expect(isChatOriginPublicationCandidate({ source })).toBe(true); + expect(isExternalChatPresentationContext({ source })).toBe(false); + expect(isExternalChatPresentationContext({ source }, true)).toBe(true); + }); + it("keeps internal work internal", () => { + expect(isChatOriginPublicationCandidate({ source: "issue.assigned" })).toBe(false); + expect(isExternalChatPresentationContext({ source: "issue.assigned" }, true)).toBe(false); + }); +}); + describe("selectHeartbeatRunFinalAgentMessage", () => { const substantive = { seq: 80, diff --git a/server/src/services/chat-monitor-continuation.test.ts b/server/src/services/chat-monitor-continuation.test.ts index 973daefb885..aaf0a6c7314 100644 --- a/server/src/services/chat-monitor-continuation.test.ts +++ b/server/src/services/chat-monitor-continuation.test.ts @@ -8,7 +8,7 @@ import { } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js"; import { enqueueChatRunMilestones, resolveChatRunPresentationAuthorizationReason } from "./chat-run-publications.js"; -import { CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON } from "./heartbeat-run-summary.js"; +import { CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON, isChatOriginPublicationCandidate, isExternalChatPresentationContext, resolveHeartbeatRunResponse } from "./heartbeat-run-summary.js"; import { resolveChatOriginPublicationBindings } from "./issues.js"; import { NativeRunCoordinatorStore } from "./native-runtime/native-run-coordinator-store.js"; import { finalizeNativeRun, repairCommittedNativeChatResponse } from "./native-runtime/native-run-finalizer.js"; @@ -196,6 +196,21 @@ if (!support.supported) console.warn(`Monitor publication database unavailable: expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.source.id))).toHaveLength(1); }); + it("selects the continued chat run's completed answer over an earlier bookkeeping comment", async () => { + const f = await continuedTaskFixture(); + const authorization = isChatOriginPublicationCandidate(f.successor.contextSnapshot) + ? await resolveChatRunPresentationAuthorizationReason(db, { companyId: f.company.id, issueId: f.issue.id, runId: f.successor.id }) : null; + const context = isExternalChatPresentationContext(f.successor.contextSnapshot, authorization === CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON); + const answer = "Migration QA is complete. The verified report is available at the attached result."; + const resolved = resolveHeartbeatRunResponse({ resultJson: {}, + existingComment: { id: "earlier-comment", body: "The checkout is ready; QA is underway." }, + finalAgentMessage: { text: answer, channel: "final", sourceEventId: "completed-final" }, + preferFinalResponseOverExistingComment: context, + }); + expect(resolved.text).toBe(answer); + expect(resolved.decision).toMatchObject({ chosenSource: "final_agent_message", commentAction: "create" }); + }); + it.each(["effect", "decision", "wake", "dispatch", "requester", "agent"])("rejects automatic continuation when %s authority is no longer valid", async fault => { const f = await continuedTaskFixture(); if (fault === "effect") await db.update(statusDecisionEffects).set({ targetId: randomUUID() }).where(eq(statusDecisionEffects.companyId, f.company.id)); diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index b1c8885fa48..eec17111280 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -192,6 +192,11 @@ function record(value: unknown): Record { : {}; } +function requiresChatOriginProof(source: string): boolean { + return source === "tool_action_review" || source === "issue.monitor" || source === "native_status_decision" || + source.startsWith("issue.comment") || source === "issue.update"; +} + export function isExternalChatPresentationContext( contextSnapshot: unknown, verifiedToolReviewChatOrigin = false, @@ -202,13 +207,7 @@ export function isExternalChatPresentationContext( typeof context.source === "string" ? context.source.trim() : ""; // Approvals and Board comments also resume ordinary internal tasks. Only // durable source-run or mirrored-comment proof authorizes an external reply. - if ( - source === "tool_action_review" || - source === "issue.monitor" || - source === "native_status_decision" || - source.startsWith("issue.comment") || - source === "issue.update" - ) + if (requiresChatOriginProof(source)) return verifiedToolReviewChatOrigin; return ( source.startsWith("chat:") || @@ -217,6 +216,12 @@ export function isExternalChatPresentationContext( ); } +export function isChatOriginPublicationCandidate(contextSnapshot: unknown): boolean { + const context = record(contextSnapshot); + const source = typeof context.source === "string" ? context.source.trim() : ""; + return requiresChatOriginProof(source) || isExternalChatPresentationContext(contextSnapshot); +} + /** * Read only completed assistant prose that can participate in final-response * presentation. A canonical `final` is authoritative; `unknown` is retained diff --git a/server/src/services/heartbeat/run-completion.ts b/server/src/services/heartbeat/run-completion.ts index a0a98c3f624..7f2b486dfe7 100644 --- a/server/src/services/heartbeat/run-completion.ts +++ b/server/src/services/heartbeat/run-completion.ts @@ -20,7 +20,7 @@ import { HttpError } from "../../errors.js"; import { logger } from "../../middleware/logger.js"; import { normalizeResponsibleUserDenialCode } from "../responsible-user-denial-run-outcomes.js"; import { parseObject } from "../../adapters/utils.js"; -import { CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON, isExternalChatPresentationContext, mergeHeartbeatRunResultJson, resolveHeartbeatRunResponse, type RunPresentationDecision } from "../heartbeat-run-summary.js"; +import { CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON, isChatOriginPublicationCandidate, isExternalChatPresentationContext, mergeHeartbeatRunResultJson, resolveHeartbeatRunResponse, type RunPresentationDecision } from "../heartbeat-run-summary.js"; import { normalizeMaxTurnStopReason } from "../heartbeat-stop-metadata.js"; import { CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE } from "../chat-control-recovery-stop.js"; import { logActivity } from "../activity-log.js"; @@ -789,12 +789,7 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC livenessRun.id, livenessRun.companyId, )); - const externalChatPresentationCandidate = - isExternalChatPresentationContext(livenessRun.contextSnapshot) || - parseObject(livenessRun.contextSnapshot).source === "issue.monitor" || - parseObject(livenessRun.contextSnapshot).source === "tool_action_review" || - String(parseObject(livenessRun.contextSnapshot).source ?? "").startsWith("issue.comment") || - parseObject(livenessRun.contextSnapshot).source === "issue.update"; + const externalChatPresentationCandidate = isChatOriginPublicationCandidate(livenessRun.contextSnapshot); const externalChatPresentationAuthorization = issueId && externalChatPresentationCandidate ? await measureRunPhase("response.authorize", () => resolveChatRunPresentationAuthorizationReason(db, {