feat: make recovery updates quieter (#10542)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The recovery subsystem restores work after an agent run stops or loses state > - Recovery notices currently use the same visual weight as normal work comments > - Recovery agents can also post long narratives that obscure the useful hand-off > - The server must identify recovery output because agents cannot set presentation controls > - This pull request adds compact recovery notices, structured action references, and brief recovery prompts > - The benefit is a quieter issue thread that still keeps recovery state inspectable ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: `server/`, `packages/shared`, and `packages/adapter-utils`. **Problem or motivation** Recovery notices and recovery-run comments can dominate an issue thread. Operators must scan routine recovery narration before they find the work hand-off. **Proposed solution** Give routine recovery output a compact system-notice presentation. Derive the presentation on the server so agents cannot hide arbitrary comments. Keep the successful missing-state summary fully visible because that comment is the recovery deliverable. **Alternatives considered** The UI could detect recovery text. That approach is fragile and does not provide structured action references. Agents could also set presentation directly, but that would weaken the current board-only security boundary. **Roadmap alignment** This change refines the completed “Self-healing runs & automatic recovery” and “Enforced Outcomes” roadmap areas. It does not add a competing roadmap capability. **Additional context** The scope covers shared comment validation, server recovery notices, agent-comment derivation, and recovery prompt text. No database migration is needed because presentation data already uses JSON. ## What Changed - Add the `compact` issue-comment presentation density to shared constants, types, and validation. - Give recovery escalation, waiting, and in-place notices compact titles and structured recovery-action metadata. - Use recovery-action metadata for notice deduplication, with the legacy text marker as a compatibility fallback. - Derive compact presentation for comments from recovery-scoped runs while preserving the board-only presentation boundary. - Keep successful missing-state recovery summaries fully visible. - Ask recovery participants to record outcomes in `resolutionNote` and keep source-issue comments brief. - Add shared, route, service, and prompt tests for the new behavior and exceptions. ## Verification - `pnpm -r typecheck` - Focused Vitest coverage: 320 tests passed across shared validators, adapter prompts, issue comments, recovery actions, and heartbeat recovery. - Full server phase: 292 files passed, 3,094 tests passed, and 2 tests skipped. - Full UI phase: 386 files passed and 3,182 tests passed. - `pnpm build` - Known master baseline: `cli/src/__tests__/secrets.test.ts` expects `pass`, but the current implementation returns `warn` when strict secret mode is disabled for Postgres. This branch does not change CLI secrets code. ## Risks - Low migration risk. The presentation column is JSON and needs no database migration. - Recovery-run detection depends on the persisted run context snapshot. - Structured metadata becomes the primary deduplication key. The existing body marker remains as a fallback for older comments. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with `gpt-5.6-sol`. The runtime did not expose the context-window size. The model used agentic reasoning, repository tools, code execution, and test execution. ## 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> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
131d476a7e
commit
b4a7a12985
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<typeof issueCommentPresentationSchema>;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
async function deriveRecoveryCommentPresentation(
|
||||
req: Request,
|
||||
companyId: string,
|
||||
body: string,
|
||||
): Promise<IssueCommentPresentation | null> {
|
||||
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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof agents.$inferSelect, "id" | "name"> | 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SystemNoticeTone, string> = {
|
||||
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 (
|
||||
<div id={anchorId} data-testid="compact-system-notice" className="group">
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<span className="size-6 shrink-0" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={detailsId}
|
||||
className="-mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left transition-colors hover:bg-accent/5"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span
|
||||
className={cn("size-1.5 shrink-0 rounded-full", COMPACT_TONE_DOT[tone])}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate text-sm font-medium text-foreground/80">{title}</span>
|
||||
{source ? (
|
||||
<span className="truncate text-(length:--text-micro) text-muted-foreground">
|
||||
· {source.label}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Trailing meta never shrinks — keeps the timestamp on one line so the
|
||||
collapsed row stays a single quiet line on narrow / mobile widths. */}
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{message.createdAt ? (
|
||||
<span data-testid="compact-system-notice-time" className="whitespace-nowrap text-(length:--text-micro) text-muted-foreground/50">
|
||||
{commentDateLabel(message.createdAt)}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground/40 transition-transform group-hover:text-muted-foreground/70", open && "rotate-180")} />
|
||||
</span>
|
||||
</button>
|
||||
<div id={detailsId} hidden={!open} className="py-1">
|
||||
<SystemNotice {...noticeProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<CompactSystemNoticeRow
|
||||
anchorId={anchorId}
|
||||
message={message}
|
||||
tone={tone}
|
||||
title={title}
|
||||
source={source}
|
||||
noticeProps={props}
|
||||
defaultOpen={Boolean(presentation.detailsDefaultOpen)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id={anchorId} className="group">
|
||||
<div className="py-1">
|
||||
|
|
|
|||
|
|
@ -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<string, Agent>([[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<string, Agent>([[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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner max-w-4xl space-y-6">
|
||||
<Section eyebrow="IssueChatThread" title="Compact recovery notices — collapsed">
|
||||
<div className="rounded-lg border border-border bg-background/70 p-4">
|
||||
<IssueChatThread
|
||||
comments={compactRecoveryComments(false)}
|
||||
timelineEvents={[]}
|
||||
linkedRuns={[]}
|
||||
liveRuns={[]}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
issueStatus="in_progress"
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
showJumpToLatest={false}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
<Section eyebrow="IssueChatThread" title="Compact recovery notices — expanded">
|
||||
<div className="rounded-lg border border-border bg-background/70 p-4">
|
||||
<IssueChatThread
|
||||
comments={compactRecoveryComments(true)}
|
||||
timelineEvents={[]}
|
||||
linkedRuns={[]}
|
||||
liveRuns={[]}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
issueStatus="in_progress"
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
showJumpToLatest={false}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatCommentsStories() {
|
||||
return (
|
||||
<div className="paperclip-story">
|
||||
|
|
@ -961,3 +1064,7 @@ export const IssueChatWithTimeline: Story = {
|
|||
export const IssueThreadNotices: Story = {
|
||||
render: () => <IssueThreadNoticeReview />,
|
||||
};
|
||||
|
||||
export const CompactRecoveryNotices: Story = {
|
||||
render: () => <CompactRecoveryNoticeReview />,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue