Add recovery handoff system notices (#5289)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Agent runs can end productively while the source issue still lacks a
durable final disposition.
> - That leaves the control plane unsure whether to resume, escalate, or
close the work.
> - Issue comments also need a presentation contract so system-authored
recovery notices can render as first-class thread messages without
overloading normal comments.
> - This pull request adds successful-run handoff recovery, comment
presentation metadata, and system notice rendering.
> - The benefit is stricter task liveness with clearer operator-facing
recovery state.

## What Changed

- Added successful-run handoff decisions, wake payloads, escalation
behavior, and recovery tests.
- Added issue comment presentation metadata with migration
`0078_white_darwin.sql` and shared/server/company portability support.
- Rendered recovery/system notices in issue chat with dedicated UI
components, fixtures, tests, and storybook/lab coverage.
- Included the current recovery model-profile hint patch so automatic
recovery follow-ups use the cheap profile.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
server/src/services/recovery/successful-run-handoff.test.ts
ui/src/components/SystemNotice.test.tsx
ui/src/lib/system-notice-comment.test.ts
ui/src/components/IssueChatThreadSystemNotice.test.tsx`

## Risks

- Migration-bearing PR: merge this before any other branch that might
later add a migration.
- The branch touches both recovery services and issue-thread rendering,
so review should pay attention to recovery wake idempotency and comment
metadata compatibility.

## Model Used

- OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with
shell/git/GitHub CLI tool use.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-06 06:05:58 -05:00 committed by GitHub
parent 50db8c01d2
commit 454edfe81e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 21919 additions and 125 deletions

View file

@ -65,4 +65,13 @@ describe("activity formatting", () => {
expect(formatIssueActivityAction("issue.monitor_cleared")).toBe("cleared a monitor");
expect(formatIssueActivityAction("issue.monitor_recovery_issue_created")).toBe("created a monitor recovery issue");
});
it("uses plain next-step copy for successful-run handoff activity", () => {
expect(formatActivityVerb("issue.successful_run_handoff_required")).toBe("flagged missing next step on");
expect(formatIssueActivityAction("issue.successful_run_handoff_required")).toBe("Run finished without a clear next step");
expect(formatIssueActivityAction("issue.successful_run_handoff_resolved")).toBe("Next step chosen");
expect(formatIssueActivityAction("issue.successful_run_handoff_escalated")).toBe(
"Run finished without a next step - recovery escalated",
);
});
});

View file

@ -43,6 +43,9 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.monitor_escalated_to_board": "escalated monitor for",
"issue.commented": "commented on",
"issue.deleted": "deleted",
"issue.successful_run_handoff_required": "flagged missing next step on",
"issue.successful_run_handoff_resolved": "recorded next step chosen on",
"issue.successful_run_handoff_escalated": "escalated missing next step on",
"agent.created": "created",
"agent.updated": "updated",
"agent.paused": "paused",
@ -92,6 +95,9 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"issue.monitor_recovery_issue_created": "created a monitor recovery issue",
"issue.monitor_escalated_to_board": "escalated a monitor to the board",
"issue.deleted": "deleted the issue",
"issue.successful_run_handoff_required": "Run finished without a clear next step",
"issue.successful_run_handoff_resolved": "Next step chosen",
"issue.successful_run_handoff_escalated": "Run finished without a next step - recovery escalated",
"agent.created": "created an agent",
"agent.updated": "updated the agent",
"agent.paused": "paused the agent",

View file

@ -39,6 +39,7 @@ function createAgent(id: string, name: string): Agent {
}
function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComment {
const authorAgentId = overrides.authorAgentId ?? null;
return {
id: "comment-1",
companyId: "company-1",
@ -46,6 +47,9 @@ function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComm
authorAgentId: null,
authorUserId: "user-1",
body: "Hello",
authorType: authorAgentId ? "agent" : "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,

View file

@ -305,11 +305,13 @@ function createCommentMessage(args: {
const { comment, agentMap, currentUserId, userLabelMap, companyId, projectId } = args;
const createdAt = toDate(comment.createdAt);
const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap);
const isSystemNotice = comment.authorType === "system";
const custom = {
kind: "comment",
kind: isSystemNotice ? "system_notice" : "comment",
commentId: comment.id,
anchorId: `comment-${comment.id}`,
authorName,
authorType: comment.authorType,
authorAgentId: comment.authorAgentId,
authorUserId: comment.authorUserId,
companyId: companyId ?? comment.companyId,
@ -322,8 +324,21 @@ function createCommentMessage(args: {
queueReason: comment.queueReason ?? null,
interruptedRunId: comment.interruptedRunId ?? null,
followUpRequested: comment.followUpRequested === true,
presentation: comment.presentation ?? null,
commentMetadata: comment.metadata ?? null,
};
if (isSystemNotice) {
const message: ThreadSystemMessage = {
id: comment.id,
role: "system",
createdAt,
content: [{ type: "text", text: comment.body }],
metadata: { custom },
};
return message;
}
if (comment.authorAgentId) {
const message: ThreadAssistantMessage = {
id: comment.id,

View file

@ -83,6 +83,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Second",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@ -97,6 +100,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "First",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@ -140,6 +146,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Original",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
@ -151,6 +160,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Updated",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:05.000Z"),
},
@ -170,6 +182,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
@ -182,6 +197,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@ -192,6 +210,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Middle",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@ -216,6 +237,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Second",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@ -226,6 +250,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "First",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@ -310,6 +337,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
@ -322,6 +352,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@ -334,6 +367,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Brand new",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:04.000Z"),
updatedAt: new Date("2026-03-28T14:00:04.000Z"),
},
@ -354,6 +390,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Newest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:03.000Z"),
updatedAt: new Date("2026-03-28T14:00:03.000Z"),
},
@ -366,6 +405,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Middle",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
@ -376,6 +418,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Oldest",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
@ -827,6 +872,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Follow up after the active run",
authorType: "user" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T16:20:05.000Z"),
updatedAt: new Date("2026-03-28T16:20:05.000Z"),
};
@ -853,6 +901,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Follow up after the active run",
authorType: "user" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T16:20:05.000Z"),
updatedAt: new Date("2026-03-28T16:20:05.000Z"),
};
@ -874,6 +925,9 @@ describe("optimistic issue comments", () => {
authorAgentId: null,
authorUserId: "board-1",
body: "Follow up after the active run",
authorType: "user" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-03-28T16:20:05.000Z"),
updatedAt: new Date("2026-03-28T16:20:05.000Z"),
};

View file

@ -57,9 +57,12 @@ export function createOptimisticIssueComment(params: {
clientId,
companyId: params.companyId,
issueId: params.issueId,
authorType: "user",
authorAgentId: null,
authorUserId: params.authorUserId,
body: params.body,
presentation: null,
metadata: null,
clientStatus: params.clientStatus ?? "pending",
queueTargetRunId: params.queueTargetRunId ?? null,
createdAt: now,

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION,
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION,
isSuccessfulRunHandoffComment,
isSuccessfulRunHandoffEscalationComment,
successfulRunHandoffActivityTone,
} from "./successful-run-handoff";
describe("successful run handoff UI helpers", () => {
it("matches both required and escalated production comments", () => {
expect(isSuccessfulRunHandoffComment(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toBe(true);
expect(isSuccessfulRunHandoffComment("## This issue still needs a next step\n\n- Source run: abc")).toBe(true);
expect(isSuccessfulRunHandoffComment("## Successful run missing issue disposition\n\n- Source run: abc")).toBe(true);
expect(isSuccessfulRunHandoffComment(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY)).toBe(true);
expect(
isSuccessfulRunHandoffComment(
"Paperclip exhausted the bounded successful-run handoff correction for this issue, but it still has no clear next-step disposition.",
),
).toBe(true);
expect(
isSuccessfulRunHandoffEscalationComment(
"Paperclip exhausted the bounded successful-run handoff correction for this issue, but it still has no clear next-step disposition.",
),
).toBe(true);
expect(isSuccessfulRunHandoffComment("Ordinary issue comment")).toBe(false);
});
it("returns shared tones for required, escalated, and neutral activity", () => {
expect(successfulRunHandoffActivityTone(SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION).className).toContain("amber");
expect(successfulRunHandoffActivityTone(SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION).className).toContain("red");
expect(successfulRunHandoffActivityTone(SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION).className).toContain("border");
});
});

View file

@ -0,0 +1,90 @@
import type { ActivityEvent, Issue, SuccessfulRunHandoffState } from "@paperclipai/shared";
export const SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION = "issue.successful_run_handoff_required";
export const SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION = "issue.successful_run_handoff_resolved";
export const SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION = "issue.successful_run_handoff_escalated";
export const SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY =
"Paperclip needs a disposition before this issue can continue.";
export const SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY =
"Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.";
export function isSuccessfulRunHandoffActivity(action: string) {
return action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION
|| action === SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION
|| action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION;
}
export function isSuccessfulRunHandoffRequired(issue: Pick<Issue, "successfulRunHandoff">) {
return issue.successfulRunHandoff?.required === true;
}
function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export function successfulRunHandoffFromActivity(event: ActivityEvent): SuccessfulRunHandoffState | null {
if (!isSuccessfulRunHandoffActivity(event.action)) return null;
const details = event.details ?? {};
const state = event.action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION
? "required"
: event.action === SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION
? "resolved"
: "escalated";
return {
state,
required: state === "required",
sourceRunId:
readString(details.sourceRunId)
?? readString(details.source_run_id)
?? readString(details.resumeFromRunId)
?? event.runId
?? null,
correctiveRunId:
readString(details.correctiveRunId)
?? readString(details.corrective_run_id)
?? (state !== "required" ? event.runId : null),
assigneeAgentId:
readString(details.assigneeAgentId)
?? readString(details.agentId)
?? event.agentId
?? null,
detectedProgressSummary:
readString(details.detectedProgressSummary)
?? readString(details.detected_progress_summary)
?? null,
createdAt: event.createdAt,
};
}
export function isSuccessfulRunHandoffComment(text: string) {
const trimmed = text.trim();
return trimmed === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
|| /^##\s+(This issue still needs a next step|Run finished without a next step|Successful run missing issue disposition)/i.test(trimmed)
|| isSuccessfulRunHandoffEscalationComment(trimmed);
}
export function isSuccessfulRunHandoffEscalationComment(text: string) {
const trimmed = text.trim();
return trimmed === SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY
|| /^Paperclip exhausted the bounded successful-run handoff correction\b/i.test(trimmed);
}
export function successfulRunHandoffActivityTone(action: string) {
if (action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION) {
return {
className: "border-red-500/35 bg-red-500/10 text-red-950 dark:text-red-100",
iconClassName: "text-red-600 dark:text-red-300",
};
}
if (action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION) {
return {
className: "border-amber-300/70 bg-amber-50/90 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
iconClassName: "text-amber-600 dark:text-amber-300",
};
}
return {
className: "border-border/60 text-muted-foreground",
iconClassName: "text-muted-foreground",
};
}

View file

@ -0,0 +1,143 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { buildSystemNoticeProps, mapCommentMetadataToSystemNoticeSections } from "./system-notice-comment";
describe("mapCommentMetadataToSystemNoticeSections", () => {
it("maps server metadata row types to SystemNotice rows", () => {
const sections = mapCommentMetadataToSystemNoticeSections(
{
version: 1,
sections: [
{
title: "Required action",
rows: [
{ type: "issue_link", label: "Source issue", issueId: "i1", identifier: "PAP-3440", title: "Recovery" },
{ type: "agent_link", label: "Assignee", agentId: "agent-1", name: "CodexCoder" },
{ type: "key_value", label: "Status before", value: "in_progress" },
{ type: "code", label: "Cause code", code: "missing_disposition" },
{ type: "text", label: "Notes", text: "Pick a disposition." },
{ type: "run_link", label: "Source run", runId: "9cdba892-c7ca-4d93-8604-4843873b127c", title: "succeeded" },
],
},
],
},
{ runAgentId: "agent-1" },
);
expect(sections).toHaveLength(1);
expect(sections[0]?.title).toBe("Required action");
const rows = sections[0]!.rows;
expect(rows).toEqual([
{
kind: "issue",
label: "Source issue",
identifier: "PAP-3440",
href: "/issues/PAP-3440",
title: "Recovery",
},
{ kind: "agent", label: "Assignee", name: "CodexCoder", href: "/agents/agent-1" },
{ kind: "text", label: "Status before", value: "in_progress" },
{ kind: "code", label: "Cause code", value: "missing_disposition" },
{ kind: "text", label: "Notes", value: "Pick a disposition." },
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/agents/agent-1/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
]);
});
it("omits run href when no runAgentId is available", () => {
const sections = mapCommentMetadataToSystemNoticeSections(
{
version: 1,
sections: [
{
rows: [
{ type: "run_link", label: "Run", runId: "abc12345" },
],
},
],
},
{},
);
expect(sections[0]?.rows[0]).toEqual({
kind: "run",
label: "Run",
runId: "abc12345",
href: undefined,
status: undefined,
});
});
it("returns an empty array for null metadata", () => {
expect(mapCommentMetadataToSystemNoticeSections(null)).toEqual([]);
expect(mapCommentMetadataToSystemNoticeSections(undefined)).toEqual([]);
});
});
describe("buildSystemNoticeProps", () => {
it("derives tone, label, and metadata from a system_notice presentation", () => {
const props = buildSystemNoticeProps({
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing disposition",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
title: "Required",
rows: [{ type: "key_value", label: "Status", value: "in_progress" }],
},
],
},
body: "Body text",
runAgentId: "agent-1",
});
expect(props.tone).toBe("warning");
expect(props.label).toBe("Missing disposition");
expect(props.detailsDefaultOpen).toBe(false);
expect(props.metadata?.[0]?.rows[0]).toEqual({
kind: "text",
label: "Status",
value: "in_progress",
});
});
it("falls back to neutral tone with default label when presentation is null", () => {
const props = buildSystemNoticeProps({
presentation: null,
metadata: null,
body: "Hello",
});
expect(props.tone).toBe("neutral");
expect(props.label).toBe("System notice");
expect(props.metadata).toBeUndefined();
});
it("uses the danger default label when presentation lacks a title", () => {
const props = buildSystemNoticeProps({
presentation: {
kind: "system_notice",
tone: "danger",
title: null,
detailsDefaultOpen: true,
},
metadata: null,
body: "boom",
});
expect(props.label).toBe("System alert");
expect(props.detailsDefaultOpen).toBe(true);
});
});

View file

@ -0,0 +1,125 @@
import type {
IssueCommentMetadata,
IssueCommentMetadataRow,
IssueCommentPresentation,
} from "@paperclipai/shared";
import type {
SystemNoticeMetadataRow,
SystemNoticeMetadataSection,
SystemNoticeProps,
SystemNoticeTone,
} from "../components/SystemNotice";
const TONE_LABEL: Record<SystemNoticeTone, string> = {
neutral: "System notice",
info: "System notice",
success: "System notice",
warning: "System warning",
danger: "System alert",
};
function metadataRowText(row: { label?: string | null }, fallback: string) {
const label = row.label?.trim();
return label && label.length > 0 ? label : fallback;
}
function mapMetadataRow(
row: IssueCommentMetadataRow,
ctx: { runAgentId?: string | null },
): SystemNoticeMetadataRow | null {
switch (row.type) {
case "text":
return { kind: "text", label: metadataRowText(row, "Detail"), value: row.text };
case "code":
return { kind: "code", label: metadataRowText(row, "Code"), value: row.code };
case "key_value":
return { kind: "text", label: row.label, value: row.value };
case "issue_link": {
const identifier = row.identifier ?? null;
if (!identifier) {
return { kind: "text", label: metadataRowText(row, "Issue"), value: row.title ?? "unknown" };
}
return {
kind: "issue",
label: metadataRowText(row, "Issue"),
identifier,
href: `/issues/${identifier}`,
title: row.title ?? undefined,
};
}
case "agent_link": {
const name = row.name?.trim() || row.agentId.slice(0, 8);
return {
kind: "agent",
label: metadataRowText(row, "Agent"),
name,
href: `/agents/${row.agentId}`,
};
}
case "run_link": {
const runAgentId = ctx.runAgentId ?? null;
const href = runAgentId ? `/agents/${runAgentId}/runs/${row.runId}` : undefined;
return {
kind: "run",
label: metadataRowText(row, "Run"),
runId: row.runId,
href,
status: row.title ?? undefined,
};
}
default:
return null;
}
}
export function mapCommentMetadataToSystemNoticeSections(
metadata: IssueCommentMetadata | null | undefined,
ctx: { runAgentId?: string | null } = {},
): SystemNoticeMetadataSection[] {
if (!metadata || !Array.isArray(metadata.sections)) return [];
return metadata.sections
.map((section) => {
const rows = section.rows
.map((row) => mapMetadataRow(row, ctx))
.filter((r): r is SystemNoticeMetadataRow => r !== null);
if (rows.length === 0) return null;
const out: SystemNoticeMetadataSection = { rows };
if (section.title) out.title = section.title;
return out;
})
.filter((s): s is SystemNoticeMetadataSection => s !== null);
}
export function systemNoticeLabelForTone(
tone: SystemNoticeTone,
presentationTitle?: string | null,
): string {
const trimmed = presentationTitle?.trim();
if (trimmed && trimmed.length > 0) return trimmed;
return TONE_LABEL[tone];
}
export function buildSystemNoticeProps(input: {
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
body: import("react").ReactNode;
timestamp?: string;
source?: SystemNoticeProps["source"];
runAgentId?: string | null;
}): SystemNoticeProps {
const tone: SystemNoticeTone = input.presentation?.tone ?? "neutral";
const label = systemNoticeLabelForTone(tone, input.presentation?.title);
const detailsDefaultOpen = Boolean(input.presentation?.detailsDefaultOpen);
const sections = mapCommentMetadataToSystemNoticeSections(input.metadata, {
runAgentId: input.runAgentId ?? null,
});
return {
tone,
label,
body: input.body,
metadata: sections.length > 0 ? sections : undefined,
detailsDefaultOpen,
timestamp: input.timestamp,
source: input.source,
};
}