mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
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:
parent
50db8c01d2
commit
454edfe81e
70 changed files with 21919 additions and 125 deletions
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
37
ui/src/lib/successful-run-handoff.test.ts
Normal file
37
ui/src/lib/successful-run-handoff.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
90
ui/src/lib/successful-run-handoff.ts
Normal file
90
ui/src/lib/successful-run-handoff.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
143
ui/src/lib/system-notice-comment.test.ts
Normal file
143
ui/src/lib/system-notice-comment.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
125
ui/src/lib/system-notice-comment.ts
Normal file
125
ui/src/lib/system-notice-comment.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue