diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 77e620e1900..4d4c9d6523e 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -973,6 +973,48 @@ describe("renderPaperclipWakePrompt", () => { expect(prompt).toContain("- recovery attempt: 2/3"); expect(prompt).toContain("- next action: Restore the execution path."); expect(prompt).not.toContain("Execution contract: take concrete action"); + if (cause === "successful_run_missing_state") { + expect(prompt).not.toContain("Any comment you post on the source issue must be ≤3 lines"); + } else { + expect(prompt).toContain("Record the outcome in the resolve call's `resolutionNote`"); + expect(prompt).toContain("Any comment you post on the source issue must be ≤3 lines"); + expect(prompt).toContain("No headings, no run-by-run narrative."); + } + }); + + it("asks process-loss retries to lead with the work instead of narrating recovery", () => { + const prompt = renderPaperclipWakePrompt({ + reason: "source_scoped_recovery_action", + issue: { id: "issue-1", identifier: "PAP-14092", title: "Recover work", status: "blocked" }, + recovery: { + cause: "process_lost", + failureSummary: "adapter stopped", + originalAssignee: { id: "agent-1", name: "Coder" }, + attemptCount: 1, + nextAction: "Restore the execution path.", + }, + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + comments: [], + fallbackFetchNeeded: false, + }); + + expect(prompt).toContain( + "Do not narrate the recovery in your next comment — at most one short sentence; lead with the work.", + ); + }); + + it("asks restored source owners to lead with work instead of narrating recovery", () => { + const prompt = renderPaperclipWakePrompt({ + reason: "issue_recovery_action_restored", + issue: { id: "issue-1", identifier: "PAP-14092", title: "Continue work", status: "todo" }, + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + comments: [], + fallbackFetchNeeded: false, + }); + + expect(prompt).toContain( + "Do not narrate the recovery in your next comment — at most one short sentence; lead with the work.", + ); }); it("keeps exactly one execution contract in a composed fresh heartbeat prompt", () => { diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 889c2bd5df9..5a3fc36cbb3 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1437,7 +1437,7 @@ export function renderPaperclipWakePrompt( const recoveryInstruction = (() => { switch (recovery?.cause) { case "process_lost": - return `Your previous run on this issue was lost (${recovery.failureSummary ?? "no failure summary available"}). Try again — resume from durable progress; don't redo completed steps.`; + return `Your previous run on this issue was lost (${recovery.failureSummary ?? "no failure summary available"}). Try again — resume from durable progress; don't redo completed steps. Do not narrate the recovery in your next comment — at most one short sentence; lead with the work.`; case "successful_run_missing_state": case "successful_run_missing_issue_disposition": return "Your run completed but left no final disposition. Post a comment summarizing the state and set the correct disposition (`done` / `in_review` / `blocked` / `in_progress` with a live path). Do not start new work."; @@ -1480,6 +1480,10 @@ export function renderPaperclipWakePrompt( ? [ "Recovery contract: your job is to RECOVER this task, not to do the work. Do not produce the deliverable yourself.", `Cause-specific instruction: ${recoveryInstruction}`, + ...(recovery?.cause === "successful_run_missing_state" || + recovery?.cause === "successful_run_missing_issue_disposition" + ? [] + : ["Record the outcome in the resolve call's `resolutionNote`. Any comment you post on the source issue must be ≤3 lines (cause → what you did → hand-back). No headings, no run-by-run narrative."]), `Fallback preference order: (1) send back to ${originalAssigneeLabel} with a retry instruction; (2) fix the runtime/adapter/workspace problem, then send it back; (3) reassign to another agent with the right specialty; (4) convert to an explicit manual-review state for the board.`, "", ] @@ -1511,6 +1515,9 @@ export function renderPaperclipWakePrompt( : []), ] : []), + ...(normalized.reason === "issue_recovery_action_restored" + ? ["- instruction: Do not narrate the recovery in your next comment — at most one short sentence; lead with the work."] + : []), ]; const lines = resumedSession ? [ diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 81ede0f4f8c..42c002ef809 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -235,6 +235,9 @@ export type IssueCommentPresentationKind = (typeof ISSUE_COMMENT_PRESENTATION_KI export const ISSUE_COMMENT_PRESENTATION_TONES = ["neutral", "info", "success", "warning", "danger"] as const; export type IssueCommentPresentationTone = (typeof ISSUE_COMMENT_PRESENTATION_TONES)[number]; +export const ISSUE_COMMENT_PRESENTATION_DENSITIES = ["compact"] as const; +export type IssueCommentPresentationDensity = (typeof ISSUE_COMMENT_PRESENTATION_DENSITIES)[number]; + export const ISSUE_COMMENT_METADATA_ROW_TYPES = [ "text", "code", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6ebfd0c14dc..521616c7bdc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -191,6 +191,7 @@ export { ISSUE_COMMENT_METADATA_ROW_TYPES, ISSUE_COMMENT_PRESENTATION_KINDS, ISSUE_COMMENT_PRESENTATION_TONES, + ISSUE_COMMENT_PRESENTATION_DENSITIES, clampIssueRequestDepth, ISSUE_THREAD_INTERACTION_KINDS, ISSUE_THREAD_INTERACTION_STATUSES, @@ -375,6 +376,7 @@ export { type IssueCommentMetadataRowType, type IssueCommentPresentationKind, type IssueCommentPresentationTone, + type IssueCommentPresentationDensity, type IssueThreadInteractionKind, type IssueThreadInteractionStatus, type IssueThreadInteractionContinuationPolicy, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index e1e5466ae0d..be4bd771d9e 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -3,6 +3,7 @@ import type { IssueCommentMetadataRowType, IssueCommentPresentationKind, IssueCommentPresentationTone, + IssueCommentPresentationDensity, IssueExecutionMonitorClearReason, IssueExecutionMonitorKind, IssueExecutionMonitorRecoveryPolicy, @@ -953,6 +954,7 @@ export interface IssueCommentPresentation { tone: IssueCommentPresentationTone; title?: string | null; detailsDefaultOpen: boolean; + density?: IssueCommentPresentationDensity; } export interface IssueThreadInteractionActorFields { diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 5db3925761c..57650ba0df6 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -216,6 +216,7 @@ describe("issue validators", () => { kind: "system_notice", tone: "warning", title: "Needs disposition", + density: "compact", }, metadata: { version: 1, @@ -234,10 +235,22 @@ describe("issue validators", () => { }); expect(parsed.presentation?.detailsDefaultOpen).toBe(false); + expect(parsed.presentation?.density).toBe("compact"); expect(parsed.metadata?.sourceRunId).toBe("11111111-1111-4111-8111-111111111111"); expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3); }); + it("rejects unknown issue comment presentation densities", () => { + expect(addIssueCommentSchema.safeParse({ + body: "Hidden details", + presentation: { + kind: "system_notice", + tone: "warning", + density: "condensed", + }, + }).success).toBe(false); + }); + it("rejects arbitrary issue comment metadata", () => { const parsed = addIssueCommentSchema.safeParse({ body: "Hidden details", diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index ed935a8b38a..e7a2746ca1b 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -12,6 +12,7 @@ import { ISSUE_COMMENT_METADATA_ROW_TYPES, ISSUE_COMMENT_PRESENTATION_KINDS, ISSUE_COMMENT_PRESENTATION_TONES, + ISSUE_COMMENT_PRESENTATION_DENSITIES, ISSUE_HARNESS_KINDS, ISSUE_MONITOR_SCHEDULED_BY, ISSUE_PRIORITIES, @@ -563,6 +564,7 @@ export const issueCommentPresentationSchema = z.object({ tone: z.enum(ISSUE_COMMENT_PRESENTATION_TONES).default("neutral"), title: z.string().trim().min(1).max(160).nullable().optional(), detailsDefaultOpen: z.boolean().optional().default(false), + density: z.enum(ISSUE_COMMENT_PRESENTATION_DENSITIES).optional(), }).strict(); export type IssueCommentPresentation = z.infer; diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 4f34a4fc665..1ce6c938588 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -2039,6 +2039,20 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments[0]?.body).toContain("recovery issues do not create nested `stranded_issue_recovery` issues"); expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread"); expect(comments[0]?.body).not.toContain("sk-test-recovery-secret"); + expect(comments[0]?.presentation).toMatchObject({ + kind: "system_notice", + tone: "warning", + title: "Recovery: recovery attempt failed — remains blocked", + density: "compact", + }); + expect(comments[0]?.metadata).toMatchObject({ + version: 1, + sections: [expect.objectContaining({ + rows: expect.arrayContaining([ + expect.objectContaining({ type: "key_value", label: "Cause", value: "recovery_issue_failed" }), + ]), + })], + }); await expect(sourceBlockerIssueIds(companyId, sourceIssueId)).resolves.toEqual([issueId]); }); @@ -3134,6 +3148,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { tone: "danger", detailsDefaultOpen: false, }); + expect(comments[0]?.presentation).not.toHaveProperty("density"); expect(comments[0]?.metadata).toMatchObject({ version: 1, sections: expect.arrayContaining([ @@ -3287,6 +3302,24 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments[0]?.body).not.toContain(`${issuePrefix}-12`); // Plain language — the raw machine error code never leaks into the thread. expect(comments[0]?.body).not.toContain("issue_continuation_waiting_on_review"); + expect(comments[0]?.presentation).toMatchObject({ + kind: "system_notice", + tone: "warning", + title: "Recovery: waiting on dependencies — moved to blocked", + density: "compact", + }); + expect(comments[0]?.metadata).toMatchObject({ + version: 1, + sections: [expect.objectContaining({ + rows: expect.arrayContaining([ + expect.objectContaining({ + type: "key_value", + label: "Cause", + value: "continuation_waiting_on_review", + }), + ]), + })], + }); const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId)); expect( @@ -4849,6 +4882,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { runErrorCode: "process_lost", runError: "Authorization: Bearer sk-test-recovery-secret", }); + const longRecoveryOwnerName = "R".repeat(161); + await db.update(agents).set({ name: longRecoveryOwnerName }).where(eq(agents.id, agentId)); const heartbeat = heartbeatService(db); const result = await heartbeat.reconcileStrandedAssignedIssues(); @@ -4875,7 +4910,23 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments[0]?.body).toContain("retried dispatch"); expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread"); expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``); - expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]"); + expect(comments[0]?.body).toContain(`Recovery owner: [${longRecoveryOwnerName}]`); + expect(comments[0]?.presentation).toMatchObject({ + kind: "system_notice", + tone: "warning", + title: `${`Recovery: retries exhausted — moved to blocked (owner: ${longRecoveryOwnerName})`.slice(0, 159)}…`, + density: "compact", + }); + expect(comments[0]?.metadata).toMatchObject({ + version: 1, + sections: [expect.objectContaining({ + rows: expect.arrayContaining([ + expect.objectContaining({ type: "key_value", label: "Recovery action", value: recoveryAction.id }), + expect.objectContaining({ type: "key_value", label: "Cause", value: "process_lost" }), + expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "R".repeat(160) }), + ]), + })], + }); }); it("blocks an already stranded recovery issue without creating a recovery child", async () => { @@ -4953,6 +5004,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments[0]?.body).toContain("recovery issues do not create nested `stranded_issue_recovery` issues"); expect(comments[0]?.body).toContain(`Recovery issue: [${recoveryIssues[0]?.identifier}]`); expect(comments[0]?.body).toContain("Next action:"); + expect(comments[0]?.presentation).toMatchObject({ + kind: "system_notice", + tone: "warning", + title: "Recovery: recovery attempt failed — remains blocked", + density: "compact", + }); + expect(comments[0]?.metadata).toMatchObject({ version: 1 }); }); it("assigns open unassigned blockers back to their creator agent", async () => { diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 3a0b0907480..85bd66d9074 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -1042,6 +1042,87 @@ describe.sequential("issue comment reopen routes", () => { expect(mockIssueService.addComment).not.toHaveBeenCalled(); }); + it("derives compact presentation for comments from source-scoped recovery runs", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("in_progress")); + mockDbSelectWhere.mockImplementation(() => ({ + then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => + Promise.resolve([{ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + contextSnapshot: { + wakeReason: "source_scoped_recovery_action", + recoveryCause: "process_lost", + }, + }]).then(onFulfilled, onRejected), + })); + + const res = await request(await installActor(createApp(), agentActor())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "Recovered the execution path.\nHanded back to the original owner." }); + + expect(res.status).toBe(201); + expect(mockIssueService.addComment).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + "Recovered the execution path.\nHanded back to the original owner.", + { agentId: "22222222-2222-4222-8222-222222222222", userId: undefined, runId: "run-1" }, + expect.objectContaining({ + authorType: "agent", + presentation: { + kind: "system_notice", + tone: "info", + title: "Recovered the execution path.", + detailsDefaultOpen: false, + density: "compact", + }, + }), + ); + }); + + it("leaves normal agent comments without derived presentation", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("in_progress")); + + const res = await request(await installActor(createApp(), agentActor())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "Normal work update." }); + + expect(res.status).toBe(201); + expect(mockIssueService.addComment).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + "Normal work update.", + { agentId: "22222222-2222-4222-8222-222222222222", userId: undefined, runId: "run-1" }, + expect.objectContaining({ presentation: null }), + ); + }); + + it("keeps successful-run missing-state recovery comments fully visible", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue("in_progress")); + mockDbSelectWhere.mockImplementation(() => ({ + then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) => + Promise.resolve([{ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + contextSnapshot: { + wakeReason: "source_scoped_recovery_action", + recoveryCause: "successful_run_missing_state", + }, + }]).then(onFulfilled, onRejected), + })); + + const res = await request(await installActor(createApp(), agentActor())) + .post("/api/issues/11111111-1111-4111-8111-111111111111/comments") + .send({ body: "The run completed; here is the required summary." }); + + expect(res.status).toBe(201); + expect(mockIssueService.addComment).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + "The run completed; here is the required summary.", + { agentId: "22222222-2222-4222-8222-222222222222", userId: undefined, runId: "run-1" }, + expect.objectContaining({ presentation: null }), + ); + }); + it("rejects invalid comment metadata before writing a comment", async () => { const app = await installActor(createApp()); mockIssueService.getById.mockResolvedValue(makeIssue("todo")); diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index 3228d5e55b8..302065e96ed 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -1019,6 +1019,12 @@ describeEmbeddedPostgres("issue recovery actions", () => { comment: "Workspace failed validation.", recoveryCause: "workspace_validation_failed", }); + // Prove dedupe uses the structured recovery-action reference rather than + // depending only on the legacy body marker. + await db + .update(issueComments) + .set({ body: "Workspace recovery was already escalated." }) + .where(eq(issueComments.issueId, sourceIssue.id)); await recovery.escalateStrandedAssignedIssue({ issue: sourceIssue, previousStatus: "in_progress", @@ -1061,7 +1067,22 @@ describeEmbeddedPostgres("issue recovery actions", () => { }); const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceIssue.id)); - expect(comments.filter((comment) => comment.body.includes(`Recovery action: \`${actionRows[0]?.id}\``))).toHaveLength(1); + expect(comments).toHaveLength(1); + expect(comments[0]?.presentation).toMatchObject({ + kind: "system_notice", + tone: "warning", + title: "Recovery: workspace validation failed — moved to blocked (owner: CTO)", + density: "compact", + }); + expect(comments[0]?.metadata).toMatchObject({ + version: 1, + sections: [expect.objectContaining({ + rows: expect.arrayContaining([ + expect.objectContaining({ type: "key_value", label: "Recovery action", value: actionRows[0]?.id }), + expect.objectContaining({ type: "key_value", label: "Cause", value: "workspace_validation_failed" }), + ]), + })], + }); expect(enqueueWakeup).toHaveBeenCalledTimes(2); expect(enqueueWakeup).toHaveBeenCalledWith( expect.any(String), diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index adaaa35aced..f414934ec71 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -88,6 +88,7 @@ import { type IssueWakeDiagnosticWakeRequest, type IssueWakeDiagnosticsResponse, type IssueRelationIssueSummary, + type IssueCommentPresentation, type IssueWatchdogDiscoveryKind, type ProjectWorkspace, type SourceTrustMetadata, @@ -4035,6 +4036,53 @@ export function issueRoutes( return run; } + function readObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; + } + + async function deriveRecoveryCommentPresentation( + req: Request, + companyId: string, + body: string, + ): Promise { + const run = await loadActorRunContext(req, companyId); + if (!run) return null; + + const context = readObject(run.contextSnapshot); + const paperclipWake = readObject(context.paperclipWake); + const recovery = readObject(paperclipWake.recovery); + const wakeReason = typeof context.wakeReason === "string" + ? context.wakeReason + : typeof paperclipWake.reason === "string" + ? paperclipWake.reason + : null; + if (wakeReason !== "source_scoped_recovery_action") return null; + + const recoveryCause = typeof context.recoveryCause === "string" + ? context.recoveryCause + : typeof recovery.cause === "string" + ? recovery.cause + : null; + if ( + recoveryCause === "successful_run_missing_state" || + recoveryCause === "successful_run_missing_issue_disposition" + ) { + return null; + } + + const firstLine = body.split(/\r?\n/, 1)[0]?.trim() || "Recovery update"; + const title = firstLine.length > 160 ? `${firstLine.slice(0, 159)}…` : firstLine; + return { + kind: "system_notice", + tone: "info", + title, + detailsDefaultOpen: false, + density: "compact", + }; + } + async function assertCheapRecoveryIssueAssigneeProfileAllowed( req: Request, res: Response, @@ -9982,6 +10030,8 @@ export function issueRoutes( } const actor = getActorInfo(req); + const commentPresentation = req.body.presentation ?? + await deriveRecoveryCommentPresentation(req, issue.companyId, req.body.body); const reopenRequested = req.body.reopen === true; const resumeRequested = req.body.resume === true; const interruptRequested = req.body.interrupt === true; @@ -10196,7 +10246,7 @@ export function issueRoutes( const sourceTrust = await sourceTrustForActorWrite(currentIssue, actor); const commentOptions = { authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"), - presentation: req.body.presentation ?? null, + presentation: commentPresentation, metadata: req.body.metadata ?? null, sourceTrust, }; @@ -10281,7 +10331,7 @@ export function issueRoutes( runId: actor.runId, }, { authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"), - presentation: req.body.presentation ?? null, + presentation: commentPresentation, metadata: req.body.metadata ?? null, sourceTrust: await sourceTrustForActorWrite(currentIssue, actor), }); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index ed37976de5a..d3af94c6f38 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -5,6 +5,8 @@ import { MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, PROVIDER_QUOTA_MONITOR_SERVICE_NAME, + type IssueCommentMetadata, + type IssueCommentPresentation, type IssueGraphLivenessAutoRecoveryPreview, type IssueGraphLivenessAutoRecoveryPreviewItem, } from "@paperclipai/shared"; @@ -164,6 +166,76 @@ type SuccessfulRunHandoffRecoveryEvidence = { maxHandoffAttempts: number; }; +function compactRecoveryPresentation(title: string): IssueCommentPresentation { + const normalizedTitle = title.trim(); + return { + kind: "system_notice", + tone: "warning", + title: normalizedTitle.length > 160 ? `${normalizedTitle.slice(0, 159)}…` : normalizedTitle, + detailsDefaultOpen: false, + density: "compact", + }; +} + +function recoveryCauseTitle(cause: StrandedRecoveryCause) { + switch (cause) { + case "process_lost": + return "retries exhausted"; + case "codex_output_inactivity_monitor": + return "output-inactivity retry exhausted"; + case "workspace_validation_failed": + return "workspace validation failed"; + case "configuration_incomplete": + return "configuration incomplete"; + case "execution_review_participant_recovery": + return "reviewer recovery failed"; + case "provider_quota": + return "provider quota unavailable"; + case SUCCESSFUL_RUN_MISSING_STATE_REASON: + return "missing disposition recovery failed"; + default: + return "execution path recovery failed"; + } +} + +function recoveryNoticeMetadata(input: { + cause: string; + latestRun: LatestIssueRun; + recoveryActionId?: string | null; + previousStatus: string; + recoveryOwner?: Pick | null; +}): IssueCommentMetadata { + const rows: IssueCommentMetadata["sections"][number]["rows"] = [ + ...(input.recoveryActionId + ? [{ type: "key_value" as const, label: "Recovery action", value: input.recoveryActionId }] + : []), + { type: "key_value", label: "Cause", value: input.cause }, + { type: "key_value", label: "Previous status", value: input.previousStatus }, + ...(input.recoveryOwner + ? [{ + type: "agent_link" as const, + label: "Recovery owner", + agentId: input.recoveryOwner.id, + name: input.recoveryOwner.name.slice(0, 160), + }] + : [{ type: "key_value" as const, label: "Recovery owner", value: "board" }]), + ...(input.latestRun + ? [{ + type: "run_link" as const, + label: "Latest run", + runId: input.latestRun.id, + title: input.latestRun.status, + }] + : []), + ]; + + return { + version: 1, + sourceRunId: input.latestRun?.id ?? null, + sections: [{ title: "Recovery", rows }], + }; +} + function readRecoveryRunErrorFamily(latestRun: LatestIssueRun) { const result = parseObject(latestRun?.resultJson); return readNonEmptyString(result.errorFamily); @@ -3028,6 +3100,29 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) prefix, }), {}, + { + authorType: "system", + presentation: compactRecoveryPresentation("Recovery: recovery attempt failed — remains blocked"), + metadata: { + version: 1, + sourceRunId: input.latestRun?.id ?? null, + sections: [{ + title: "Recovery", + rows: [ + { type: "key_value", label: "Cause", value: "recovery_issue_failed" }, + { type: "key_value", label: "Previous status", value: input.previousStatus }, + ...(input.latestRun + ? [{ + type: "run_link" as const, + label: "Latest run", + runId: input.latestRun.id, + title: input.latestRun.status, + }] + : []), + ], + }], + }, + }, ); await logActivity(db, { @@ -3121,7 +3216,25 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) "(It was paused because the latest run reported it was waiting for review/approval; " + "Paperclip turned that into a normal dependency wait instead of flagging it as stuck.)", {}, - { authorType: "system" }, + { + authorType: "system", + presentation: compactRecoveryPresentation("Recovery: waiting on dependencies — moved to blocked"), + metadata: { + version: 1, + sections: [{ + title: "Recovery", + rows: [ + { type: "key_value", label: "Cause", value: "continuation_waiting_on_review" }, + { type: "key_value", label: "Previous status", value: issue.status }, + { + type: "key_value", + label: "Blocking issues", + value: blockedByIssueIds.join(", ").slice(0, 2000), + }, + ], + }], + }, + }, ); await logActivity(db, { companyId: issue.companyId, @@ -3242,8 +3355,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .orderBy(desc(issueComments.createdAt)) .limit(50) .then((rows) => rows.some((row) => - (row.body ?? "").includes(escalationCommentMarker) || - noticeMetadataReferencesRecoveryAction(row.metadata, recoveryAction.id), + noticeMetadataReferencesRecoveryAction(row.metadata, recoveryAction.id) || + (row.body ?? "").includes(escalationCommentMarker), )); if (!hasEscalationComment) { @@ -3256,6 +3369,17 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) } else { await issuesSvc.addComment(input.issue.id, `${input.comment ?? ""}${recoveryLine}`, {}, { authorType: "system", + presentation: compactRecoveryPresentation( + `Recovery: ${recoveryCauseTitle(recoveryCause)} — moved to blocked ` + + `(owner: ${recoveryOwner?.name ?? "board"})`, + ), + metadata: recoveryNoticeMetadata({ + cause: recoveryCause, + latestRun: input.latestRun, + recoveryActionId: recoveryAction.id, + previousStatus: input.previousStatus, + recoveryOwner, + }), }); } } diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 54cdb577dc0..104abe3402a 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -145,10 +145,13 @@ import { SystemNotice, type SystemNoticeMetadataRow, type SystemNoticeMetadataSection, + type SystemNoticeProps, + type SystemNoticeTone, } from "./SystemNotice"; import { buildSystemNoticeProps, mapCommentMetadataToSystemNoticeSections, + systemNoticeLabelForTone, } from "../lib/system-notice-comment"; import type { IssueCommentMetadata, @@ -2558,6 +2561,82 @@ function StaleDispositionWarningRow({ ); } +// Tone-colored dot for the fully-collapsed compact notice row. Tone is never +// conveyed by color alone — the adjacent title text names the notice. +const COMPACT_TONE_DOT: Record = { + neutral: "bg-muted-foreground/40", + info: "bg-sky-500 dark:bg-sky-400", + success: "bg-emerald-500 dark:bg-emerald-400", + warning: "bg-amber-500 dark:bg-amber-400", + danger: "bg-red-500 dark:bg-red-400", +}; + +// A system notice whose presentation opts into `density: "compact"` collapses to +// a single quiet row — tone dot + title (+ author) + timestamp + chevron. +// Expanding reveals the full SystemNotice card (body + details), so no +// information is lost. Generalized from the StaleDispositionWarningRow precedent. +function CompactSystemNoticeRow({ + anchorId, + message, + tone, + title, + source, + noticeProps, + defaultOpen = false, +}: { + anchorId?: string; + message: ThreadMessage; + tone: SystemNoticeTone; + title: string; + source?: SystemNoticeProps["source"]; + noticeProps: SystemNoticeProps; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + const detailsId = useId(); + + return ( +
+
+ +
+ + +
+
+
+ ); +} + function SystemNoticeCommentRow({ message, anchorId, @@ -2654,6 +2733,24 @@ function SystemNoticeCommentRow({ ); } + // Compact presentation collapses the notice to a single quiet row. Notices + // without `density` (old comments / old data) keep today's full card. + if (presentation?.density === "compact") { + const tone = presentation.tone ?? "neutral"; + const title = systemNoticeLabelForTone(tone, presentation.title); + return ( + + ); + } + return (
diff --git a/ui/src/components/IssueChatThreadSystemNotice.test.tsx b/ui/src/components/IssueChatThreadSystemNotice.test.tsx index 7e44f8c8c20..a3799deccbc 100644 --- a/ui/src/components/IssueChatThreadSystemNotice.test.tsx +++ b/ui/src/components/IssueChatThreadSystemNotice.test.tsx @@ -391,7 +391,9 @@ describe("IssueChatThread system notice routing", () => { expect(sourceLink?.textContent).toBe("Paperclip"); }); - it("keeps agent-authored comments as assistant bubbles even when presentation requests system_notice", () => { + it("routes agent-authored comments to the notice renderer when presentation requests system_notice", () => { + const owner = { id: "agent-1", name: "ClaudeCoder" } as unknown as Agent; + const agentMap = new Map([[owner.id, owner]]); const comment: IssueChatComment = { id: "comment-agent-system", companyId: "company-1", @@ -399,11 +401,166 @@ describe("IssueChatThread system notice routing", () => { authorType: "agent", authorAgentId: "agent-1", authorUserId: null, + runId: "run-owner", + runAgentId: "agent-1", body: "Reassigned to ClaudeFixer.", presentation: { kind: "system_notice", tone: "neutral", - title: null, + title: "Recovery reassignment", + detailsDefaultOpen: false, + }, + metadata: null, + ...baseTimestamps, + }; + + renderThread([comment], { agentMap }); + + // No longer an assistant bubble — it collapses like a system notice. + expect(container.querySelector('[data-message-role="assistant"]')).toBeNull(); + const row = container.querySelector('[data-message-role="system"]'); + expect(row).not.toBeNull(); + const status = row?.querySelector('[role="status"]'); + expect(status?.getAttribute("aria-label")).toBe("Recovery reassignment"); + // The real author's name + run link stay visible on the notice. + const sourceLink = status?.querySelector('a[href^="/agents/"]') as HTMLAnchorElement | null; + expect(sourceLink?.getAttribute("href")).toBe("/agents/agent-1/runs/run-owner"); + expect(sourceLink?.textContent).toBe("ClaudeCoder"); + }); + + it("collapses compact system notices to a single quiet row and expands to the full card", () => { + const comment: IssueChatComment = { + id: "comment-compact", + companyId: "company-1", + issueId: "issue-1", + authorType: "system", + authorAgentId: null, + authorUserId: null, + runId: "run-recovery", + runAgentId: "agent-codex", + body: "Recovery escalated to the CTO after repeated stalls.", + presentation: { + kind: "system_notice", + tone: "warning", + title: "Recovery escalated", + detailsDefaultOpen: false, + density: "compact", + }, + metadata: { + version: 1, + sections: [ + { + title: "Escalation", + rows: [{ type: "agent_link", label: "Owner", agentId: "agent-cto", name: "CTO" }], + }, + ], + }, + ...baseTimestamps, + }; + + renderThread([comment]); + + const row = container.querySelector('[data-testid="compact-system-notice"]'); + expect(row).not.toBeNull(); + // Collapsed: title + timestamp + chevron visible, but body/card hidden. + expect(row?.textContent).toContain("Recovery escalated"); + expect(row?.querySelector('[data-testid="compact-system-notice-time"]')).not.toBeNull(); + expect(row?.querySelector(".lucide-chevron-down")).not.toBeNull(); + const toggle = row?.querySelector("button[aria-expanded]") as HTMLButtonElement; + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + const detailsId = toggle.getAttribute("aria-controls"); + const details = detailsId ? container.ownerDocument.getElementById(detailsId) : null; + expect(details).not.toBeNull(); + expect(details).toHaveProperty("hidden", true); + // The full SystemNotice card lives inside the collapsed region. + expect(details?.querySelector('[role="status"]')).not.toBeNull(); + + act(() => { + toggle.click(); + }); + + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + expect(details).toHaveProperty("hidden", false); + expect(details?.textContent).toContain("Recovery escalated to the CTO"); + }); + + it("renders a compact notice already expanded when detailsDefaultOpen is true", () => { + const comment: IssueChatComment = { + id: "comment-compact-open", + companyId: "company-1", + issueId: "issue-1", + authorType: "system", + authorAgentId: null, + authorUserId: null, + body: "Recovery escalated to the CTO.", + presentation: { + kind: "system_notice", + tone: "danger", + title: "Recovery escalated", + detailsDefaultOpen: true, + density: "compact", + }, + metadata: null, + ...baseTimestamps, + }; + + renderThread([comment]); + + const row = container.querySelector('[data-testid="compact-system-notice"]'); + expect(row).not.toBeNull(); + const toggle = row?.querySelector("button[aria-expanded]") as HTMLButtonElement; + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + const detailsId = toggle.getAttribute("aria-controls"); + const details = detailsId ? container.ownerDocument.getElementById(detailsId) : null; + expect(details).toHaveProperty("hidden", false); + expect(details?.querySelector('[role="status"]')).not.toBeNull(); + expect(details?.textContent).toContain("Recovery escalated to the CTO."); + }); + + it("keeps the author visible on a compact agent-authored notice row", () => { + const owner = { id: "agent-1", name: "ClaudeCoder" } as unknown as Agent; + const agentMap = new Map([[owner.id, owner]]); + const comment: IssueChatComment = { + id: "comment-compact-agent", + companyId: "company-1", + issueId: "issue-1", + authorType: "agent", + authorAgentId: "agent-1", + authorUserId: null, + body: "Picked this back up after the wake.", + presentation: { + kind: "system_notice", + tone: "neutral", + title: "Recovery owner update", + detailsDefaultOpen: false, + density: "compact", + }, + metadata: null, + ...baseTimestamps, + }; + + renderThread([comment], { agentMap }); + + const row = container.querySelector('[data-testid="compact-system-notice"]'); + expect(row).not.toBeNull(); + expect(row?.textContent).toContain("Recovery owner update"); + // Author name shows on the collapsed row itself. + expect(row?.querySelector("button[aria-expanded]")?.textContent).toContain("ClaudeCoder"); + }); + + it("keeps notices without density rendering as the full card (graceful fallback)", () => { + const comment: IssueChatComment = { + id: "comment-no-density", + companyId: "company-1", + issueId: "issue-1", + authorType: "system", + authorAgentId: null, + authorUserId: null, + body: "Recovery completed.", + presentation: { + kind: "system_notice", + tone: "success", + title: "Recovery completed", detailsDefaultOpen: false, }, metadata: null, @@ -412,8 +569,11 @@ describe("IssueChatThread system notice routing", () => { renderThread([comment]); - expect(container.querySelector('[role="status"]')).toBeNull(); - expect(container.querySelector('[data-message-role="assistant"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="compact-system-notice"]')).toBeNull(); + const status = container.querySelector('[role="status"]'); + expect(status?.getAttribute("aria-label")).toBe("Recovery completed"); + // Body is visible immediately on the full card. + expect(status?.textContent).toContain("Recovery completed."); }); it("folds stale successful-run disposition warnings into the activity log disclosure style", () => { diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index 1862b5292ce..15124665308 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -493,11 +493,19 @@ function createCommentMessage(args: { }): ThreadMessage { const { comment, agentMap, currentUserId, userLabelMap, companyId, projectId } = args; const createdAt = toDate(comment.createdAt); - const isSystemNotice = comment.authorType === "system"; + const isSystemAuthor = comment.authorType === "system"; + // Presentation can route a comment to the system-notice renderer even when it + // is agent-authored (e.g. a recovery owner's short status update), letting it + // collapse like a system notice while keeping the real author's name/link. + // Comments without a presentation keep today's routing (graceful fallback for + // old data both directions). + const renderAsSystemNotice = isSystemAuthor || comment.presentation?.kind === "system_notice"; const authorAgentId = effectiveCommentAuthorAgentId(comment); - const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap, { isSystemNotice }); + const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap, { + isSystemNotice: isSystemAuthor, + }); const custom = { - kind: isSystemNotice ? "system_notice" : "comment", + kind: renderAsSystemNotice ? "system_notice" : "comment", commentId: comment.id, anchorId: `comment-${comment.id}`, authorName, @@ -525,7 +533,7 @@ function createCommentMessage(args: { }; const contentText = comment.deletedAt ? "" : comment.body; - if (isSystemNotice) { + if (renderAsSystemNotice) { const message: ThreadSystemMessage = { id: comment.id, role: "system", diff --git a/ui/storybook/stories/chat-comments.stories.tsx b/ui/storybook/stories/chat-comments.stories.tsx index eaf59d196ab..8762c4d1f1b 100644 --- a/ui/storybook/stories/chat-comments.stories.tsx +++ b/ui/storybook/stories/chat-comments.stories.tsx @@ -887,6 +887,109 @@ function IssueThreadNoticeReview() { ); } +// PAP-15871 — compact recovery notices collapse to a single quiet row and +// expand to the full SystemNotice card. `detailsDefaultOpen` seeds the expanded +// state so both states are visible in a static screenshot. +function compactRecoveryComments(expanded: boolean): IssueChatComment[] { + return [ + createComment({ + id: `comment-compact-harness-${expanded ? "open" : "closed"}`, + authorType: "system", + authorAgentId: null, + authorUserId: null, + runId: "run-recovery-source", + runAgentId: codexAgent.id, + body: "Recovery escalated this issue to the CTO after three stalled runs.", + presentation: { + kind: "system_notice", + tone: "warning", + title: "Recovery escalated to CTO", + detailsDefaultOpen: expanded, + density: "compact", + }, + metadata: { + version: 1, + sourceRunId: "run-recovery-source", + sections: [ + { + title: "Escalation", + rows: [ + { type: "agent_link", label: "Owner", agentId: codexAgent.id, name: codexAgent.name }, + { type: "run_link", label: "Last run", runId: "run-recovery-source", title: "process_lost" }, + { type: "key_value", label: "Stalled attempts", value: "3" }, + ], + }, + ], + }, + createdAt: new Date("2026-04-20T14:10:00.000Z"), + }), + createComment({ + id: `comment-compact-agent-${expanded ? "open" : "closed"}`, + authorAgentId: codexAgent.id, + authorUserId: null, + runId: "run-recovery-owner", + runAgentId: codexAgent.id, + body: "Picked this back up after the wake — re-running the failing migration now.", + presentation: { + kind: "system_notice", + tone: "neutral", + title: "Recovery owner update", + detailsDefaultOpen: expanded, + density: "compact", + }, + metadata: null, + createdAt: new Date("2026-04-20T14:12:00.000Z"), + }), + ]; +} + +function CompactRecoveryNoticeReview() { + return ( +
+
+
+
+ {}} + enableLiveTranscriptPolling={false} + showJumpToLatest={false} + /> +
+
+
+
+ {}} + enableLiveTranscriptPolling={false} + showJumpToLatest={false} + /> +
+
+
+
+ ); +} + function ChatCommentsStories() { return (
@@ -961,3 +1064,7 @@ export const IssueChatWithTimeline: Story = { export const IssueThreadNotices: Story = { render: () => , }; + +export const CompactRecoveryNotices: Story = { + render: () => , +};