From 1c92458033d3952b4aaca2fe2e92e774748d781a Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 4 Nov 2026 09:10:27 -0600 Subject: [PATCH] fix(heartbeat): deliver final handoff after progress promises Co-Authored-By: Paperclip --- doc/execution-semantics.md | 6 +++- .../__tests__/heartbeat-run-summary.test.ts | 34 +++++++++++++++++++ server/src/services/heartbeat-run-summary.ts | 22 ++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index dcd06e88c89..bb46e117633 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1338,7 +1338,11 @@ failed or cancelled even when the agent already reported completed work. The final assistant message is the visible task response. Response selection runs after preceding event persistence completes; the completion summary cannot replace an available final answer. Existing fallback and explicit-comment precedence still -apply. Stream closure without a turn terminal is not proof of success. Event +apply. A progress-only promise to investigate does not replace an available final +response backed by an accepted completed semantic result. Substantive explicit +outcomes keep precedence. A yielded result or missing final response does not +cause a second comment. The presentation decision records this selection. +Stream closure without a turn terminal is not proof of success. Event replay uses the existing source receipts and never repeats provider work merely to recover recorded output. diff --git a/server/src/__tests__/heartbeat-run-summary.test.ts b/server/src/__tests__/heartbeat-run-summary.test.ts index c0ed3d6b113..6462241e590 100644 --- a/server/src/__tests__/heartbeat-run-summary.test.ts +++ b/server/src/__tests__/heartbeat-run-summary.test.ts @@ -363,6 +363,40 @@ describe("resolveHeartbeatRunResponse", () => { }, }; + it("delivers the completed handoff after an explicit progress promise", () => { + const progress = "Handling the request to set your personal publishing credential. I will verify which piece is missing (definition vs. value) and direct you to the secure entry surface; personal secret values cannot be entered or read through chat."; + const summary = "Open Settings → Secrets → My secrets. Select the publishing definition and Set my value. Keep the value out of chat."; + const response = resolveHeartbeatRunResponse({ + resultJson: { nativeResult: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary } }, + existingComment: { id: "progress", body: progress }, + }); + expect(response).toMatchObject({ text: summary, decision: { chosenSource: "semantic_result_summary", commentAction: "create", + reasonCodes: ["accepted_semantic_result_summary", "completed_response_after_progress_promise"] } }); + }); + + it.each([ + "The credential is configured and the worker check passed.", + "The worker check failed. Keep the credential unchanged.", + "I will check again later. The current worker check failed.", + "I will verify it later, but the deployment failed with HTTP 403.", + ])("retains a substantive explicit outcome: %s", body => { + expect(resolveHeartbeatRunResponse({ resultJson, existingComment: { id: "explicit", body } })) + .toMatchObject({ text: body, decision: { commentAction: "reuse", commentId: "explicit" } }); + }); + + it("retains a progress promise when no completed response is available or the run yields", () => { + const existingComment = { id: "progress", body: "I will verify the configuration." }; + for (const resultJson of [ + {}, { summary: "Unqualified legacy summary" }, + { nativeResult: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary: "Waiting for an answer" } }, + { nativeResult: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "failed", summary: "The provider failed" } }, + { nativeResult: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: existingComment.body } }, + ]) { + expect(resolveHeartbeatRunResponse({ resultJson, existingComment })) + .toMatchObject({ text: existingComment.body, decision: { commentAction: "reuse" } }); + } + }); + it("applies comment, explicit provider, adapter, terminal fallback, and semantic precedence", () => { const resultWithAdapterFinal = { ...resultJson, diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index 97368cb1da6..1f7b40c481c 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -127,6 +127,17 @@ export function summarizeHeartbeatRunResultJson( const NARRATION_OPENERS = /^(let me\b|i['’]ll\b|i['’]m going\b|i need to\b|i can see\b|now i['’]ll\b|next,? i['’]ll\b|looking at\b|fetching\b|checking\b|first,)/i; +// An acknowledgement followed only by a promised investigation is progress, +// even if it was posted through the ordinary comment tool. Keep multiple +// substantive sentences out of this narrow fallback. +function isProgressPromise(text: string): boolean { + const sentences = text.trim().split(/(?<=[.!?])\s+(?=[A-Z])/); + if (/^Handling (?:the|your) request\b/i.test(sentences[0] ?? "")) sentences.shift(); + return sentences.length === 1 + && /^I(?: will|['’]ll)\s+(?:check|verify|inspect|investigate|review|read|fetch)\b/i.test(sentences[0]!) + && !/\b(?:but|however|failed|succeeded|completed|saved)\b/i.test(text); +} + export const LEGACY_WITHHELD_RUN_COMMENT = "Run completed. Agent did not post a summary comment this run (transcript withheld — see run log)."; @@ -571,6 +582,17 @@ export function resolveHeartbeatRunResponse(input: { const existingText = readCommentText(input.existingComment?.body); if (input.existingComment && existingText) { + if (isProgressPromise(existingText) && semanticResultCandidates(resultJson).some(candidate => + candidate.schema === "paperclip.run_result.v1" && candidate.reportedWorkDisposition === "done" + && readCommentText(candidate.summary)) && !hasYieldedSemanticResult(resultJson)) { + const completed = resolveCompletedUpstreamResponse(); + if (completed && completed.text !== existingText) return { + ...completed, + decision: { ...completed.decision, reasonCodes: [ + ...completed.decision.reasonCodes, "completed_response_after_progress_promise", + ] }, + }; + } return { text: existingText, decision: decision("existing_issue_comment", {