mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
a90304ef8c
commit
b9c8677635
4 files changed
+44
-15
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -192,6 +192,11 @@ function record(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in new issue
Block a user