fix(heartbeat): deliver final handoff after progress promises

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-04 09:15:25 -06:00
1 parent 34d8e441a4
commit 1c92458033
3 files changed
+61 -1

No files matched your search

+5 -1
View File
@@ -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.
@@ -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,
@@ -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", {