mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The shared ACP adapter engine records agent failures for operators. > - ACP providers can report a failure category, title, and detailed cause. > - Our patch kept only the category in the saved error, so an operator could not diagnose a failure when tracing was off. > - This pull request preserves redacted provider diagnostics in the run error, transcript, and structured run result. > - Operators can now inspect the provider message and any supplied request ID or stack trace after the run ends. ## Linked Issues or Issue Description Refs #13889 (the diagnostic gap; this PR does not update the bundled Claude version). Refs #14484 (related model-refusal classification; this PR retains diagnostics for all terminal failure categories). **What happened?** An ACP turn failed with only `ACP agent reported a terminal service failure.` The provider's title and details were available in memory but absent from the saved error and transcript. **Expected behavior** The run retains useful provider diagnostics even when raw tracing is disabled. Credentials remain redacted. A size limit must report truncation instead of silently removing the cause. **Steps to reproduce** 1. Run an ACP agent that returns an error-severity typed session failure. 2. Include an HTTP error, request ID, and stack text in its title and details. 3. Inspect the failed run with tracing disabled. Before this change, only the category survives. ## What Changed - Both pinned ACPX patches pass complete error text to the in-memory callback, so redaction happens before truncation. - The shared engine retains the sanitized category, title, and details in `resultJson.terminalSessionFailure` and includes the text in the run error and error transcript. - Diagnostics redact configured environment values even under arbitrary names, unknown launch-environment values, connection URL passwords, run credentials, and common credential syntax. Known boolean settings remain readable, while credential values are redacted even when embedded in other text. Diagnostics remove control characters and invalid Unicode. - Title and detail limits keep escaped transcript JSON below the server's chunk limit. Truncated fields include an omission count. The safe run-result projection preserves a byte-bounded diagnostic preview when the result exceeds its byte budget, with an explicit pointer to the full adapter-bounded run error and transcript. - The existing UI and CLI display the error. Diagnostics do not become assistant output. Issue continuation summaries and session-compaction prompts receive only the generic category, preventing provider text from becoming handoff instructions. Existing quota classification, warnings, timeout precedence, and control-channel failure precedence remain in place. - Regression tests cover real ACP child processes with both pinned versions in one-shot and persistent modes, credential redaction, request IDs after the old 4 KiB cutoff, transcript parsing, storage bounds, and database retrieval of oversized multibyte diagnostics. ## Verification - Full CI on `20ad4f5f1f66c46d2c260e6ad0339cbea607b4cf`: **54 passed, 2 intentionally skipped, no pending or failing checks**. Includes typechecking, build, all Vitest shards, Runner checks, browser E2E, and the canary packaging/public-install dry run. - Greptile: **5/5** on this commit. Superagent security scan passes. All review threads are resolved. - Local verification passed: shared ACP engine suite (395 tests); real Claude ACP child-process and diagnostic regressions across both pinned runtimes and both execution modes; run retrieval and model-handoff regressions (59 tests); ACPX patch packaging (16 tests); full typecheck and build. Affected package typechecks and focused tests were rerun after review fixes. - The broad local `pnpm test:run` was stopped after review edits made its cached imports stale. Fresh targeted runs pass, including both affected server suites. Cold-build import failures were also rerun after dependency builds: chat integration (1,063 tests) and tool access (351 tests) pass. The final commit's complete CI matrix is green. ## Risks - Provider diagnostic text is untrusted. This change retains more of it in company-scoped run records. Redaction and size bounds apply before persistence. - Diagnostics are limited to fields the provider supplies. Old runs cannot recover discarded error text. - No schema migration, recovery-policy change, or new Telemetry or OpenTelemetry export. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, repository inspection, code editing, and test execution. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
622 lines
21 KiB
TypeScript
622 lines
21 KiB
TypeScript
import type {
|
||
RunPresentationDecision,
|
||
RunPresentationSource,
|
||
} from "@paperclipai/shared";
|
||
|
||
export const HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS = 500;
|
||
export const HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS = 4_096;
|
||
export const HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES = 64 * 1024;
|
||
|
||
/** Operator diagnostics are untrusted provider data, not model handoff prose. */
|
||
export function summarizeRunErrorForModel(error: string | null, terminalFailureCategory?: unknown): string | null {
|
||
if (terminalFailureCategory == null) return error;
|
||
const category = typeof terminalFailureCategory === "string"
|
||
&& ["connection", "access", "limit", "service", "request", "unknown"].includes(terminalFailureCategory)
|
||
? terminalFailureCategory
|
||
: "unknown";
|
||
return `ACP agent reported a terminal ${category} failure. Provider diagnostics are available in the run record.`;
|
||
}
|
||
|
||
function truncateSummaryText(
|
||
value: unknown,
|
||
maxLength = HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS,
|
||
) {
|
||
if (typeof value !== "string") return null;
|
||
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
||
}
|
||
|
||
function readNumericField(record: Record<string, unknown>, key: string) {
|
||
return key in record ? (record[key] ?? null) : undefined;
|
||
}
|
||
|
||
function readCommentText(value: unknown) {
|
||
if (typeof value !== "string") return null;
|
||
return value.trim().length > 0 ? value : null;
|
||
}
|
||
|
||
export function mergeHeartbeatRunResultJson(
|
||
resultJson: Record<string, unknown> | null | undefined,
|
||
summary: string | null | undefined,
|
||
): Record<string, unknown> | null {
|
||
const normalizedSummary = readCommentText(summary);
|
||
const baseResult =
|
||
resultJson && typeof resultJson === "object" && !Array.isArray(resultJson)
|
||
? resultJson
|
||
: null;
|
||
|
||
if (!baseResult) {
|
||
return normalizedSummary ? { summary: normalizedSummary } : null;
|
||
}
|
||
|
||
if (!normalizedSummary) {
|
||
return baseResult;
|
||
}
|
||
|
||
if (readCommentText(baseResult.summary)) {
|
||
return baseResult;
|
||
}
|
||
|
||
return {
|
||
...baseResult,
|
||
summary: normalizedSummary,
|
||
};
|
||
}
|
||
|
||
export function summarizeHeartbeatRunResultJson(
|
||
resultJson: Record<string, unknown> | null | undefined,
|
||
): Record<string, unknown> | null {
|
||
if (
|
||
!resultJson ||
|
||
typeof resultJson !== "object" ||
|
||
Array.isArray(resultJson)
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
const summary: Record<string, unknown> = {};
|
||
const textFields = ["summary", "result", "message", "error"] as const;
|
||
for (const key of textFields) {
|
||
const value = truncateSummaryText(resultJson[key]);
|
||
if (value !== null) {
|
||
summary[key] = value;
|
||
}
|
||
}
|
||
|
||
const numericFieldAliases = [
|
||
"total_cost_usd",
|
||
"cost_usd",
|
||
"costUsd",
|
||
] as const;
|
||
for (const key of numericFieldAliases) {
|
||
const value = readNumericField(resultJson, key);
|
||
if (value !== undefined && value !== null) {
|
||
summary[key] = value;
|
||
}
|
||
}
|
||
|
||
for (const key of ["stopReason", "timeoutSource"] as const) {
|
||
const value = readCommentText(resultJson[key]);
|
||
if (value !== null) {
|
||
summary[key] = value;
|
||
}
|
||
}
|
||
|
||
for (const key of ["effectiveTimeoutSec", "effectiveTimeoutMs"] as const) {
|
||
const value = readNumericField(resultJson, key);
|
||
if (value !== undefined && value !== null) {
|
||
summary[key] = value;
|
||
}
|
||
}
|
||
|
||
for (const key of ["timeoutConfigured", "timeoutFired"] as const) {
|
||
if (typeof resultJson[key] === "boolean") {
|
||
summary[key] = resultJson[key];
|
||
}
|
||
}
|
||
|
||
return Object.keys(summary).length > 0 ? summary : null;
|
||
}
|
||
|
||
// An untyped adapter summary can be raw inter-tool narration (assistantTexts
|
||
// concatenated by the adapter), which must never be published verbatim to the
|
||
// board — see BRO-1507 / BRO-1516. Typed final messages and accepted PRP results
|
||
// are semantic output, so they intentionally bypass this legacy safety check.
|
||
// Apostrophes are matched as a character class so both the straight (') and
|
||
// curly (’) forms count — agents emit either. Openers are narration phrases a
|
||
// declarative status summary would not begin with ("Fixed X", "13/13 pass").
|
||
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;
|
||
|
||
export const LEGACY_WITHHELD_RUN_COMMENT =
|
||
"Run completed. Agent did not post a summary comment this run (transcript withheld — see run log).";
|
||
|
||
export const RUN_PRESENTATION_RESOLVER_VERSION = "1";
|
||
export const CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON =
|
||
"allow_chat_run_presentation";
|
||
|
||
export type RunPresentationCommentAction = "reuse" | "create" | "none";
|
||
export type { RunPresentationDecision } from "@paperclipai/shared";
|
||
|
||
export interface ResolvedHeartbeatRunResponse {
|
||
text: string | null;
|
||
decision: RunPresentationDecision;
|
||
}
|
||
|
||
export interface CompletedFinalAgentMessageCandidate {
|
||
seq: number;
|
||
text: string;
|
||
sourceEventId: string | null;
|
||
channel: "final" | "unknown";
|
||
}
|
||
|
||
/**
|
||
* A bounded native retry can exist solely because a completed provider turn
|
||
* omitted its semantic result. In that case the retry is a disposition-only
|
||
* recovery turn: prose it emits must not displace the real final response
|
||
* already produced by the completed work turn. The boundary is protocol
|
||
* state, not a length heuristic or narration regex.
|
||
*/
|
||
export function selectHeartbeatRunFinalAgentMessage(input: {
|
||
candidates: CompletedFinalAgentMessageCandidate[];
|
||
semanticResultRecoveryAfterSeq?: number | null;
|
||
}): (CompletedFinalAgentMessageCandidate & { reasonCode: string }) | null {
|
||
const candidates = [...input.candidates].sort((a, b) => b.seq - a.seq);
|
||
if (candidates.length === 0) return null;
|
||
const boundary = input.semanticResultRecoveryAfterSeq;
|
||
const preRecovery =
|
||
typeof boundary === "number"
|
||
? candidates.filter((candidate) => candidate.seq < boundary)
|
||
: [];
|
||
const eligible = preRecovery.length > 0 ? preRecovery : candidates;
|
||
const selected =
|
||
eligible.find((candidate) => candidate.channel === "final") ??
|
||
eligible.find((candidate) => candidate.channel === "unknown");
|
||
if (!selected) return null;
|
||
const beforeRecovery = preRecovery.includes(selected);
|
||
return {
|
||
...selected,
|
||
reasonCode:
|
||
selected.channel === "final"
|
||
? beforeRecovery
|
||
? "pre_semantic_result_recovery_final_agent_message"
|
||
: "latest_non_empty_completed_final_agent_message"
|
||
: beforeRecovery
|
||
? "pre_semantic_result_recovery_terminal_assistant_message"
|
||
: "latest_non_empty_completed_terminal_assistant_message",
|
||
};
|
||
}
|
||
|
||
function record(value: unknown): Record<string, unknown> {
|
||
return value && typeof value === "object" && !Array.isArray(value)
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
}
|
||
|
||
export function isExternalChatPresentationContext(
|
||
contextSnapshot: unknown,
|
||
verifiedToolReviewChatOrigin = false,
|
||
): boolean {
|
||
const context = record(contextSnapshot);
|
||
const wake = record(context.paperclipWake);
|
||
const source =
|
||
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.startsWith("issue.comment") ||
|
||
source === "issue.update"
|
||
)
|
||
return verifiedToolReviewChatOrigin;
|
||
return (
|
||
source.startsWith("chat:") ||
|
||
context.externalChatContinuation === true ||
|
||
wake.externalInteractionContinuation === true
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Read only completed assistant prose that can participate in final-response
|
||
* presentation. A canonical `final` is authoritative; `unknown` is retained
|
||
* solely for the compatibility fallback selected by the resolver. Other
|
||
* channels and item kinds remain activity, never durable reply candidates.
|
||
*/
|
||
export function readCompletedAssistantMessageCandidate(input: {
|
||
seq: number;
|
||
prpEvent: unknown;
|
||
}): CompletedFinalAgentMessageCandidate | null {
|
||
const prpEvent = record(input.prpEvent);
|
||
const payload = record(prpEvent.payload);
|
||
// `assistant_message` is the shipped PRP v1 spelling used by older replay
|
||
// fixtures and persisted native runs. New providers emit `agentMessage`,
|
||
// but both represent the same canonical assistant item at this boundary.
|
||
if (payload.kind !== "agentMessage" && payload.kind !== "assistant_message") {
|
||
return null;
|
||
}
|
||
if (payload.channel !== "final" && payload.channel !== "unknown") {
|
||
return null;
|
||
}
|
||
const text = readCommentText(payload.text);
|
||
if (!text) return null;
|
||
return {
|
||
seq: input.seq,
|
||
text,
|
||
sourceEventId: readCommentText(prpEvent.sourceEventId),
|
||
channel: payload.channel,
|
||
};
|
||
}
|
||
|
||
function readAcceptedSemanticSummary(resultJson: Record<string, unknown>) {
|
||
const candidates = semanticResultCandidates(resultJson);
|
||
for (const candidate of candidates) {
|
||
if (candidate.schema !== "paperclip.run_result.v1") continue;
|
||
// A yielded result is a control-plane liveness fact, not a final assistant
|
||
// response. Its summary belongs in diagnostics/system state while the
|
||
// durable interaction card remains the user-facing surface.
|
||
if (candidate.reportedWorkDisposition === "yielded") continue;
|
||
const summary = readCommentText(candidate.summary);
|
||
if (summary) return summary;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function semanticResultCandidates(resultJson: Record<string, unknown>) {
|
||
return [
|
||
record(resultJson.nativeResult),
|
||
record(resultJson.acceptedResult),
|
||
record(record(resultJson.semanticResult).result),
|
||
];
|
||
}
|
||
|
||
export function hasAcceptedSemanticResult(
|
||
resultJson: Record<string, unknown> | null | undefined,
|
||
) {
|
||
return semanticResultCandidates(record(resultJson)).some(
|
||
(candidate) => candidate.schema === "paperclip.run_result.v1",
|
||
);
|
||
}
|
||
|
||
function hasYieldedSemanticResult(resultJson: Record<string, unknown>) {
|
||
return semanticResultCandidates(resultJson).some(
|
||
(candidate) =>
|
||
candidate.schema === "paperclip.run_result.v1" &&
|
||
candidate.reportedWorkDisposition === "yielded",
|
||
);
|
||
}
|
||
|
||
function readAcceptedExternalChatResponseWakeSummary(
|
||
resultJson: Record<string, unknown>,
|
||
reviewPresentationAuthorized = false,
|
||
committedResponseAuthorized = false,
|
||
) {
|
||
const nativeResult = record(resultJson.nativeResult);
|
||
const continuation = record(nativeResult.continuation);
|
||
if (
|
||
resultJson.finalizationPhase !== "committed" ||
|
||
(!committedResponseAuthorized &&
|
||
resultJson.finalizationReasonCode !== "external_chat_response_waiting" &&
|
||
!(
|
||
reviewPresentationAuthorized &&
|
||
resultJson.finalizationReasonCode === "governed_response_waiting"
|
||
)) ||
|
||
nativeResult.schema !== "paperclip.run_result.v1" ||
|
||
nativeResult.reportedWorkDisposition !== "yielded" ||
|
||
continuation.kind !== "response_wake" ||
|
||
!readCommentText(continuation.summary) ||
|
||
!readCommentText(continuation.idempotencyKey)
|
||
) {
|
||
return null;
|
||
}
|
||
return readCommentText(nativeResult.summary);
|
||
}
|
||
|
||
export function projectHistoricalHeartbeatRunComment(
|
||
body: string,
|
||
resultJson: Record<string, unknown> | null | undefined,
|
||
) {
|
||
if (body !== LEGACY_WITHHELD_RUN_COMMENT) return body;
|
||
return readAcceptedSemanticSummary(record(resultJson)) ?? body;
|
||
}
|
||
|
||
function readMarkedAdapterFinalResponse(resultJson: Record<string, unknown>) {
|
||
const structured = record(resultJson.finalResponse);
|
||
if (structured.disposition === "final" || structured.final === true) {
|
||
return (
|
||
readCommentText(structured.text) ?? readCommentText(structured.message)
|
||
);
|
||
}
|
||
if (resultJson.finalResponseDisposition === "final") {
|
||
return (
|
||
readCommentText(resultJson.finalResponseText) ??
|
||
readCommentText(resultJson.finalResponse)
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isStructuredSemanticResultText(value: string) {
|
||
try {
|
||
const parsed = JSON.parse(value) as unknown;
|
||
return Boolean(
|
||
parsed &&
|
||
typeof parsed === "object" &&
|
||
!Array.isArray(parsed) &&
|
||
(parsed as Record<string, unknown>).schema === "paperclip.run_result.v1",
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function decision(
|
||
chosenSource: RunPresentationSource,
|
||
input: {
|
||
sourceEventId?: string | null;
|
||
commentAction: RunPresentationCommentAction;
|
||
commentId?: string | null;
|
||
reasonCodes: string[];
|
||
},
|
||
): RunPresentationDecision {
|
||
return {
|
||
schema: "paperclip.run_presentation_decision.v1",
|
||
resolverVersion: RUN_PRESENTATION_RESOLVER_VERSION,
|
||
chosenSource,
|
||
sourceEventId: input.sourceEventId ?? null,
|
||
commentAction: input.commentAction,
|
||
commentId: input.commentId ?? null,
|
||
activityDisposition: "collapse",
|
||
reasonCodes: input.reasonCodes,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolve durable user-facing prose independently from the semantic run status.
|
||
* The returned text is never truncated. Callers may persist only the bounded
|
||
* decision record and materialize the exact text as an issue comment.
|
||
*/
|
||
export function resolveHeartbeatRunResponse(input: {
|
||
resultJson: Record<string, unknown> | null | undefined;
|
||
/** Server-owned conversation finalization, after normal governance checks. */
|
||
conversationTurnFinished?: boolean;
|
||
existingComment?: { id: string; body?: string | null } | null;
|
||
preferFinalResponseOverExistingComment?: boolean;
|
||
externalChatResponseWakeSummaryAuthorized?: boolean;
|
||
/** Server-only proof of the exact accepted response after durable status
|
||
* finalization. Never read this capability from provider/context JSON. */
|
||
externalChatCommittedResponseWakeSummaryAuthorized?: boolean;
|
||
externalChatReviewResponseSummaryAuthorized?: boolean;
|
||
finalAgentMessage?: {
|
||
text: string;
|
||
sourceEventId: string | null;
|
||
channel: "final" | "unknown";
|
||
reasonCode?: string;
|
||
} | null;
|
||
}): ResolvedHeartbeatRunResponse {
|
||
const resultJson = record(input.resultJson);
|
||
const finalAgentText = readCommentText(input.finalAgentMessage?.text);
|
||
const explicitProviderFinal = input.finalAgentMessage?.channel === "final";
|
||
const compatibleTerminalAssistant =
|
||
input.finalAgentMessage?.channel === "unknown";
|
||
const resolveCompletedUpstreamResponse = () => {
|
||
if (
|
||
explicitProviderFinal &&
|
||
finalAgentText &&
|
||
!isStructuredSemanticResultText(finalAgentText)
|
||
) {
|
||
return {
|
||
text: finalAgentText,
|
||
decision: decision("final_agent_message", {
|
||
sourceEventId: input.finalAgentMessage?.sourceEventId,
|
||
commentAction: "create",
|
||
reasonCodes: [
|
||
input.finalAgentMessage?.reasonCode ??
|
||
"latest_non_empty_completed_final_agent_message",
|
||
],
|
||
}),
|
||
} satisfies ResolvedHeartbeatRunResponse;
|
||
}
|
||
|
||
const adapterFinal = readMarkedAdapterFinalResponse(resultJson);
|
||
if (adapterFinal) {
|
||
return {
|
||
text: adapterFinal,
|
||
decision: decision("adapter_final_response", {
|
||
commentAction: "create",
|
||
reasonCodes: ["adapter_output_marked_final"],
|
||
}),
|
||
} satisfies ResolvedHeartbeatRunResponse;
|
||
}
|
||
|
||
if (
|
||
compatibleTerminalAssistant &&
|
||
finalAgentText &&
|
||
!isStructuredSemanticResultText(finalAgentText)
|
||
) {
|
||
return {
|
||
text: finalAgentText,
|
||
decision: decision("final_agent_message", {
|
||
sourceEventId: input.finalAgentMessage?.sourceEventId,
|
||
commentAction: "create",
|
||
reasonCodes: [
|
||
input.finalAgentMessage?.reasonCode ??
|
||
"latest_non_empty_completed_terminal_assistant_message",
|
||
],
|
||
}),
|
||
} satisfies ResolvedHeartbeatRunResponse;
|
||
}
|
||
|
||
const semanticSummary = readAcceptedSemanticSummary(resultJson);
|
||
if (semanticSummary) {
|
||
return {
|
||
text: semanticSummary,
|
||
decision: decision("semantic_result_summary", {
|
||
commentAction: "create",
|
||
reasonCodes: ["accepted_semantic_result_summary"],
|
||
}),
|
||
} satisfies ResolvedHeartbeatRunResponse;
|
||
}
|
||
|
||
const legacyText =
|
||
readCommentText(resultJson.summary) ??
|
||
readCommentText(resultJson.result) ??
|
||
readCommentText(resultJson.message);
|
||
if (legacyText && !NARRATION_OPENERS.test(legacyText.trimStart())) {
|
||
return {
|
||
text: legacyText,
|
||
decision: decision("adapter_final_response", {
|
||
commentAction: "create",
|
||
reasonCodes: ["legacy_adapter_summary_compatibility"],
|
||
}),
|
||
} satisfies ResolvedHeartbeatRunResponse;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
if (input.preferFinalResponseOverExistingComment === true) {
|
||
if (hasYieldedSemanticResult(resultJson)) {
|
||
const responseWakeSummary =
|
||
input.externalChatResponseWakeSummaryAuthorized === true
|
||
? readAcceptedExternalChatResponseWakeSummary(
|
||
resultJson,
|
||
input.externalChatReviewResponseSummaryAuthorized === true,
|
||
input.externalChatCommittedResponseWakeSummaryAuthorized === true,
|
||
)
|
||
: null;
|
||
if (responseWakeSummary) {
|
||
return {
|
||
text: responseWakeSummary,
|
||
decision: decision("semantic_result_summary", {
|
||
commentAction: "create",
|
||
reasonCodes: [
|
||
"accepted_external_chat_response_wake_summary",
|
||
"external_chat_final_precedence",
|
||
],
|
||
}),
|
||
};
|
||
}
|
||
return {
|
||
text: null,
|
||
decision: decision("none", {
|
||
commentAction: "none",
|
||
reasonCodes: ["yielded_control_plane_wait"],
|
||
}),
|
||
};
|
||
}
|
||
const upstream = resolveCompletedUpstreamResponse();
|
||
if (upstream) {
|
||
return {
|
||
...upstream,
|
||
decision: {
|
||
...upstream.decision,
|
||
reasonCodes: [
|
||
...upstream.decision.reasonCodes,
|
||
"external_chat_final_precedence",
|
||
],
|
||
},
|
||
};
|
||
}
|
||
return {
|
||
text: null,
|
||
decision: decision("none", {
|
||
commentAction: "none",
|
||
reasonCodes: ["external_chat_final_response_unavailable"],
|
||
}),
|
||
};
|
||
}
|
||
|
||
const existingText = readCommentText(input.existingComment?.body);
|
||
if (input.existingComment && existingText) {
|
||
return {
|
||
text: existingText,
|
||
decision: decision("existing_issue_comment", {
|
||
commentAction: "reuse",
|
||
commentId: input.existingComment.id,
|
||
reasonCodes: ["explicit_non_progress_comment_precedence"],
|
||
}),
|
||
};
|
||
}
|
||
|
||
// A governed wait is not a completed assistant turn. Provider adapters may
|
||
// still emit terminal-looking prose while the control plane is yielding for
|
||
// an interaction; keep that prose in activity and let the durable
|
||
// interaction own the visible waiting state.
|
||
if (hasYieldedSemanticResult(resultJson) && !input.conversationTurnFinished) {
|
||
return {
|
||
text: null,
|
||
decision: decision("none", {
|
||
commentAction: "none",
|
||
reasonCodes: ["yielded_control_plane_wait"],
|
||
}),
|
||
};
|
||
}
|
||
|
||
const upstream = resolveCompletedUpstreamResponse();
|
||
if (upstream) return upstream;
|
||
|
||
const legacyText =
|
||
readCommentText(resultJson.summary) ??
|
||
readCommentText(resultJson.result) ??
|
||
readCommentText(resultJson.message);
|
||
|
||
return {
|
||
text: null,
|
||
decision: decision("none", {
|
||
commentAction: "none",
|
||
reasonCodes: legacyText
|
||
? ["legacy_adapter_summary_ambiguous"]
|
||
: ["no_user_facing_response"],
|
||
}),
|
||
};
|
||
}
|
||
|
||
export function buildHeartbeatRunIssueComment(
|
||
resultJson: Record<string, unknown> | null | undefined,
|
||
): string | null {
|
||
return resolveHeartbeatRunResponse({ resultJson }).text;
|
||
}
|
||
|
||
export function findHeartbeatRunCompletionComment<T extends { id: string }>(
|
||
comments: T[],
|
||
resultJson: Record<string, unknown> | null | undefined,
|
||
): T | null {
|
||
const receipts = resultJson?.semanticToolReceipts;
|
||
if (!receipts || typeof receipts !== "object" || Array.isArray(receipts)) {
|
||
return comments[0] ?? null;
|
||
}
|
||
|
||
const generatedCommentIds = new Set<string>();
|
||
for (const receipt of Object.values(receipts)) {
|
||
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt))
|
||
continue;
|
||
const receiptRecord = receipt as Record<string, unknown>;
|
||
const result = receiptRecord.result;
|
||
if (!result || typeof result !== "object" || Array.isArray(result))
|
||
continue;
|
||
const receiptResult = result as Record<string, unknown>;
|
||
if (receiptRecord.operationId === "report_progress") {
|
||
const commentId = receiptResult.commentId;
|
||
if (typeof commentId === "string" && commentId.length > 0) {
|
||
generatedCommentIds.add(commentId);
|
||
}
|
||
} else if (
|
||
receiptRecord.operationId === "register_deliverable" &&
|
||
typeof receiptResult.attachmentId === "string" &&
|
||
receiptResult.commandId === `deliverable-prepared:${receiptResult.attachmentId}` &&
|
||
["applied", "duplicate"].includes(String(receiptResult.disposition)) &&
|
||
Array.isArray(receiptResult.entityRefs)
|
||
) {
|
||
// File preparation binds an attachment to a generated comment. That
|
||
// receipt is not the agent's final answer. Use the server-issued entity
|
||
// references, never the comment's wording, to exclude its comment from
|
||
// final-response deduplication. The attachment and binding stay intact.
|
||
for (const ref of receiptResult.entityRefs) {
|
||
if (typeof ref === "string") generatedCommentIds.add(ref);
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
comments.find((comment) => !generatedCommentIds.has(comment.id)) ?? null
|
||
);
|
||
}
|