mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(heartbeat): deliver final handoff after progress promises
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
34d8e441a4
commit
1c92458033
3 files changed
+61
-1
No files matched your search
@@ -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", {
|
||||
|
||||
Reference in new issue
Block a user