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
70 changed files with 21919 additions and 125 deletions
+2
View File
@@ -244,6 +244,7 @@ describe("renderCompanyImportPreview", () => {
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
comments: [],
metadata: null,
},
],
@@ -460,6 +461,7 @@ describe("import selection catalog", () => {
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
comments: [],
metadata: null,
},
],
@@ -0,0 +1,3 @@
ALTER TABLE "issue_comments" ADD COLUMN "author_type" text;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN "presentation" jsonb;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN "metadata" jsonb;
File diff suppressed because it is too large Load Diff
@@ -547,6 +547,13 @@
"when": 1777933347806,
"tag": "0077_unusual_karnak",
"breakpoints": true
},
{
"idx": 78,
"version": "7",
"when": 1778004024976,
"tag": "0078_white_darwin",
"breakpoints": true
}
]
}
+5 -1
View File
@@ -1,4 +1,5 @@
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import type { IssueCommentAuthorType, IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared";
import { pgTable, uuid, text, timestamp, index, jsonb } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
@@ -12,8 +13,11 @@ export const issueComments = pgTable(
issueId: uuid("issue_id").notNull().references(() => issues.id),
authorAgentId: uuid("author_agent_id").references(() => agents.id),
authorUserId: text("author_user_id"),
authorType: text("author_type").$type<IssueCommentAuthorType>(),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
body: text("body").notNull(),
presentation: jsonb("presentation").$type<IssueCommentPresentation | null>(),
metadata: jsonb("metadata").$type<IssueCommentMetadata | null>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
+3
View File
@@ -1260,9 +1260,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
id: randomUUID(),
companyId: parentIssue.companyId,
issueId,
authorType: options?.authorAgentId ? "agent" : "system",
authorAgentId: options?.authorAgentId ?? null,
authorUserId: null,
body,
presentation: null,
metadata: null,
createdAt: now,
updatedAt: now,
};
+19
View File
@@ -148,6 +148,25 @@ export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
export const MAX_ISSUE_REQUEST_DEPTH = 1024;
export const ISSUE_COMMENT_AUTHOR_TYPES = ["user", "agent", "system"] as const;
export type IssueCommentAuthorType = (typeof ISSUE_COMMENT_AUTHOR_TYPES)[number];
export const ISSUE_COMMENT_PRESENTATION_KINDS = ["message", "system_notice"] as const;
export type IssueCommentPresentationKind = (typeof ISSUE_COMMENT_PRESENTATION_KINDS)[number];
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_METADATA_ROW_TYPES = [
"text",
"code",
"key_value",
"issue_link",
"agent_link",
"run_link",
] as const;
export type IssueCommentMetadataRowType = (typeof ISSUE_COMMENT_METADATA_ROW_TYPES)[number];
export function clampIssueRequestDepth(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.min(MAX_ISSUE_REQUEST_DEPTH, Math.max(0, Math.floor(value)));
+26
View File
@@ -20,6 +20,10 @@ export {
INBOX_MINE_ISSUE_STATUS_FILTER,
ISSUE_PRIORITIES,
MAX_ISSUE_REQUEST_DEPTH,
ISSUE_COMMENT_AUTHOR_TYPES,
ISSUE_COMMENT_METADATA_ROW_TYPES,
ISSUE_COMMENT_PRESENTATION_KINDS,
ISSUE_COMMENT_PRESENTATION_TONES,
clampIssueRequestDepth,
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
@@ -130,6 +134,10 @@ export {
type AgentIconName,
type IssueStatus,
type IssuePriority,
type IssueCommentAuthorType,
type IssueCommentMetadataRowType,
type IssueCommentPresentationKind,
type IssueCommentPresentationTone,
type IssueThreadInteractionKind,
type IssueThreadInteractionStatus,
type IssueThreadInteractionContinuationPolicy,
@@ -352,6 +360,8 @@ export type {
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
@@ -366,6 +376,16 @@ export type {
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment,
IssueCommentMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,
IssueCommentMetadataTextRow,
IssueCommentMetadataCodeRow,
IssueCommentMetadataKeyValueRow,
IssueCommentMetadataIssueLinkRow,
IssueCommentMetadataAgentLinkRow,
IssueCommentMetadataRunLinkRow,
IssueCommentPresentation,
IssueThreadInteractionActorFields,
SuggestedTaskDraft,
SuggestTasksPayload,
@@ -475,6 +495,7 @@ export type {
CompanyPortabilityProjectWorkspaceManifestEntry,
CompanyPortabilityIssueRoutineTriggerManifestEntry,
CompanyPortabilityIssueRoutineManifestEntry,
CompanyPortabilityIssueCommentManifestEntry,
CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,
@@ -685,6 +706,11 @@ export {
issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
issueCommentAuthorTypeSchema,
issueCommentPresentationSchema,
issueCommentMetadataRowSchema,
issueCommentMetadataSectionSchema,
issueCommentMetadataSchema,
addIssueCommentSchema,
issueThreadInteractionStatusSchema,
issueThreadInteractionKindSchema,
@@ -1,5 +1,7 @@
import type { AgentEnvConfig } from "./secrets.js";
import type { RoutineVariable } from "./routine.js";
import type { IssueCommentAuthorType } from "../constants.js";
import type { IssueCommentMetadata, IssueCommentPresentation } from "./issue.js";
export interface CompanyPortabilityInclude {
company: boolean;
@@ -94,6 +96,16 @@ export interface CompanyPortabilityIssueRoutineManifestEntry {
triggers: CompanyPortabilityIssueRoutineTriggerManifestEntry[];
}
export interface CompanyPortabilityIssueCommentManifestEntry {
body: string;
authorType: IssueCommentAuthorType;
authorAgentSlug: string | null;
authorUserId: string | null;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
createdAt: string | null;
}
export interface CompanyPortabilityIssueManifestEntry {
slug: string;
identifier: string | null;
@@ -112,6 +124,7 @@ export interface CompanyPortabilityIssueManifestEntry {
billingCode: string | null;
executionWorkspaceSettings: Record<string, unknown> | null;
assigneeAdapterOverrides: Record<string, unknown> | null;
comments: CompanyPortabilityIssueCommentManifestEntry[];
metadata: Record<string, unknown> | null;
}
+13
View File
@@ -140,6 +140,8 @@ export type {
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
@@ -155,6 +157,16 @@ export type {
IssueReviewRequest,
IssueExecutionDecision,
IssueComment,
IssueCommentMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,
IssueCommentMetadataTextRow,
IssueCommentMetadataCodeRow,
IssueCommentMetadataKeyValueRow,
IssueCommentMetadataIssueLinkRow,
IssueCommentMetadataAgentLinkRow,
IssueCommentMetadataRunLinkRow,
IssueCommentPresentation,
IssueThreadInteractionActorFields,
SuggestedTaskDraft,
SuggestTasksPayload,
@@ -296,6 +308,7 @@ export type {
CompanyPortabilityProjectWorkspaceManifestEntry,
CompanyPortabilityIssueRoutineTriggerManifestEntry,
CompanyPortabilityIssueRoutineManifestEntry,
CompanyPortabilityIssueCommentManifestEntry,
CompanyPortabilityIssueManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,
+86
View File
@@ -1,4 +1,8 @@
import type {
IssueCommentAuthorType,
IssueCommentMetadataRowType,
IssueCommentPresentationKind,
IssueCommentPresentationTone,
IssueExecutionMonitorClearReason,
IssueExecutionMonitorKind,
IssueExecutionMonitorRecoveryPolicy,
@@ -162,6 +166,18 @@ export interface IssueProductivityReview {
updatedAt: Date;
}
export type SuccessfulRunHandoffStateKind = "required" | "resolved" | "escalated";
export interface SuccessfulRunHandoffState {
state: SuccessfulRunHandoffStateKind;
required: boolean;
sourceRunId: string | null;
correctiveRunId: string | null;
assigneeAgentId: string | null;
detectedProgressSummary: string | null;
createdAt: Date | string | null;
}
export interface IssueRelation {
id: string;
companyId: string;
@@ -324,6 +340,7 @@ export interface Issue {
blocks?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention;
productivityReview?: IssueProductivityReview | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
relatedWork?: IssueRelatedWorkSummary;
referencedIssueIdentifiers?: string[];
planDocument?: IssueDocument | null;
@@ -346,14 +363,83 @@ export interface IssueComment {
id: string;
companyId: string;
issueId: string;
authorType: IssueCommentAuthorType;
authorAgentId: string | null;
authorUserId: string | null;
body: string;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
followUpRequested?: boolean;
createdAt: Date;
updatedAt: Date;
}
interface IssueCommentMetadataRowBase {
type: IssueCommentMetadataRowType;
label?: string | null;
}
export interface IssueCommentMetadataTextRow extends IssueCommentMetadataRowBase {
type: "text";
text: string;
}
export interface IssueCommentMetadataCodeRow extends IssueCommentMetadataRowBase {
type: "code";
code: string;
language?: string | null;
}
export interface IssueCommentMetadataKeyValueRow extends IssueCommentMetadataRowBase {
type: "key_value";
label: string;
value: string;
}
export interface IssueCommentMetadataIssueLinkRow extends IssueCommentMetadataRowBase {
type: "issue_link";
issueId?: string | null;
identifier?: string | null;
title?: string | null;
}
export interface IssueCommentMetadataAgentLinkRow extends IssueCommentMetadataRowBase {
type: "agent_link";
agentId: string;
name?: string | null;
}
export interface IssueCommentMetadataRunLinkRow extends IssueCommentMetadataRowBase {
type: "run_link";
runId: string;
title?: string | null;
}
export type IssueCommentMetadataRow =
| IssueCommentMetadataTextRow
| IssueCommentMetadataCodeRow
| IssueCommentMetadataKeyValueRow
| IssueCommentMetadataIssueLinkRow
| IssueCommentMetadataAgentLinkRow
| IssueCommentMetadataRunLinkRow;
export interface IssueCommentMetadataSection {
title?: string | null;
rows: IssueCommentMetadataRow[];
}
export interface IssueCommentMetadata {
version: 1;
sections: IssueCommentMetadataSection[];
}
export interface IssueCommentPresentation {
kind: IssueCommentPresentationKind;
tone: IssueCommentPresentationTone;
title?: string | null;
detailsDefaultOpen: boolean;
}
export interface IssueThreadInteractionActorFields {
createdByAgentId?: string | null;
createdByUserId?: string | null;
@@ -1,5 +1,10 @@
import { z } from "zod";
import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "../constants.js";
import {
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
} from "./issue.js";
import { routineVariableSchema } from "./routine.js";
export const portabilityIncludeSchema = z
@@ -131,6 +136,16 @@ export const portabilityIssueRoutineManifestEntrySchema = z.object({
triggers: z.array(portabilityIssueRoutineTriggerManifestEntrySchema).default([]),
});
export const portabilityIssueCommentManifestEntrySchema = z.object({
body: z.string().min(1),
authorType: issueCommentAuthorTypeSchema,
authorAgentSlug: z.string().min(1).nullable(),
authorUserId: z.string().nullable(),
presentation: issueCommentPresentationSchema.nullable(),
metadata: issueCommentMetadataSchema.nullable(),
createdAt: z.string().datetime().nullable(),
});
export const portabilityIssueManifestEntrySchema = z.object({
slug: z.string().min(1),
identifier: z.string().min(1).nullable(),
@@ -149,6 +164,7 @@ export const portabilityIssueManifestEntrySchema = z.object({
billingCode: z.string().nullable(),
executionWorkspaceSettings: z.record(z.unknown()).nullable(),
assigneeAdapterOverrides: z.record(z.unknown()).nullable(),
comments: z.array(portabilityIssueCommentManifestEntrySchema).default([]),
metadata: z.record(z.unknown()).nullable(),
});
+5
View File
@@ -157,6 +157,11 @@ export {
issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
issueCommentAuthorTypeSchema,
issueCommentPresentationSchema,
issueCommentMetadataRowSchema,
issueCommentMetadataSectionSchema,
issueCommentMetadataSchema,
addIssueCommentSchema,
issueThreadInteractionStatusSchema,
issueThreadInteractionKindSchema,
@@ -54,6 +54,46 @@ describe("issue validators", () => {
expect(parsed.body).toBe("Progress update\n\nNext action.");
});
it("accepts structured issue comment presentation and metadata", () => {
const parsed = addIssueCommentSchema.parse({
body: "Paperclip needs a disposition before this issue can continue.",
authorType: "system",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Needs disposition",
},
metadata: {
version: 1,
sections: [
{
title: "Evidence",
rows: [
{ type: "key_value", label: "Cause", value: "successful_run_missing_state" },
{ type: "issue_link", label: "Source issue", identifier: "PAP-3440" },
{ type: "run_link", label: "Run", runId: "11111111-1111-4111-8111-111111111111" },
],
},
],
},
});
expect(parsed.presentation?.detailsDefaultOpen).toBe(false);
expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3);
});
it("rejects arbitrary issue comment metadata", () => {
const parsed = addIssueCommentSchema.safeParse({
body: "Hidden details",
metadata: {
version: 1,
transcript: "raw log dump",
},
});
expect(parsed.success).toBe(false);
});
it("normalizes escaped line breaks in generated task drafts", () => {
const parsed = suggestedTaskDraftSchema.parse({
clientKey: "task-1",
+91
View File
@@ -8,6 +8,10 @@ import {
ISSUE_EXECUTION_POLICY_MODES,
ISSUE_EXECUTION_STAGE_TYPES,
ISSUE_EXECUTION_STATE_STATUSES,
ISSUE_COMMENT_AUTHOR_TYPES,
ISSUE_COMMENT_METADATA_ROW_TYPES,
ISSUE_COMMENT_PRESENTATION_KINDS,
ISSUE_COMMENT_PRESENTATION_TONES,
ISSUE_MONITOR_SCHEDULED_BY,
ISSUE_PRIORITIES,
clampIssueRequestDepth,
@@ -233,8 +237,95 @@ export const checkoutIssueSchema = z.object({
export type CheckoutIssue = z.infer<typeof checkoutIssueSchema>;
const commentMetadataLabelSchema = z.string().trim().min(1).max(120);
const commentMetadataTextSchema = z.string().trim().min(1).max(2000);
export const issueCommentAuthorTypeSchema = z.enum(ISSUE_COMMENT_AUTHOR_TYPES);
export const issueCommentPresentationSchema = z.object({
kind: z.enum(ISSUE_COMMENT_PRESENTATION_KINDS).default("message"),
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),
}).strict();
export type IssueCommentPresentation = z.infer<typeof issueCommentPresentationSchema>;
const issueCommentMetadataBaseRowSchema = z.object({
type: z.enum(ISSUE_COMMENT_METADATA_ROW_TYPES),
label: commentMetadataLabelSchema.nullable().optional(),
});
const issueCommentMetadataTextRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("text"),
text: commentMetadataTextSchema,
}).strict();
const issueCommentMetadataCodeRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("code"),
code: z.string().min(1).max(4000),
language: z.string().trim().min(1).max(40).nullable().optional(),
}).strict();
const issueCommentMetadataKeyValueRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("key_value"),
label: commentMetadataLabelSchema,
value: commentMetadataTextSchema,
}).strict();
const issueCommentMetadataIssueLinkRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("issue_link"),
issueId: z.string().uuid().nullable().optional(),
identifier: z.string().trim().min(1).max(80).nullable().optional(),
title: z.string().trim().min(1).max(240).nullable().optional(),
}).strict();
const issueCommentMetadataAgentLinkRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("agent_link"),
agentId: z.string().uuid(),
name: z.string().trim().min(1).max(160).nullable().optional(),
}).strict();
const issueCommentMetadataRunLinkRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("run_link"),
runId: z.string().uuid(),
title: z.string().trim().min(1).max(160).nullable().optional(),
}).strict();
export const issueCommentMetadataRowSchema = z.discriminatedUnion("type", [
issueCommentMetadataTextRowSchema,
issueCommentMetadataCodeRowSchema,
issueCommentMetadataKeyValueRowSchema,
issueCommentMetadataIssueLinkRowSchema,
issueCommentMetadataAgentLinkRowSchema,
issueCommentMetadataRunLinkRowSchema,
]).superRefine((value, ctx) => {
if (value.type === "issue_link" && !value.issueId && !value.identifier) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Issue link rows require issueId or identifier",
path: ["issueId"],
});
}
});
export const issueCommentMetadataSectionSchema = z.object({
title: z.string().trim().min(1).max(160).nullable().optional(),
rows: z.array(issueCommentMetadataRowSchema).min(1).max(50),
}).strict();
export const issueCommentMetadataSchema = z.object({
version: z.literal(1),
sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20),
}).strict();
export type IssueCommentMetadata = z.infer<typeof issueCommentMetadataSchema>;
export const addIssueCommentSchema = z.object({
body: multilineTextSchema.pipe(z.string().min(1)),
authorType: issueCommentAuthorTypeSchema.optional(),
presentation: issueCommentPresentationSchema.nullable().optional(),
metadata: issueCommentMetadataSchema.nullable().optional(),
reopen: z.boolean().optional(),
resume: z.boolean().optional(),
interrupt: z.boolean().optional(),
@@ -35,9 +35,11 @@ const projectSvc = {
const issueSvc = {
list: vi.fn(),
listComments: vi.fn(),
getById: vi.fn(),
getByIdentifier: vi.fn(),
create: vi.fn(),
addComment: vi.fn(),
};
const routineSvc = {
@@ -131,6 +133,14 @@ describe("company portability", () => {
config,
secretKeys: new Set<string>(),
}));
issueSvc.listComments.mockResolvedValue([]);
issueSvc.addComment.mockResolvedValue({
id: "comment-imported",
body: "Imported comment",
authorType: "system",
presentation: null,
metadata: null,
});
companySvc.getById.mockResolvedValue({
id: "company-1",
name: "Paperclip",
@@ -2333,6 +2343,103 @@ describe("company portability", () => {
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
it("does not silently add local adapter permission bypasses on import", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "agent-created",
name: String(input.name),
adapterType: input.adapterType,
adapterConfig: input.adapterConfig,
}));
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterType: "claude_local",
adapterConfig: expect.not.objectContaining({
dangerouslySkipPermissions: expect.anything(),
}),
}));
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
adapterConfig: {
extraArgs: [],
args: ["--legacy-arg"],
},
},
},
}, "user-1");
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({
adapterType: "codex_local",
adapterConfig: expect.objectContaining({
extraArgs: ["--skip-git-repo-check"],
args: ["--legacy-arg"],
}),
}));
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({
adapterConfig: expect.not.objectContaining({
dangerouslyBypassApprovalsAndSandbox: expect.anything(),
dangerouslyBypassSandbox: expect.anything(),
}),
}));
});
it("preserves issue labelIds through export and import round-trip", async () => {
const portability = companyPortabilityService({} as any);
@@ -2399,6 +2506,85 @@ describe("company portability", () => {
);
});
it("preserves issue comment presentation fields through export and import", async () => {
const portability = companyPortabilityService({} as any);
const presentation = { kind: "system_notice", tone: "warning", detailsDefaultOpen: false };
const metadata = {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
};
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Needs disposition",
description: "System notice source",
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "high",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Paperclip needs a disposition before this issue can continue.",
presentation,
metadata,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("comments:");
expect(extension).toContain("system_notice");
expect(extension).toContain("successful_run_missing_state");
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Needs disposition" });
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(issueSvc.addComment).toHaveBeenCalledWith(
"issue-imported",
"Paperclip needs a disposition before this issue can continue.",
{ agentId: undefined, userId: undefined },
{
authorType: "system",
presentation,
metadata,
createdAt: "2026-05-04T12:00:00.000Z",
},
);
});
it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => {
const portability = companyPortabilityService({} as any);
@@ -208,6 +208,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
expect(evaluations[0]).toMatchObject({
priority: "medium",
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
originId: runId,
originFingerprint: `stale_active_run:${companyId}:${runId}`,
});
@@ -13,6 +13,7 @@ import {
issues,
} from "@paperclipai/db";
import { heartbeatService } from "../services/heartbeat.ts";
import { SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY } from "../services/recovery/index.ts";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts";
async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs = 10_000, intervalMs = 50) {
@@ -543,8 +544,24 @@ describe("heartbeat comment wake batching", () => {
.values({
companyId,
issueId,
authorType: "user",
authorUserId: "user-1",
body: "Queued follow-up",
presentation: {
kind: "system_notice",
tone: "warning",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
rows: [
{ type: "key_value", label: "Cause", value: "successful_run_missing_state" },
],
},
],
},
})
.returning()
.then((rows) => rows[0]);
@@ -577,7 +594,15 @@ describe("heartbeat comment wake batching", () => {
comments: [
expect.objectContaining({
id: queuedComment.id,
authorType: "user",
body: "Queued follow-up",
presentation: expect.objectContaining({
kind: "system_notice",
tone: "warning",
}),
metadata: expect.objectContaining({
version: 1,
}),
}),
],
commentWindow: {
@@ -1130,6 +1155,7 @@ describe("heartbeat comment wake batching", () => {
expect(payloads).toHaveLength(2);
expect(runs[1]?.contextSnapshot).toMatchObject({
retryReason: "missing_issue_comment",
modelProfile: "cheap",
});
} finally {
gateway.releaseFirstWait();
@@ -1329,8 +1355,9 @@ describe("heartbeat comment wake batching", () => {
eq(agentWakeupRequests.agentId, primaryAgentId),
eq(agentWakeupRequests.reason, "missing_issue_comment"),
),
);
);
expect(missingCommentRetries).toHaveLength(1);
expect(missingCommentRetries[0]?.payload).toMatchObject({ modelProfile: "cheap" });
} finally {
gateway.releaseFirstWait();
await gateway.close();
@@ -1566,7 +1593,8 @@ describe("heartbeat comment wake batching", () => {
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
return runs.length === 1 && runs[0]?.status === "succeeded" && runs[0]?.issueCommentStatus === "satisfied";
const sourceRun = runs.find((run) => run.id === firstRun?.id);
return sourceRun?.status === "succeeded" && sourceRun.issueCommentStatus === "satisfied";
});
const runs = await db
@@ -1574,9 +1602,26 @@ describe("heartbeat comment wake batching", () => {
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
expect(runs[0]?.issueCommentStatus).toBe("satisfied");
expect(runs[0]?.issueCommentSatisfiedByCommentId).not.toBeNull();
const sourceRun = runs.find((run) => run.id === firstRun?.id);
expect(sourceRun?.issueCommentStatus).toBe("satisfied");
expect(sourceRun?.issueCommentSatisfiedByCommentId).not.toBeNull();
await waitFor(async () => {
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId));
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
const hasHandoffComment = comments.some((comment) =>
comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
);
const hasHandoffWake = wakeups.some((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return hasHandoffComment && hasHandoffWake;
});
const comments = await db
.select()
@@ -1584,16 +1629,19 @@ describe("heartbeat comment wake batching", () => {
.where(eq(issueComments.issueId, issueId))
.orderBy(asc(issueComments.createdAt));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toBe("Manual completion comment from the run.");
expect(comments[0]?.createdByRunId).toBe(firstRun?.id);
expect(comments.some((comment) => comment.body === "Manual completion comment from the run.")).toBe(true);
expect(comments.some((comment) =>
comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
)).toBe(true);
expect(comments.every((comment) => !comment.body.startsWith("## Run summary"))).toBe(true);
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
expect(wakeups).toHaveLength(1);
expect(wakeups.some((wakeup) => wakeup.reason === "missing_issue_comment")).toBe(false);
expect(wakeups.some((wakeup) => wakeup.reason === "finish_successful_run_handoff")).toBe(true);
} finally {
gateway.releaseFirstWait();
await gateway.close();
@@ -280,6 +280,23 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
unresolvedBlockerIssueIds: [blockerId],
});
let finishReadyRun!: () => void;
const readyRunCanFinish = new Promise<void>((resolve) => {
finishReadyRun = resolve;
});
mockAdapterExecute.mockImplementationOnce(async () => {
await readyRunCanFinish;
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Ready dependency scheduling run complete.",
provider: "test",
model: "test-model",
};
});
const readyWake = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
@@ -288,6 +305,15 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: readyIssueId, wakeReason: "issue_assigned" },
});
expect(readyWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: readyIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: readyWake!.id,
body: "Ready dependency scheduling run complete.",
});
finishReadyRun();
await waitForCondition(async () => {
const run = await db
@@ -354,6 +380,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
expect(promotedBlockedRun?.status).toBe("succeeded");
expect(blockedWakeRequestCount).toBeGreaterThanOrEqual(2);
const noActiveRuns = await waitForCondition(async () => {
const rows = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns);
return rows.every((run) => run.status !== "queued" && run.status !== "running");
}, 10_000);
expect(noActiveRuns).toBe(true);
});
it("honors maxConcurrentRuns 1 by leaving a second assignment wake queued", async () => {
@@ -429,6 +463,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: firstIssueId, wakeReason: "issue_assigned" },
});
expect(firstWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: firstIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: firstWake!.id,
body: "First assignment run completed.",
});
const firstRunStarted = await waitForCondition(async () => {
const run = await db
@@ -439,7 +481,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
return run?.status === "running";
});
expect(firstRunStarted).toBe(true);
const firstAdapterStarted = await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1);
const firstAdapterStarted = await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1, 30_000);
expect(firstAdapterStarted).toBe(true);
const secondWake = await heartbeat.wakeup(agentId, {
@@ -450,6 +492,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: secondIssueId, wakeReason: "issue_assigned" },
});
expect(secondWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: secondIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: secondWake!.id,
body: "Second assignment run completed.",
});
const secondRunWhileFirstRunning = await db
.select({ status: heartbeatRuns.status })
@@ -470,11 +520,11 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
return run?.status === "succeeded";
});
expect(secondRunSucceeded).toBe(true);
expect(mockAdapterExecute).toHaveBeenCalledTimes(2);
expect(mockAdapterExecute.mock.calls.length).toBeGreaterThanOrEqual(2);
} finally {
finishFirstRun();
}
});
}, 40_000);
it("cancels stale queued runs when issue blockers are still unresolved", async () => {
const companyId = randomUUID();
@@ -598,6 +648,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
.update(agentWakeupRequests)
.set({ runId: readyRunId })
.where(eq(agentWakeupRequests.id, readyWakeupRequestId));
await db.insert(issueComments).values({
companyId,
issueId: readyIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: readyRunId,
body: "Ready queued run completed.",
});
await db
.update(issues)
.set({
@@ -665,7 +723,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
executionLockedAt: null,
});
expect(readyRun?.status).toBe("succeeded");
expect(mockAdapterExecute).toHaveBeenCalledTimes(1);
expect(mockAdapterExecute).toHaveBeenCalledTimes(2);
});
it("suppresses normal wakeups while allowing comment interaction wakes under a pause hold", async () => {
@@ -320,6 +320,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(escalations[0]).toMatchObject({
parentId: blockerIssueId,
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
status: expect.stringMatching(/^(todo|in_progress|done)$/),
originFingerprint: [
"harness_liveness_leaf",
@@ -568,6 +569,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
executionWorkspaceId: null,
executionWorkspacePreference: null,
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
});
@@ -23,6 +23,7 @@ import {
issueRelations,
issueTreeHoldMembers,
issueTreeHolds,
issueWorkProducts,
issues,
} from "@paperclipai/db";
import {
@@ -69,7 +70,15 @@ vi.mock("../adapters/index.ts", async () => {
};
});
import { heartbeatService } from "../services/heartbeat.ts";
import {
heartbeatService,
redactDetectedSuccessfulRunProgressSummaryForBoard,
} from "../services/heartbeat.ts";
import {
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
} from "../services/recovery/index.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -313,6 +322,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await db.delete(costEvents);
await db.delete(environmentLeases);
await db.delete(environments);
await db.delete(issueWorkProducts);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
@@ -709,6 +719,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
originId: input.issueId,
originRunId: input.runId,
priority: "medium",
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
expect(recovery.title).toContain("Recover stalled issue");
expect(recovery.description).toContain(`Previous source status: \`${input.previousStatus}\``);
@@ -743,6 +754,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
companyId: input.companyId,
reason: "issue_assigned",
source: "assignment",
payload: expect.objectContaining({ modelProfile: "cheap" }),
});
const recoveryRun = recoveryWakeup?.runId
@@ -758,6 +770,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
source: "stranded_issue_recovery",
sourceIssueId: input.issueId,
strandedRunId: input.runId,
modelProfile: "cheap",
});
return recovery;
@@ -915,6 +928,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(retryRun?.status).toBe("queued");
expect(retryRun?.retryOfRunId).toBe(runId);
expect(retryRun?.processLossRetryCount).toBe(1);
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
const issue = await db
.select()
@@ -1227,7 +1241,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect((failedRun?.resultJson as Record<string, unknown> | null)?.errorFamily).toBe("transient_upstream");
expect(retryRun?.status).toBe("scheduled_retry");
expect(retryRun?.scheduledRetryReason).toBe("transient_failure");
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.codexTransientFallbackMode).toBe("same_session");
expect(retryRun?.contextSnapshot).toMatchObject({
codexTransientFallbackMode: "same_session",
modelProfile: "cheap",
});
const issue = await db
.select()
@@ -1241,6 +1258,448 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments).toHaveLength(0);
});
it("queues one finish-handoff wake when a successful run leaves in-progress work without a next action", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Implemented the backend detector, but did not choose a final issue state.",
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Implemented the backend detector, but did not choose a final issue state.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(1);
expect(handoffWakeups[0]?.idempotencyKey).toBe(`finish_successful_run_handoff:${issueId}:${runId}:1`);
expect(handoffWakeups[0]?.payload).toMatchObject({
issueId,
sourceRunId: runId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
handoffAttempt: 1,
maxHandoffAttempts: 1,
resumeIntent: true,
resumeFromRunId: runId,
});
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const handoffComment = comments.find((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
expect(handoffComment).toBeTruthy();
expect(handoffComment?.authorType).toBe("system");
expect(handoffComment?.presentation).toMatchObject({
kind: "system_notice",
tone: "warning",
detailsDefaultOpen: false,
});
expect(handoffComment?.metadata).toMatchObject({
version: 1,
sections: expect.arrayContaining([
expect.objectContaining({
title: "Required action",
rows: expect.arrayContaining([
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "run_link", runId }),
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
]),
}),
]),
});
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
expect(activity.some((event) => event.action === "issue.successful_run_handoff_required")).toBe(true);
});
it("requeues a missing-disposition handoff when the previous corrective wake was cancelled", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
const idempotencyKey = `finish_successful_run_handoff:${issueId}:${runId}:1`;
await db.insert(agentWakeupRequests).values({
id: randomUUID(),
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "finish_successful_run_handoff",
payload: {
issueId,
sourceRunId: runId,
handoffRequired: true,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
},
status: "cancelled",
idempotencyKey,
requestedAt: new Date("2026-03-19T00:00:01.000Z"),
finishedAt: new Date("2026-03-19T00:00:02.000Z"),
updatedAt: new Date("2026-03-19T00:00:02.000Z"),
});
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Implemented recovery handling, but did not choose a final issue state.",
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Implemented recovery handling, but did not choose a final issue state.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.idempotencyKey, idempotencyKey));
const requeued = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return requeued.length > 1 ? requeued : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(2);
expect(handoffWakeups.filter((wakeup) => wakeup.status === "cancelled")).toHaveLength(1);
expect(handoffWakeups.some((wakeup) => wakeup.status !== "cancelled")).toBe(true);
});
it("queues one missing-disposition handoff for artifact-producing successful runs left in progress", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
const documentId = randomUUID();
const revisionId = randomUUID();
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Drafted the Phase 3 test plan but did not choose a final issue disposition.",
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Regression test plan",
format: "markdown",
latestBody: "# Regression test plan\n\n- Cover artifact-producing successful runs",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: agentId,
updatedByAgentId: agentId,
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Regression test plan",
format: "markdown",
body: "# Regression test plan\n\n- Cover artifact-producing successful runs",
createdByAgentId: agentId,
createdByRunId: ctx.runId,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
await db.insert(issueWorkProducts).values({
companyId,
issueId,
type: "report",
provider: "test",
externalId: "phase-3-report",
title: "Phase 3 regression notes",
status: "ready",
summary: "Successful run produced a visible artifact.",
createdByRunId: ctx.runId,
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Created comments, a plan document, and a work product without choosing a disposition.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
const settledRun = await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
const classifiedRun = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
expect(classifiedRun?.status ?? settledRun?.status).toBe("succeeded");
expect(classifiedRun?.livenessState).toBe("advanced");
expect(handoffWakeups).toHaveLength(1);
expect(handoffWakeups[0]?.idempotencyKey).toBe(`finish_successful_run_handoff:${issueId}:${runId}:1`);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("in_progress");
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([]);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments.filter((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toHaveLength(1);
expect(comments.some((comment) => comment.body.startsWith("Drafted the Phase 3 test plan"))).toBe(true);
const workProducts = await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.issueId, issueId));
expect(workProducts).toHaveLength(1);
const recoveryIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
expect(recoveryIssues).toHaveLength(0);
});
it("redacts secret-bearing successful-run detected progress before handoff disclosure", async () => {
const { agentId, runId, issueId } = await seedQueuedIssueRunFixture();
const bearerSecret = "live-bearer-token-value";
const apiKeySecret = "sk-testsuccessfulhandoffsecret";
const redactedDetectedSummary = redactDetectedSuccessfulRunProgressSummaryForBoard(
`Next action noted: Authorization: Bearer ${bearerSecret} OPENAI_API_KEY=${apiKeySecret}`,
{ enabled: false },
);
expect(redactedDetectedSummary).toContain("***REDACTED***");
expect(redactedDetectedSummary).not.toContain(bearerSecret);
expect(redactedDetectedSummary).not.toContain(apiKeySecret);
mockAdapterExecute.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Made progress but left the issue open.",
resultJson: {
message: `Next action: Authorization: Bearer ${bearerSecret} OPENAI_API_KEY=${apiKeySecret}`,
},
provider: "test",
model: "test-model",
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(1);
const wakeupPayloadText = JSON.stringify(handoffWakeups[0]?.payload ?? {});
expect(wakeupPayloadText).not.toContain(bearerSecret);
expect(wakeupPayloadText).not.toContain(apiKeySecret);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const handoffComment = comments.find((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
expect(handoffComment).toBeTruthy();
expect(handoffComment?.body).not.toContain(bearerSecret);
expect(handoffComment?.body).not.toContain(apiKeySecret);
expect(JSON.stringify(handoffComment?.metadata ?? {})).not.toContain(bearerSecret);
expect(JSON.stringify(handoffComment?.metadata ?? {})).not.toContain(apiKeySecret);
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
const handoffActivity = activity.find((event) => event.action === "issue.successful_run_handoff_required");
expect(handoffActivity).toBeTruthy();
const activityDetailsText = JSON.stringify(handoffActivity?.details ?? {});
expect(activityDetailsText).not.toContain(bearerSecret);
expect(activityDetailsText).not.toContain(apiKeySecret);
});
it("escalates an exhausted failed successful-run handoff without using generic continuation recovery first", async () => {
const { companyId, agentId, runId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
runErrorCode: "adapter_failed",
runError: "Authorization: Bearer sk-test-successful-handoff-secret",
});
const sourceRunId = randomUUID();
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "finish_successful_run_handoff",
sourceRunId,
resumeFromRunId: sourceRunId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
missingDisposition: "clear_next_step",
handoffAttempt: 1,
maxHandoffAttempts: 1,
},
})
.where(eq(heartbeatRuns.id, runId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(0);
expect(result.successfulRunHandoffEscalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const recovery = await waitForValue(async () =>
db.select().from(issues).where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "stranded_issue_recovery"),
eq(issues.originId, issueId),
),
).then((rows) => rows[0] ?? null),
);
expect(recovery?.assigneeAgentId).toBe(agentId);
expect(recovery?.title).toContain("Recover missing next step");
expect(recovery?.description).toContain("Normalized cause: `successful_run_missing_state`");
expect(recovery?.description).toContain("not a runtime/adapter crash report");
expect(recovery?.description).toContain(`Source run: [\`${sourceRunId}\`]`);
expect(recovery?.description).toContain("Missing disposition: `clear_next_step`");
expect(recovery?.description).toContain("Source assignee: [CodexCoder]");
expect(recovery?.description).not.toContain("sk-test-successful-handoff-secret");
const sourceIssue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(sourceIssue?.status).toBe("blocked");
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([recovery?.id]);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments[0]?.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
expect(comments[0]?.authorType).toBe("system");
expect(comments[0]?.presentation).toMatchObject({
kind: "system_notice",
tone: "danger",
detailsDefaultOpen: false,
});
expect(comments[0]?.metadata).toMatchObject({
version: 1,
sections: expect.arrayContaining([
expect.objectContaining({
title: "Recovery owner",
rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: recovery?.identifier }),
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CodexCoder" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
]),
});
expect(comments[0]?.body).not.toContain("sk-test-successful-handoff-secret");
expect(JSON.stringify(comments[0]?.metadata ?? {})).not.toContain("sk-test-successful-handoff-secret");
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId));
expect(activity.some((event) => event.action === "issue.successful_run_handoff_escalated")).toBe(true);
});
it("escalates an exhausted successful handoff run that still leaves no disposition", async () => {
const { companyId, runId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "succeeded",
livenessState: "advanced",
});
const sourceRunId = randomUUID();
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "finish_successful_run_handoff",
sourceRunId,
resumeFromRunId: sourceRunId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
missingDisposition: "clear_next_step",
handoffAttempt: 1,
maxHandoffAttempts: 1,
},
})
.where(eq(heartbeatRuns.id, runId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.successfulContinuationObserved).toBe(0);
expect(result.successfulRunHandoffEscalated).toBe(1);
const recovery = await waitForValue(async () =>
db.select().from(issues).where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "stranded_issue_recovery"),
eq(issues.originId, issueId),
),
).then((rows) => rows[0] ?? null),
);
expect(recovery?.description).toContain("Latest handoff run status: `succeeded`");
expect(recovery?.description).toContain("Suggested");
});
it("clears the detached warning when the run reports activity again", async () => {
const { runId } = await seedRunFixture({
includeIssue: false,
@@ -1315,6 +1774,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
payload: expect.objectContaining({
issueId,
mutation: "assigned_todo_liveness_dispatch",
modelProfile: "cheap",
}),
});
@@ -1326,6 +1786,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
taskId: issueId,
wakeReason: "issue_assigned",
source: "issue.assigned_todo_liveness_dispatch",
modelProfile: "cheap",
});
expect((runs[0]?.contextSnapshot as Record<string, unknown>)?.retryReason).toBeUndefined();
@@ -1433,6 +1894,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
payload: expect.objectContaining({
issueId: unblocked.issueId,
mutation: "assigned_todo_liveness_dispatch",
modelProfile: "cheap",
}),
});
const unblockedRuns = await db
@@ -1486,6 +1948,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -1524,6 +1987,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.continuation_recovery",
modelProfile: "cheap",
});
const recoveries = await db
@@ -1575,6 +2039,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -1738,6 +2203,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("issue_continuation_needed");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@@ -2215,6 +2681,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
@@ -2281,6 +2748,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
});
@@ -2336,6 +2804,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
});
@@ -286,6 +286,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
retryOfRunId: sourceRunId,
scheduledRetryAttempt: 1,
scheduledRetryReason: "transient_failure",
contextSnapshot: expect.objectContaining({ modelProfile: "cheap" }),
});
expect(retryRun?.scheduledRetryAt?.toISOString()).toBe(expectedDueAt.toISOString());
@@ -38,7 +38,12 @@ const mockTxInsert = vi.hoisted(() => vi.fn(() => ({ values: mockTxInsertValues
const mockTx = vi.hoisted(() => ({
insert: mockTxInsert,
}));
const mockDbSelectOrderBy = vi.hoisted(() => vi.fn(async () => []));
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({ orderBy: mockDbSelectOrderBy })));
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
}));
const mockFeedbackService = vi.hoisted(() => ({
@@ -236,9 +241,17 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
mockTxInsertValues.mockReset();
mockTxInsert.mockReset();
mockDbSelect.mockReset();
mockDbSelectFrom.mockReset();
mockDbSelectWhere.mockReset();
mockDbSelectOrderBy.mockReset();
mockDb.transaction.mockReset();
mockTxInsertValues.mockResolvedValue(undefined);
mockTxInsert.mockImplementation(() => ({ values: mockTxInsertValues }));
mockDbSelectOrderBy.mockResolvedValue([]);
mockDbSelectWhere.mockImplementation(() => ({ orderBy: mockDbSelectOrderBy }));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx));
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
@@ -545,6 +558,68 @@ describe.sequential("issue comment reopen routes", () => {
));
});
it("passes validated comment presentation fields to trusted board comment writes", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
body: "Paperclip needs a disposition before this issue can continue.",
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
metadata: {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
},
createdAt: new Date(),
updatedAt: new Date(),
});
mockIssueService.findMentionedAgents.mockResolvedValue([]);
const metadata = {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
};
const presentation = { kind: "system_notice", tone: "warning" };
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Paperclip needs a disposition before this issue can continue.",
presentation,
metadata,
});
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Paperclip needs a disposition before this issue can continue.",
{ agentId: undefined, userId: "local-board", runId: null },
{
authorType: "user",
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
metadata,
},
);
});
it("rejects invalid comment metadata before writing a comment", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Invalid metadata",
metadata: { version: 1, arbitrary: true },
});
expect(res.status).toBe(400);
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("does not move dependency-blocked issues to todo via POST comments", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("blocked"));
mockIssueService.getDependencyReadiness.mockResolvedValue({
@@ -372,6 +372,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
issueId,
clearReason: "max_attempts_exhausted",
maxAttempts: 1,
modelProfile: "cheap",
});
const activity = await db
@@ -414,6 +415,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
expect(recoveryIssue).toMatchObject({
parentId: issueId,
priority: "high",
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
expect(["todo", "in_progress"]).toContain(recoveryIssue?.status);
});
@@ -91,6 +91,11 @@ const mockWorkProductService = vi.hoisted(() => ({
const mockEnvironmentService = vi.hoisted(() => ({}));
const mockDb = vi.hoisted(() => ({
select: vi.fn(),
execute: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
@@ -130,7 +135,7 @@ function createApp() {
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(mockDb as any, {} as any));
app.use(errorHandler);
return app;
}
@@ -186,6 +191,14 @@ describe.sequential("issue goal context routes", () => {
mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({});
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
mockDb.select.mockReturnValue({
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn(async () => []),
})),
})),
});
mockDb.execute.mockResolvedValue([]);
mockProjectService.getById.mockResolvedValue({
id: legacyProjectLinkedIssue.projectId,
companyId: "company-1",
@@ -201,6 +201,7 @@ describeEmbeddedPostgres("productivity review service", () => {
expect(reviews).toHaveLength(1);
expect(reviews[0]?.parentId).toBe(seeded.issueId);
expect(reviews[0]?.assigneeAgentId).toBe(seeded.managerId);
expect(reviews[0]?.assigneeAdapterOverrides).toEqual({ modelProfile: "cheap" });
expect(reviews[0]?.originId).toBe(seeded.issueId);
expect(reviews[0]?.originFingerprint).toBe(`productivity-review:${seeded.issueId}`);
expect(reviews[0]?.description).toContain("Primary trigger: `no_comment_streak`");
@@ -76,10 +76,12 @@ describe("run liveness continuations", () => {
continuationAttempt: 1,
maxContinuationAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
instruction: "Take the first concrete action now.",
modelProfile: "cheap",
});
expect(decision.contextSnapshot).toMatchObject({
issueId,
wakeReason: RUN_LIVENESS_CONTINUATION_REASON,
modelProfile: "cheap",
livenessContinuationAttempt: 1,
livenessContinuationMaxAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
livenessContinuationSourceRunId: runId,
@@ -146,6 +146,7 @@ vi.mock("../services/index.js", () => ({
reconcileStrandedAssignedIssues: vi.fn(async () => ({
dispatchRequeued: 0,
continuationRequeued: 0,
successfulRunHandoffEscalated: 0,
escalated: 0,
skipped: 0,
issueIds: [],
+2
View File
@@ -686,6 +686,7 @@ export async function startServer(): Promise<StartedServer> {
reconciled.assignmentDispatched > 0 ||
reconciled.dispatchRequeued > 0 ||
reconciled.continuationRequeued > 0 ||
reconciled.successfulRunHandoffEscalated > 0 ||
reconciled.escalated > 0
) {
logger.warn(
@@ -751,6 +752,7 @@ export async function startServer(): Promise<StartedServer> {
reconciled.assignmentDispatched > 0 ||
reconciled.dispatchRequeued > 0 ||
reconciled.continuationRequeued > 0 ||
reconciled.successfulRunHandoffEscalated > 0 ||
reconciled.escalated > 0
) {
logger.warn(
+117 -2
View File
@@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { issueExecutionDecisions } from "@paperclipai/db";
import { activityLog, issueExecutionDecisions } from "@paperclipai/db";
import {
addIssueCommentSchema,
acceptIssueThreadInteractionSchema,
@@ -32,6 +33,7 @@ import {
isClosedIsolatedExecutionWorkspace,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
type ExecutionWorkspace,
type SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
@@ -78,6 +80,7 @@ import { executionWorkspaceService as executionWorkspaceServiceDirect } from "..
import { feedbackService } from "../services/feedback.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { environmentService } from "../services/environments.js";
import { redactSensitiveText } from "../redaction.js";
import {
applyIssueExecutionPolicyTransition,
normalizeIssueExecutionPolicy,
@@ -113,6 +116,103 @@ type ExecutionStageWakeContext = {
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
allowedActions: string[];
};
type SuccessfulRunHandoffActivityRow = {
entityId: string;
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
};
const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
"issue.successful_run_handoff_required",
"issue.successful_run_handoff_resolved",
"issue.successful_run_handoff_escalated",
] as const;
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function successfulRunHandoffStateFromActivity(row: {
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
}): SuccessfulRunHandoffState | null {
const details = row.details ?? {};
const state =
row.action === "issue.successful_run_handoff_required"
? "required"
: row.action === "issue.successful_run_handoff_resolved"
? "resolved"
: row.action === "issue.successful_run_handoff_escalated"
? "escalated"
: null;
if (!state) return null;
const detectedProgressSummary =
readNonEmptyString(details.detectedProgressSummary)
?? readNonEmptyString(details.detected_progress_summary)
?? null;
return {
state,
required: state === "required",
sourceRunId:
readNonEmptyString(details.sourceRunId)
?? readNonEmptyString(details.source_run_id)
?? readNonEmptyString(details.resumeFromRunId)
?? row.runId
?? null,
correctiveRunId:
readNonEmptyString(details.correctiveRunId)
?? readNonEmptyString(details.corrective_run_id)
?? (state !== "required" ? row.runId : null),
assigneeAgentId:
readNonEmptyString(details.assigneeAgentId)
?? readNonEmptyString(details.agentId)
?? row.agentId
?? null,
detectedProgressSummary: detectedProgressSummary
? redactSensitiveText(detectedProgressSummary)
: null,
createdAt: row.createdAt,
};
}
async function listSuccessfulRunHandoffStates(
db: Db,
companyId: string,
issueIds: string[],
): Promise<Map<string, SuccessfulRunHandoffState>> {
if (issueIds.length === 0) return new Map();
const result = await db.execute(sql`
SELECT DISTINCT ON (${activityLog.entityId})
${activityLog.entityId} AS "entityId",
${activityLog.action} AS "action",
${activityLog.agentId} AS "agentId",
${activityLog.runId} AS "runId",
${activityLog.details} AS "details",
${activityLog.createdAt} AS "createdAt"
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} IN (${sql.join(issueIds.map((id) => sql`${id}`), sql`, `)})
AND ${activityLog.action} IN (${sql.join(SUCCESSFUL_RUN_HANDOFF_ACTIONS.map((action) => sql`${action}`), sql`, `)})
ORDER BY ${activityLog.entityId}, ${activityLog.createdAt} DESC, ${activityLog.id} DESC
`);
const rows = Array.from(result as Iterable<SuccessfulRunHandoffActivityRow>);
const states = new Map<string, SuccessfulRunHandoffState>();
for (const row of rows) {
const state = successfulRunHandoffStateFromActivity(row);
if (state) states.set(row.entityId, state);
}
return states;
}
function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null,
@@ -1033,7 +1133,15 @@ export function issueRoutes(
limit,
offset,
});
res.json(result);
const handoffStates = await listSuccessfulRunHandoffStates(
db,
companyId,
result.map((issue) => issue.id),
);
res.json(result.map((issue) => ({
...issue,
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
})));
});
router.get("/companies/:companyId/labels", async (req, res) => {
@@ -1221,6 +1329,7 @@ export function issueRoutes(
blockerAttention,
productivityReview,
referenceSummary,
successfulRunHandoffStates,
] = await Promise.all([
resolveIssueProjectAndGoal(issue),
svc.getAncestors(issue.id),
@@ -1230,6 +1339,7 @@ export function issueRoutes(
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null),
issueReferencesSvc.listIssueReferenceSummary(issue.id),
listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]),
]);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
@@ -1244,6 +1354,7 @@ export function issueRoutes(
ancestors,
...(blockerAttention ? { blockerAttention } : {}),
productivityReview,
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
blockedBy: relations.blockedBy,
blocks: relations.blocks,
relatedWork: referenceSummary,
@@ -3688,6 +3799,10 @@ export function issueRoutes(
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
}, {
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: req.body.presentation ?? null,
metadata: req.body.metadata ?? null,
});
await issueReferencesSvc.syncComment(comment.id);
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
+139 -2
View File
@@ -16,6 +16,7 @@ import type {
CompanyPortabilityImportResult,
CompanyPortabilityInclude,
CompanyPortabilityManifest,
CompanyPortabilityIssueCommentManifestEntry,
CompanyPortabilityPreview,
CompanyPortabilityPreviewAgentPlan,
CompanyPortabilityPreviewResult,
@@ -42,6 +43,9 @@ import {
ROUTINE_TRIGGER_SIGNING_MODES,
deriveProjectUrlKey,
envConfigSchema,
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
normalizeAgentUrlKey,
} from "@paperclipai/shared";
import {
@@ -644,6 +648,96 @@ function asInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function hasOwn(record: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(record, key);
}
function readStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const entries = value.filter((entry): entry is string => typeof entry === "string");
return entries.length === value.length ? entries : null;
}
function derivePortableCommentAuthorType(value: Record<string, unknown>) {
const explicit = issueCommentAuthorTypeSchema.safeParse(value.authorType);
if (explicit.success) return explicit.data;
return asString(value.authorAgentSlug) ? "agent" : asString(value.authorUserId) ? "user" : "system";
}
function readPortableIssueComments(
value: unknown,
warnings: string[],
sourceLabel: string,
): CompanyPortabilityIssueCommentManifestEntry[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) {
warnings.push(`${sourceLabel} comments were ignored because they are not an array.`);
return [];
}
const comments: CompanyPortabilityIssueCommentManifestEntry[] = [];
for (const [index, entry] of value.entries()) {
if (!isPlainRecord(entry)) {
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it is not an object.`);
continue;
}
const body = asString(entry.body);
if (!body) {
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it has no body.`);
continue;
}
const presentation = entry.presentation == null ? null : issueCommentPresentationSchema.safeParse(entry.presentation);
if (presentation && !presentation.success) {
warnings.push(`${sourceLabel} comment ${index + 1} has invalid presentation metadata and was ignored.`);
continue;
}
const metadata = entry.metadata == null ? null : issueCommentMetadataSchema.safeParse(entry.metadata);
if (metadata && !metadata.success) {
warnings.push(`${sourceLabel} comment ${index + 1} has invalid hidden metadata and was ignored.`);
continue;
}
const createdAt = asString(entry.createdAt);
comments.push({
body,
authorType: derivePortableCommentAuthorType(entry),
authorAgentSlug: asString(entry.authorAgentSlug),
authorUserId: asString(entry.authorUserId),
presentation: presentation ? presentation.data : null,
metadata: metadata ? metadata.data : null,
createdAt: createdAt && Number.isNaN(Date.parse(createdAt)) ? null : createdAt,
});
}
return comments;
}
function appendCodexImportArg(adapterConfig: Record<string, unknown>, arg: string) {
const extraArgs = readStringArray(adapterConfig.extraArgs);
if (extraArgs) {
if (!extraArgs.includes(arg)) adapterConfig.extraArgs = [...extraArgs, arg];
return;
}
const legacyArgs = readStringArray(adapterConfig.args);
if (legacyArgs && legacyArgs.length > 0) {
if (!legacyArgs.includes(arg)) adapterConfig.args = [...legacyArgs, arg];
return;
}
if (legacyArgs?.includes(arg)) return;
adapterConfig.extraArgs = [arg];
}
function applyImportAdapterRunDefaults(
adapterType: string,
adapterConfig: Record<string, unknown>,
) {
const next = { ...adapterConfig };
if (adapterType === "codex_local") {
appendCodexImportArg(next, "--skip-git-repo-check");
}
return next;
}
function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIssueRoutineTriggerManifestEntry | null {
if (!isPlainRecord(value)) return null;
const kind = asString(value.kind);
@@ -2685,6 +2779,7 @@ function buildManifestFromPackageFiles(
assigneeAdapterOverrides: isPlainRecord(extension.assigneeAdapterOverrides)
? extension.assigneeAdapterOverrides
: null,
comments: readPortableIssueComments(extension.comments, warnings, `Task ${slug}`),
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
});
if (frontmatter.kind && frontmatter.kind !== "task") {
@@ -2804,7 +2899,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
}
const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills);
const nextAdapterConfig = writePaperclipSkillSyncPreference(
applyImportAdapterRunDefaults(effectiveAdapterType, adapterConfig),
desiredSkills,
);
delete nextAdapterConfig.promptTemplate;
delete nextAdapterConfig.bootstrapPromptTemplate;
delete nextAdapterConfig.instructionsFilePath;
@@ -3380,6 +3478,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
});
}
}
const comments = await issuesSvc.listComments(issue.id, { order: "asc" });
files[taskPath] = buildMarkdown(
{
name: issue.title,
@@ -3397,6 +3496,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
projectWorkspaceKey: projectWorkspaceKey ?? undefined,
executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined,
assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined,
comments: comments.length > 0
? comments.map((comment) => ({
body: comment.body,
authorType: comment.authorType,
authorAgentSlug: comment.authorAgentId ? (idToSlug.get(comment.authorAgentId) ?? null) : null,
// Portable bundles preserve author kind, but not raw board user ids.
authorUserId: null,
presentation: comment.presentation,
metadata: comment.metadata,
createdAt: comment.createdAt instanceof Date
? comment.createdAt.toISOString()
: new Date(comment.createdAt).toISOString(),
}))
: undefined,
});
paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {};
}
@@ -4496,7 +4609,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`);
issueStatus = "todo";
}
await issues.create(targetCompany.id, {
const createdIssue = await issues.create(targetCompany.id, {
projectId,
projectWorkspaceId,
title: manifestIssue.title,
@@ -4511,6 +4624,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings,
labelIds: manifestIssue.labelIds ?? [],
});
for (const comment of manifestIssue.comments ?? []) {
const authorAgentId = comment.authorType === "agent" && comment.authorAgentSlug
? importedSlugToAgentId.get(comment.authorAgentSlug)
?? existingSlugToAgentId.get(comment.authorAgentSlug)
?? null
: null;
if (comment.authorType === "agent" && comment.authorAgentSlug && !authorAgentId) {
warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because author agent ${comment.authorAgentSlug} was not imported.`);
}
const authorType = authorAgentId
? "agent"
: comment.authorType === "user"
? "user"
: "system";
await issues.addComment(createdIssue.id, comment.body, {
agentId: authorAgentId ?? undefined,
userId: authorType === "user" ? actorUserId ?? undefined : undefined,
}, {
authorType,
presentation: comment.presentation,
metadata: comment.metadata,
createdAt: comment.createdAt,
});
}
}
}
+21
View File
@@ -89,6 +89,9 @@ type FeedbackTargetRecord = {
createdAt: Date;
authorAgentId: string | null;
authorUserId: string | null;
authorType?: string | null;
presentation?: unknown;
metadata?: unknown;
createdByRunId: string | null;
documentId: string | null;
documentKey: string | null;
@@ -797,6 +800,9 @@ async function resolveFeedbackTarget(
companyId: issueComments.companyId,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
authorType: issueComments.authorType,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
createdByRunId: issueComments.createdByRunId,
body: issueComments.body,
createdAt: issueComments.createdAt,
@@ -820,6 +826,9 @@ async function resolveFeedbackTarget(
createdAt: targetComment.createdAt,
authorAgentId: targetComment.authorAgentId,
authorUserId: targetComment.authorUserId,
authorType: targetComment.authorType ?? (targetComment.authorAgentId ? "agent" : targetComment.authorUserId ? "user" : "system"),
presentation: targetComment.presentation ?? null,
metadata: targetComment.metadata ?? null,
createdByRunId: targetComment.createdByRunId ?? null,
documentId: null,
documentKey: null,
@@ -833,6 +842,9 @@ async function resolveFeedbackTarget(
createdAt: targetComment.createdAt.toISOString(),
authorAgentId: targetComment.authorAgentId,
authorUserId: targetComment.authorUserId,
authorType: targetComment.authorType ?? (targetComment.authorAgentId ? "agent" : targetComment.authorUserId ? "user" : "system"),
presentation: targetComment.presentation ?? null,
metadata: targetComment.metadata ?? null,
createdByRunId: targetComment.createdByRunId ?? null,
issuePath,
targetPath: issuePath ? `${issuePath}#comment-${targetComment.id}` : null,
@@ -918,6 +930,9 @@ async function listIssueContextItems(
createdAt: issueComments.createdAt,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
authorType: issueComments.authorType,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
createdByRunId: issueComments.createdByRunId,
})
.from(issueComments)
@@ -952,6 +967,9 @@ async function listIssueContextItems(
createdAt: row.createdAt,
authorAgentId: row.authorAgentId,
authorUserId: row.authorUserId,
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
presentation: row.presentation ?? null,
metadata: row.metadata ?? null,
createdByRunId: row.createdByRunId ?? null,
documentId: null,
documentKey: null,
@@ -1023,6 +1041,9 @@ async function buildIssueContext(
createdAt: item.createdAt.toISOString(),
authorAgentId: item.authorAgentId,
authorUserId: item.authorUserId,
authorType: item.authorType ?? null,
presentation: item.presentation ?? null,
metadata: item.metadata ?? null,
createdByRunId: item.createdByRunId,
documentKey: item.documentKey,
documentTitle: item.documentTitle,
+356 -26
View File
@@ -26,13 +26,16 @@ import {
agentTaskSessions,
agentWakeupRequests,
activityLog,
approvals,
companySkills as companySkillsTable,
documentRevisions,
issueDocuments,
heartbeatRunEvents,
heartbeatRuns,
issueApprovals,
issueComments,
issueRelations,
issueThreadInteractions,
issues,
issueWorkProducts,
projects,
@@ -119,18 +122,33 @@ import {
import { instanceSettingsService } from "./instance-settings.js";
import {
RECOVERY_ORIGIN_KINDS,
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
RUN_LIVENESS_CONTINUATION_REASON,
buildRunLivenessContinuationIdempotencyKey,
buildFinishSuccessfulRunHandoffIdempotencyKey,
buildSuccessfulRunHandoffRequiredNotice,
decideRunLivenessContinuation,
decideSuccessfulRunHandoff,
findExistingFinishSuccessfulRunHandoffWake,
findExistingRunLivenessContinuationWake,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
readContinuationAttempt,
} from "./recovery/index.js";
import { isAutomaticRecoverySuppressedByPauseHold } from "./recovery/pause-hold-guard.js";
import {
recoveryAssigneeAdapterOverrides,
withRecoveryModelProfileHint,
} from "./recovery/model-profile-hint.js";
import { recoveryService } from "./recovery/service.js";
import { productivityReviewService } from "./productivity-review.js";
import { withAgentStartLock } from "./agent-start-lock.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import { redactEventPayload } from "../redaction.js";
import {
redactCurrentUserText,
redactCurrentUserValue,
type CurrentUserRedactionOptions,
} from "../log-redaction.js";
import { redactEventPayload, redactSensitiveText } from "../redaction.js";
import {
hasSessionCompactionThresholds,
resolveSessionCompactionPolicy,
@@ -150,6 +168,16 @@ const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024;
const MAX_RUN_EVENT_PAYLOAD_STRING_CHARS = 16 * 1024;
const MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS = 50;
export function redactDetectedSuccessfulRunProgressSummaryForBoard(
summary: string,
currentUserRedactionOptions?: CurrentUserRedactionOptions,
) {
const normalized = summary.replace(/\s+/g, " ").trim();
const redacted = redactSensitiveText(redactCurrentUserText(normalized, currentUserRedactionOptions));
return redacted.length <= 280 ? redacted : `${redacted.slice(0, 277)}...`;
}
const MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS = 100;
const MAX_RUN_EVENT_PAYLOAD_DEPTH = 6;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = AGENT_DEFAULT_MAX_CONCURRENT_RUNS;
@@ -1837,8 +1865,11 @@ async function buildPaperclipWakePayload(input: {
id: issueComments.id,
issueId: issueComments.issueId,
body: issueComments.body,
authorType: issueComments.authorType,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
createdAt: issueComments.createdAt,
})
.from(issueComments)
@@ -1882,8 +1913,11 @@ async function buildPaperclipWakePayload(input: {
comments.push({
id: row.id,
issueId: row.issueId,
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
body,
bodyTruncated,
presentation: row.presentation ?? null,
metadata: row.metadata ?? null,
createdAt: row.createdAt.toISOString(),
author: row.authorAgentId
? { type: "agent", id: row.authorAgentId }
@@ -2541,6 +2575,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
projectId: input.claimed.projectId,
goalId: input.claimed.goalId,
assigneeAgentId: input.claimed.assigneeAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
originId: input.claimed.id,
originFingerprint: `issue_monitor:${input.clearReason}`,
@@ -2554,15 +2589,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
triggerDetail: "system",
reason: "issue_monitor_recovery_issue",
idempotencyKey: `issue-monitor-recovery-issue:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
payload: { issueId: recoveryIssue.id, sourceIssueId: input.claimed.id },
payload: withRecoveryModelProfileHint({ issueId: recoveryIssue.id, sourceIssueId: input.claimed.id }),
requestedByActorType: input.actorType,
requestedByActorId: input.actorId,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: recoveryIssue.id,
sourceIssueId: input.claimed.id,
source: "issue.monitor.recovery_issue",
wakeReason: "issue_monitor_recovery_issue",
},
}),
});
}
@@ -2615,7 +2650,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
triggerDetail: "system",
reason: "issue_monitor_recovery",
idempotencyKey: `issue-monitor-recovery:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
payload: {
payload: withRecoveryModelProfileHint({
issueId: input.claimed.id,
monitorAttemptCount: input.nextAttemptCount,
monitorNotes: input.claimed.monitorNotes ?? null,
@@ -2623,10 +2658,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
serviceName: input.monitor?.serviceName ?? null,
timeoutAt: input.monitor?.timeoutAt ?? null,
maxAttempts: input.monitor?.maxAttempts ?? null,
},
}),
requestedByActorType: input.actorType,
requestedByActorId: input.actorId,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: input.claimed.id,
source: "issue.monitor.recovery",
wakeReason: "issue_monitor_recovery",
@@ -2636,7 +2671,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
serviceName: input.monitor?.serviceName ?? null,
timeoutAt: input.monitor?.timeoutAt ?? null,
maxAttempts: input.monitor?.maxAttempts ?? null,
},
}),
});
await logActivity(db, {
@@ -3817,6 +3852,287 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
}
function issueUiLink(issue: Pick<typeof issues.$inferSelect, "id" | "identifier">) {
const label = issue.identifier ?? issue.id;
const prefix = issue.identifier?.split("-")[0] || "PAP";
return `[${label}](/${prefix}/issues/${label})`;
}
async function buildDetectedSuccessfulRunProgressSummary(run: typeof heartbeatRuns.$inferSelect) {
const resultJson = parseObject(run.resultJson);
const candidates = [
readNonEmptyString(run.nextAction) ? `Next action noted: ${readNonEmptyString(run.nextAction)}` : null,
readNonEmptyString(run.livenessReason),
readNonEmptyString(resultJson.summary),
readNonEmptyString(resultJson.result),
readNonEmptyString(resultJson.message),
].filter((value): value is string => Boolean(value));
const summary = candidates[0];
if (!summary) return null;
return redactDetectedSuccessfulRunProgressSummaryForBoard(
summary,
await getCurrentUserRedactionOptions(),
);
}
async function addSuccessfulRunHandoffCommentOnce(input: {
issue: Pick<typeof issues.$inferSelect, "id" | "identifier" | "title" | "status">;
run: typeof heartbeatRuns.$inferSelect;
agent: Pick<typeof agents.$inferSelect, "id" | "name">;
detectedProgressSummary: string;
}) {
const existing = await db
.select({ id: issueComments.id })
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.run.companyId),
eq(issueComments.issueId, input.issue.id),
eq(issueComments.createdByRunId, input.run.id),
sql`(${issueComments.body} = ${SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY} or ${issueComments.body} like '## This issue still needs a next step%' or ${issueComments.body} like '## Successful run missing issue disposition%')`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
if (existing) return null;
const notice = buildSuccessfulRunHandoffRequiredNotice(input);
return issuesSvc.addComment(
input.issue.id,
notice.body,
{ runId: input.run.id },
{
authorType: "system",
presentation: notice.presentation,
metadata: notice.metadata,
},
);
}
async function handleSuccessfulRunHandoff(run: typeof heartbeatRuns.$inferSelect, agent: typeof agents.$inferSelect) {
if (run.status !== "succeeded") return;
const context = parseObject(run.contextSnapshot);
const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
if (!issueId) return;
const issue = await db
.select({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
executionState: issues.executionState,
projectId: issues.projectId,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
.then((rows) => rows[0] ?? null);
const idempotencyKey = issue
? buildFinishSuccessfulRunHandoffIdempotencyKey({
issueId: issue.id,
sourceRunId: run.id,
})
: null;
const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null);
const detectedProgressSummary = await buildDetectedSuccessfulRunProgressSummary(run);
const [
activeExecutionPath,
queuedWake,
pendingInteraction,
pendingApproval,
explicitBlocker,
openRecoveryIssue,
existingWake,
budgetBlock,
pauseHold,
] = await Promise.all([
issue
? db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, issue.companyId),
eq(heartbeatRuns.agentId, run.agentId),
inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]),
sql`(
${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}
or ${heartbeatRuns.contextSnapshot} ->> 'taskId' = ${issue.id}
)`,
sql`${heartbeatRuns.id} <> ${run.id}`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, issue.companyId),
eq(agentWakeupRequests.agentId, run.agentId),
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution", "claimed"]),
sql`(
${agentWakeupRequests.payload} ->> 'issueId' = ${issue.id}
or ${agentWakeupRequests.payload} ->> 'taskId' = ${issue.id}
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId' = ${issue.id}
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' = ${issue.id}
)`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issueThreadInteractions.id })
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.status, "pending"),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issueApprovals.approvalId })
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(
and(
eq(issueApprovals.companyId, issue.companyId),
eq(issueApprovals.issueId, issue.id),
inArray(approvals.status, ["pending", "revision_requested"]),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issueRelations.issueId })
.from(issueRelations)
.where(
and(
eq(issueRelations.companyId, issue.companyId),
eq(issueRelations.relatedIssueId, issue.id),
eq(issueRelations.type, "blocks"),
sql`exists (
select 1
from issues blocker
where blocker.id = ${issueRelations.issueId}
and blocker.company_id = ${issue.companyId}
and blocker.status not in ('done', 'cancelled')
and blocker.hidden_at is null
)`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
issue
? db
.select({ id: issues.id })
.from(issues)
.where(
and(
eq(issues.companyId, issue.companyId),
inArray(issues.originKind, [
RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
]),
eq(issues.originId, issue.id),
isNull(issues.hiddenAt),
notInArray(issues.status, ["done", "cancelled"]),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
idempotencyKey
? findExistingFinishSuccessfulRunHandoffWake(db, {
companyId: run.companyId,
idempotencyKey,
})
: Promise.resolve(null),
issue
? budgets.getInvocationBlock(issue.companyId, run.agentId, {
issueId: issue.id,
projectId: issue.projectId,
})
: Promise.resolve(null),
issue
? treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id)
: Promise.resolve(null),
]);
const decision = decideSuccessfulRunHandoff({
run,
issue,
agent,
livenessState: run.livenessState as RunLivenessState | null,
detectedProgressSummary,
taskKey,
hasActiveExecutionPath: Boolean(activeExecutionPath),
hasQueuedWake: Boolean(queuedWake),
hasPendingInteractionOrApproval: Boolean(pendingInteraction || pendingApproval),
hasExplicitBlockerPath: Boolean(explicitBlocker),
hasOpenRecoveryIssue: Boolean(openRecoveryIssue),
hasPauseHold: Boolean(pauseHold),
budgetBlocked: Boolean(budgetBlock),
idempotentWakeExists: Boolean(existingWake),
});
if (decision.kind !== "enqueue" || !issue) return;
const handoffRun = await enqueueWakeup(run.agentId, {
source: "automation",
triggerDetail: "system",
reason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
payload: decision.payload,
contextSnapshot: decision.contextSnapshot,
idempotencyKey: decision.idempotencyKey,
requestedByActorType: "system",
requestedByActorId: "heartbeat",
});
if (!handoffRun) return;
await addSuccessfulRunHandoffCommentOnce({
issue,
run,
agent,
detectedProgressSummary: detectedProgressSummary ?? "The run reported progress, but did not choose a next step.",
});
await logActivity(db, {
companyId: issue.companyId,
actorType: "system",
actorId: "heartbeat",
agentId: run.agentId,
runId: run.id,
action: "issue.successful_run_handoff_required",
entityType: "issue",
entityId: issue.id,
details: {
label: "Successful run missing issue disposition",
sourceRunId: run.id,
correctiveRunId: handoffRun.id,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
missingDisposition: "clear_next_step",
detectedProgressSummary,
issue: issueUiLink(issue),
},
});
}
async function appendRunEvent(
run: typeof heartbeatRuns.$inferSelect,
seq: number,
@@ -3998,13 +4314,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const contextSnapshot = parseObject(run.contextSnapshot);
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const retryContextSnapshot = {
const retryContextSnapshot = withRecoveryModelProfileHint({
...contextSnapshot,
retryOfRunId: run.id,
wakeReason: "missing_issue_comment",
retryReason: "missing_issue_comment",
missingIssueCommentForRunId: run.id,
};
});
const now = new Date();
const retryRun = await db.transaction(async (tx) => {
@@ -4027,11 +4343,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: "missing_issue_comment",
payload: {
payload: withRecoveryModelProfileHint({
issueId,
retryOfRunId: run.id,
retryReason: "missing_issue_comment",
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@@ -4219,12 +4535,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const issueId = readNonEmptyString(contextSnapshot.issueId);
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const retryContextSnapshot = {
const retryContextSnapshot = withRecoveryModelProfileHint({
...contextSnapshot,
retryOfRunId: run.id,
wakeReason: "process_lost_retry",
retryReason: "process_lost",
};
});
const queued = await db.transaction(async (tx) => {
const wakeupRequest = await tx
@@ -4235,10 +4551,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: "process_lost_retry",
payload: {
payload: withRecoveryModelProfileHint({
...(issueId ? { issueId } : {}),
retryOfRunId: run.id,
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@@ -4675,7 +4991,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
const retryContextSnapshot: Record<string, unknown> = {
const retryContextSnapshot: Record<string, unknown> = withRecoveryModelProfileHint({
...contextSnapshot,
retryOfRunId: run.id,
wakeReason,
@@ -4685,7 +5001,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
scheduledRetryAt: schedule.dueAt.toISOString(),
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}),
};
});
const maxTurnContinuationIdempotencyKey = retryReason === MAX_TURN_CONTINUATION_RETRY_REASON
? `max-turn-continuation:${run.companyId}:${issueId ?? "no-issue"}:${run.id}:${schedule.attempt}`
: null;
@@ -4846,7 +5162,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: wakeReason,
payload: {
payload: withRecoveryModelProfileHint({
...(issueId ? { issueId } : {}),
retryOfRunId: run.id,
retryReason,
@@ -4855,7 +5171,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
scheduledRetryAt: schedule.dueAt.toISOString(),
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}),
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@@ -6171,6 +6487,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
.select({
id: issueComments.id,
body: issueComments.body,
authorType: issueComments.authorType,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
presentation: issueComments.presentation,
metadata: issueComments.metadata,
})
.from(issueComments)
.where(and(
@@ -7235,9 +7556,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
} else if (outcome === "failed" && readTransientRecoveryContractFromRun(livenessRun)) {
await scheduleBoundedRetryForRun(livenessRun, agent);
}
await finalizeIssueCommentPolicy(livenessRun, agent);
const issueCommentPolicyResult = await finalizeIssueCommentPolicy(livenessRun, agent);
await releaseIssueExecutionAndPromote(livenessRun);
await handleRunLivenessContinuation(livenessRun);
await handleSuccessfulRunHandoff(
issueCommentPolicyResult.outcome === "retry_queued" || issueCommentPolicyResult.outcome === "retry_exhausted"
? {
...livenessRun,
issueCommentStatus: issueCommentPolicyResult.outcome,
}
: livenessRun,
agent,
);
}
if (finalizedRun) {
@@ -7739,10 +8069,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
source: "automation",
triggerDetail: "system",
reason: recoveryReason,
payload: {
payload: withRecoveryModelProfileHint({
issueId: issue.id,
retryOfRunId: run.id,
},
}),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
@@ -7760,14 +8090,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
triggerDetail: "system",
status: "queued",
wakeupRequestId: wakeupRequest.id,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
wakeReason: recoveryReason,
retryReason,
source: recoverySource,
retryOfRunId: run.id,
},
}),
sessionIdBefore: recoverySessionBefore,
retryOfRunId: run.id,
updatedAt: now,
+61 -1
View File
@@ -28,6 +28,9 @@ import {
projects,
} from "@paperclipai/db";
import type {
IssueCommentAuthorType,
IssueCommentMetadata,
IssueCommentPresentation,
IssueBlockerAttention,
IssueProductivityReview,
IssueProductivityReviewTrigger,
@@ -37,6 +40,9 @@ import {
clampIssueRequestDepth,
extractAgentMentionIds,
extractProjectMentionIds,
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
isUuidLike,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
} from "@paperclipai/shared";
@@ -1679,10 +1685,47 @@ export function issueService(db: Db) {
return enriched;
}
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
function deriveIssueCommentAuthorType(comment: {
authorType?: string | null;
authorAgentId?: string | null;
authorUserId?: string | null;
}): IssueCommentAuthorType {
const explicit = issueCommentAuthorTypeSchema.safeParse(comment.authorType);
if (explicit.success) return explicit.data;
if (comment.authorAgentId) return "agent";
if (comment.authorUserId) return "user";
return "system";
}
function assertIssueCommentAuthorTypeAllowed(
actor: { agentId?: string | null; userId?: string | null },
authorType: IssueCommentAuthorType,
) {
if (actor.agentId && authorType !== "agent") {
throw unprocessable("Comment authorType must match authenticated actor");
}
if (actor.userId && authorType !== "user") {
throw unprocessable("Comment authorType must match authenticated actor");
}
if (!actor.agentId && !actor.userId && authorType !== "system") {
throw unprocessable("System comments cannot use user or agent authorType without an author id");
}
}
function redactIssueComment<T extends { body: string; authorType?: string | null; authorAgentId?: string | null; authorUserId?: string | null; presentation?: unknown; metadata?: unknown }>(
comment: T,
censorUsernameInLogs: boolean,
): T & {
authorType: IssueCommentAuthorType;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
} {
return {
...comment,
authorType: deriveIssueCommentAuthorType(comment),
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
presentation: issueCommentPresentationSchema.nullable().catch(null).parse(comment.presentation ?? null),
metadata: issueCommentMetadataSchema.nullable().catch(null).parse(comment.metadata ?? null),
};
}
@@ -3743,6 +3786,12 @@ export function issueService(db: Db) {
issueId: string,
body: string,
actor: { agentId?: string; userId?: string; runId?: string | null },
options?: {
authorType?: IssueCommentAuthorType | null;
presentation?: IssueCommentPresentation | null;
metadata?: IssueCommentMetadata | null;
createdAt?: Date | string | null;
},
) => {
const issue = await db
.select({ companyId: issues.companyId })
@@ -3756,6 +3805,13 @@ export function issueService(db: Db) {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
const authorType = issueCommentAuthorTypeSchema.parse(
options?.authorType ?? (actor.agentId ? "agent" : actor.userId ? "user" : "system"),
);
assertIssueCommentAuthorTypeAllowed(actor, authorType);
const presentation = issueCommentPresentationSchema.nullable().parse(options?.presentation ?? null);
const metadata = issueCommentMetadataSchema.nullable().parse(options?.metadata ?? null);
const createdAt = options?.createdAt ? new Date(options.createdAt) : null;
const [comment] = await db
.insert(issueComments)
.values({
@@ -3763,8 +3819,12 @@ export function issueService(db: Db) {
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
authorType,
createdByRunId: actor.runId ?? null,
body: redactedBody,
presentation,
metadata,
...(createdAt && !Number.isNaN(createdAt.getTime()) ? { createdAt } : {}),
})
.returning();
+9 -4
View File
@@ -14,6 +14,10 @@ import { logger } from "../middleware/logger.js";
import { logActivity } from "./activity-log.js";
import { budgetService } from "./budgets.js";
import { issueService } from "./issues.js";
import {
recoveryAssigneeAdapterOverrides,
withRecoveryModelProfileHint,
} from "./recovery/model-profile-hint.js";
import { RECOVERY_ORIGIN_KINDS } from "./recovery/origins.js";
export const PRODUCTIVITY_REVIEW_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.issueProductivityReview;
@@ -687,6 +691,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
goalId: evidence.sourceIssue.goalId,
billingCode: evidence.sourceIssue.billingCode,
assigneeAgentId: ownerAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
originId: evidence.sourceIssue.id,
originFingerprint: productivityReviewFingerprint(evidence.sourceIssue.id),
@@ -732,21 +737,21 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: review.id,
sourceIssueId: evidence.sourceIssue.id,
trigger: evidence.trigger,
},
}),
requestedByActorType: "system",
requestedByActorId: "productivity_review",
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: review.id,
taskId: review.id,
wakeReason: "issue_assigned",
source: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
sourceIssueId: evidence.sourceIssue.id,
productivityReviewTrigger: evidence.trigger,
},
}),
});
}
+20
View File
@@ -42,3 +42,23 @@ export {
export type {
RunContinuationDecision,
} from "./run-liveness-continuations.js";
export {
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES,
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_OPTIONS,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
buildFinishSuccessfulRunHandoffIdempotencyKey,
buildSuccessfulRunHandoffExhaustedNotice,
buildSuccessfulRunHandoffInstruction,
buildSuccessfulRunHandoffRequiredNotice,
decideSuccessfulRunHandoff,
findExistingFinishSuccessfulRunHandoffWake,
isSuccessfulRunHandoffRequiredNoticeBody,
} from "./successful-run-handoff.js";
export type {
SuccessfulRunHandoffNotice,
SuccessfulRunHandoffDecision,
} from "./successful-run-handoff.js";
@@ -0,0 +1,14 @@
export const RECOVERY_MODEL_PROFILE_KEY = "cheap" as const;
export function withRecoveryModelProfileHint<T extends Record<string, unknown>>(
input: T,
): T & { modelProfile: typeof RECOVERY_MODEL_PROFILE_KEY } {
return {
...input,
modelProfile: RECOVERY_MODEL_PROFILE_KEY,
};
}
export function recoveryAssigneeAdapterOverrides() {
return { modelProfile: RECOVERY_MODEL_PROFILE_KEY };
}
@@ -2,6 +2,7 @@ import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
import type { RunLivenessState } from "@paperclipai/shared";
import { withRecoveryModelProfileHint } from "./model-profile-hint.js";
import { RECOVERY_REASON_KINDS } from "./origins.js";
export const RUN_LIVENESS_CONTINUATION_REASON = RECOVERY_REASON_KINDS.runLivenessContinuation;
@@ -155,7 +156,7 @@ export function decideRunLivenessContinuation(input: {
return { kind: "skip", reason: "continuation wake already exists for this source run and attempt" };
}
const payload = {
const payload = withRecoveryModelProfileHint({
issueId: issue.id,
sourceRunId: run.id,
livenessState,
@@ -165,14 +166,14 @@ export function decideRunLivenessContinuation(input: {
instruction:
nextAction ??
"The previous run ended without concrete progress. Take the first concrete action now or mark the issue blocked with a specific unblock request.",
};
});
return {
kind: "enqueue",
nextAttempt,
idempotencyKey,
payload,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
taskKey: issue.id,
@@ -183,6 +184,6 @@ export function decideRunLivenessContinuation(input: {
livenessContinuationState: livenessState,
livenessContinuationReason: livenessReason,
livenessContinuationInstruction: payload.instruction,
},
}),
};
}
+196 -29
View File
@@ -32,6 +32,13 @@ import { instanceSettingsService } from "../instance-settings.js";
import { issueTreeControlService } from "../issue-tree-control.js";
import { issueService } from "../issues.js";
import { getRunLogStore } from "../run-log-store.js";
import {
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
buildSuccessfulRunHandoffExhaustedNotice,
type SuccessfulRunHandoffNotice,
} from "./successful-run-handoff.js";
import {
RECOVERY_ORIGIN_KINDS,
buildIssueGraphLivenessLeafKey,
@@ -42,6 +49,10 @@ import {
classifyIssueGraphLiveness,
type IssueLivenessFinding,
} from "./issue-graph-liveness.js";
import {
recoveryAssigneeAdapterOverrides,
withRecoveryModelProfileHint,
} from "./model-profile-hint.js";
import { isAutomaticRecoverySuppressedByPauseHold } from "./pause-hold-guard.js";
const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
@@ -76,6 +87,16 @@ type LatestIssueRun = Pick<
> | null;
type SuccessfulLatestIssueRun = NonNullable<LatestIssueRun> & { status: "succeeded" };
type StrandedRecoveryCause = "stranded_assigned_issue" | typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
type SuccessfulRunHandoffRecoveryEvidence = {
sourceRunId: string | null;
correctiveRunId: string;
missingDisposition: string;
handoffAttempt: number;
maxHandoffAttempts: number;
};
type WatchdogDecisionActor =
| { type: "board"; userId?: string | null; runId?: string | null }
| { type: "agent"; agentId?: string | null; runId?: string | null }
@@ -123,6 +144,39 @@ function didAutomaticRecoveryFail(
);
}
function successfulRunHandoffRecoveryEvidence(latestRun: LatestIssueRun): SuccessfulRunHandoffRecoveryEvidence | null {
if (!latestRun) return null;
const context = parseObject(latestRun.contextSnapshot);
const wakeReason = readNonEmptyString(context.wakeReason);
const handoffReason = readNonEmptyString(context.handoffReason);
const isSuccessfulRunHandoff =
wakeReason === FINISH_SUCCESSFUL_RUN_HANDOFF_REASON ||
handoffReason === SUCCESSFUL_RUN_MISSING_STATE_REASON ||
asBoolean(context.handoffRequired, false) === true;
if (!isSuccessfulRunHandoff) return null;
const handoffAttempt = asNumber(context.handoffAttempt, 1);
const maxHandoffAttempts = asNumber(
context.maxHandoffAttempts,
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
);
return {
sourceRunId: readNonEmptyString(context.sourceRunId) ?? readNonEmptyString(context.resumeFromRunId),
correctiveRunId: latestRun.id,
missingDisposition: readNonEmptyString(context.missingDisposition) ?? "clear_next_step",
handoffAttempt,
maxHandoffAttempts,
};
}
function isExhaustedSuccessfulRunHandoff(latestRun: LatestIssueRun) {
const evidence = successfulRunHandoffRecoveryEvidence(latestRun);
if (!evidence) return null;
if (evidence.handoffAttempt < evidence.maxHandoffAttempts) return { ...evidence, exhausted: false };
return { ...evidence, exhausted: true };
}
function issueIdFromRunContext(contextSnapshot: unknown) {
const context = parseObject(contextSnapshot);
return readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
@@ -145,6 +199,11 @@ function runUiLink(run: { id: string; agentId: string }, prefix: string) {
return `[${run.id}](/${prefix}/agents/${run.agentId}/runs/${run.id})`;
}
function agentUiLink(agent: { id: string; name: string | null } | null, prefix: string) {
if (!agent) return "unknown";
return `[${agent.name ?? agent.id}](/${prefix}/agents/${agent.id})`;
}
function formatDuration(ms: number | null) {
if (ms === null) return "unknown";
const minutes = Math.floor(ms / 60_000);
@@ -391,20 +450,20 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "automation",
triggerDetail: "system",
reason: input.reason,
payload: {
payload: withRecoveryModelProfileHint({
issueId: input.issueId,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: input.issueId,
taskId: input.issueId,
wakeReason: input.reason,
retryReason: input.retryReason,
source: input.source,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
},
}),
});
if (queued && input.retryOfRunId) {
@@ -427,18 +486,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: issue.id,
mutation: "assigned_todo_liveness_dispatch",
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
wakeReason: "issue_assigned",
source: "issue.assigned_todo_liveness_dispatch",
},
}),
});
}
@@ -542,18 +601,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "automation",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: candidate.id,
mutation: "unassigned_blocker_recovery",
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: candidate.id,
taskId: candidate.id,
wakeReason: "issue_assigned",
source: "issue.unassigned_blocker_recovery",
},
}),
});
if (queued) {
@@ -995,6 +1054,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
goalId: sourceIssue?.goalId ?? null,
billingCode: sourceIssue?.billingCode ?? null,
assigneeAgentId: ownerAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND,
originId: input.run.id,
originRunId: input.run.id,
@@ -1036,21 +1096,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: evaluation.id,
staleRunId: input.run.id,
sourceIssueId: sourceIssue?.id ?? null,
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: evaluation.id,
taskId: evaluation.id,
wakeReason: "issue_assigned",
source: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND,
staleRunId: input.run.id,
sourceIssueId: sourceIssue?.id ?? null,
},
}),
});
}
return { kind: "created" as const, evaluationIssueId: evaluation.id };
@@ -1294,11 +1354,45 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
prefix: string;
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
sourceAssignee?: Pick<typeof agents.$inferSelect, "id" | "name"> | null;
}) {
const sourceIssue = issueUiLink({ identifier: input.issue.identifier, id: input.issue.id }, input.prefix);
const runLink = input.latestRun
? `[\`${input.latestRun.id}\`](/${input.prefix}/agents/${input.latestRun.agentId}/runs/${input.latestRun.id})`
: "none";
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON) {
const sourceRunId = input.successfulRunHandoffEvidence?.sourceRunId;
const sourceRunLink = sourceRunId && input.latestRun
? `[\`${sourceRunId}\`](/${input.prefix}/agents/${input.latestRun.agentId}/runs/${sourceRunId})`
: "unknown";
const missingDisposition = input.successfulRunHandoffEvidence?.missingDisposition ?? "clear_next_step";
return [
"Paperclip exhausted the bounded corrective handoff for a successful run that still has no valid issue disposition.",
"",
"This is not a runtime/adapter crash report. The source run succeeded; the remaining problem is the missing `done`, `in_review`, `blocked`, delegated follow-up, or explicit continuation path.",
"",
"## Safe Evidence",
"",
`- Source issue: ${sourceIssue}`,
`- Source run: ${sourceRunLink}`,
`- Corrective handoff run: ${runLink}`,
`- Source assignee: ${agentUiLink(input.sourceAssignee ?? null, input.prefix)}`,
`- Latest issue status: \`${input.issue.status}\``,
`- Latest handoff run status: \`${input.latestRun?.status ?? "unknown"}\``,
`- Normalized cause: \`${SUCCESSFUL_RUN_MISSING_STATE_REASON}\``,
`- Missing disposition: \`${missingDisposition}\``,
"- Suggested manager action: choose and record a valid issue disposition without copying transcript content.",
"",
"## Required Action",
"",
"- Inspect the source issue and run metadata, not raw transcript excerpts.",
"- Choose a valid issue disposition: `done`/`cancelled`, `in_review` with an owner, `blocked` with first-class blockers, delegated follow-up work, or an explicit continuation path.",
"- When the source issue has a clear owner and disposition, mark this recovery issue done.",
].join("\n");
}
const retryReason = readNonEmptyString(parseObject(input.latestRun?.contextSnapshot)?.retryReason) ?? "unknown";
const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
@@ -1331,6 +1425,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue: typeof issues.$inferSelect;
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
if (isStrandedIssueRecoveryIssue(input.issue)) return null;
@@ -1341,15 +1437,22 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
if (!ownerAgentId) return null;
const prefix = await getCompanyIssuePrefix(input.issue.companyId);
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue";
let recovery: Awaited<ReturnType<typeof issuesSvc.create>>;
try {
recovery = await issuesSvc.create(input.issue.companyId, {
title: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
title: recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? `Recover missing next step ${input.issue.identifier ?? input.issue.title}`
: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
description: buildStrandedIssueRecoveryDescription({
issue: input.issue,
latestRun: input.latestRun,
previousStatus: input.previousStatus,
prefix,
recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
sourceAssignee,
}),
status: "todo",
priority: input.issue.priority,
@@ -1357,6 +1460,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
projectId: input.issue.projectId,
goalId: input.issue.goalId,
assigneeAgentId: ownerAgentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
originId: input.issue.id,
originRunId: input.latestRun?.id ?? null,
@@ -1364,6 +1468,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
input.issue.companyId,
input.issue.id,
recoveryCause,
input.latestRun?.id ?? "no-run",
].join(":"),
billingCode: input.issue.billingCode,
@@ -1380,21 +1485,23 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: recovery.id,
sourceIssueId: input.issue.id,
strandedRunId: input.latestRun?.id ?? null,
},
recoveryCause,
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: recovery.id,
taskId: recovery.id,
wakeReason: "issue_assigned",
source: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
sourceIssueId: input.issue.id,
strandedRunId: input.latestRun?.id ?? null,
},
recoveryCause,
}),
});
return recovery;
@@ -1512,7 +1619,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue: typeof issues.$inferSelect;
previousStatus: "todo" | "in_progress";
latestRun: LatestIssueRun;
comment: string;
comment?: string;
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
if (isStrandedIssueRecoveryIssue(input.issue)) {
return escalateStrandedRecoveryIssueInPlace({
@@ -1526,6 +1635,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue: input.issue,
previousStatus: input.previousStatus,
latestRun: input.latestRun,
recoveryCause: input.recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
});
const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id);
const nextBlockerIds = recoveryIssue
@@ -1538,10 +1649,29 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
if (!updated) return null;
const prefix = await getCompanyIssuePrefix(input.issue.companyId);
const recoveryOwner = recoveryIssue?.assigneeAgentId ? await getAgent(recoveryIssue.assigneeAgentId) : null;
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
let notice: SuccessfulRunHandoffNotice | null = null;
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON && input.successfulRunHandoffEvidence) {
notice = buildSuccessfulRunHandoffExhaustedNotice({
issue: input.issue,
sourceRun: input.successfulRunHandoffEvidence.sourceRunId
? { id: input.successfulRunHandoffEvidence.sourceRunId, status: "succeeded" }
: null,
correctiveRun: input.latestRun ? { id: input.latestRun.id, status: input.latestRun.status } : null,
sourceAssignee,
recoveryIssue,
recoveryOwner,
latestIssueStatus: input.issue.status,
latestHandoffRunStatus: input.latestRun?.status ?? "unknown",
missingDisposition: input.successfulRunHandoffEvidence.missingDisposition,
});
}
const recoveryLine = recoveryIssue
? [
"",
`- Recovery issue: ${issueUiLink({ identifier: recoveryIssue.identifier, id: recoveryIssue.id }, prefix)}`,
`- Recovery owner: ${agentUiLink(recoveryOwner, prefix)}`,
"- Next action: the recovery owner should either restore a live execution path or record the manual resolution, then mark the recovery issue done.",
].join("\n")
: [
@@ -1550,7 +1680,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
"- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.",
].join("\n");
await issuesSvc.addComment(input.issue.id, `${input.comment}${recoveryLine}`, {});
if (notice) {
await issuesSvc.addComment(input.issue.id, notice.body, {}, {
authorType: "system",
presentation: notice.presentation,
metadata: notice.metadata,
});
} else {
await issuesSvc.addComment(input.issue.id, `${input.comment ?? ""}${recoveryLine}`, {});
}
await logActivity(db, {
companyId: input.issue.companyId,
@@ -1558,14 +1696,19 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
actorId: "system",
agentId: null,
runId: null,
action: "issue.updated",
action: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? "issue.successful_run_handoff_escalated"
: "issue.updated",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier,
status: "blocked",
previousStatus: input.previousStatus,
source: "recovery.reconcile_stranded_assigned_issue",
source: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? "recovery.reconcile_successful_run_handoff_missing_state"
: "recovery.reconcile_stranded_assigned_issue",
recoveryCause: input.recoveryCause ?? "stranded_assigned_issue",
latestRunId: input.latestRun?.id ?? null,
latestRunStatus: input.latestRun?.status ?? null,
latestRunErrorCode: input.latestRun?.errorCode ?? null,
@@ -1596,6 +1739,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
productiveContinuationObserved: 0,
successfulContinuationObserved: 0,
orphanBlockersAssigned: 0,
successfulRunHandoffEscalated: 0,
escalated: 0,
skipped: 0,
issueIds: [] as string[],
@@ -1713,6 +1857,28 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
result.skipped += 1;
continue;
}
const handoffEvidence = isExhaustedSuccessfulRunHandoff(latestRun);
if (handoffEvidence) {
if (!handoffEvidence.exhausted) {
result.skipped += 1;
continue;
}
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "in_progress",
latestRun,
recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
successfulRunHandoffEvidence: handoffEvidence,
});
if (updated) {
result.successfulRunHandoffEscalated += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
if (isSuccessfulInProgressContinuationRun(latestRun)) {
const successfulRun = latestRun;
@@ -2393,6 +2559,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
projectId: recoveryIssue.projectId,
goalId: recoveryIssue.goalId,
assigneeAgentId: ownerSelection.agentId,
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
originKind: RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
originId: input.finding.incidentKey,
originFingerprint: livenessRecoveryLeafFingerprint(input.finding),
@@ -2473,15 +2640,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
payload: {
payload: withRecoveryModelProfileHint({
issueId: escalation.id,
sourceIssueId: issue.id,
recoveryIssueId: recoveryIssue.id,
incidentKey: input.finding.incidentKey,
},
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: {
contextSnapshot: withRecoveryModelProfileHint({
issueId: escalation.id,
taskId: escalation.id,
wakeReason: "issue_assigned",
@@ -2489,7 +2656,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
sourceIssueId: issue.id,
recoveryIssueId: recoveryIssue.id,
incidentKey: input.finding.incidentKey,
},
}),
});
logger.warn({
@@ -0,0 +1,295 @@
import { describe, expect, it } from "vitest";
import {
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
buildFinishSuccessfulRunHandoffIdempotencyKey,
buildSuccessfulRunHandoffExhaustedNotice,
buildSuccessfulRunHandoffRequiredNotice,
decideSuccessfulRunHandoff,
isIdempotentFinishSuccessfulRunHandoffWakeStatus,
isSuccessfulRunHandoffRequiredNoticeBody,
} from "./successful-run-handoff.js";
const run = {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
status: "succeeded",
contextSnapshot: { issueId: "issue-1" },
} as any;
const issue = {
id: "issue-1",
companyId: "company-1",
identifier: "PAP-1",
title: "Finish backend handoff",
status: "in_progress",
assigneeAgentId: "agent-1",
assigneeUserId: null,
executionState: null,
} as any;
const agent = {
id: "agent-1",
companyId: "company-1",
status: "idle",
} as any;
function decide(overrides: Partial<Parameters<typeof decideSuccessfulRunHandoff>[0]> = {}) {
return decideSuccessfulRunHandoff({
run,
issue,
agent,
livenessState: "advanced",
detectedProgressSummary: "Run produced concrete action evidence: 1 issue comment(s)",
taskKey: "issue-1",
hasActiveExecutionPath: false,
hasQueuedWake: false,
hasPendingInteractionOrApproval: false,
hasExplicitBlockerPath: false,
hasOpenRecoveryIssue: false,
hasPauseHold: false,
budgetBlocked: false,
idempotentWakeExists: false,
...overrides,
});
}
describe("successful run handoff decision", () => {
it("queues one corrective handoff wake for a successful progress run without a visible next action", () => {
const decision = decide();
expect(decision.kind).toBe("enqueue");
if (decision.kind !== "enqueue") return;
expect(decision.idempotencyKey).toBe("finish_successful_run_handoff:issue-1:run-1:1");
expect(decision.payload).toMatchObject({
issueId: "issue-1",
sourceRunId: "run-1",
handoffRequired: true,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
missingDisposition: "clear_next_step",
handoffAttempt: 1,
maxHandoffAttempts: 1,
resumeIntent: true,
resumeFromRunId: "run-1",
modelProfile: "cheap",
});
expect(decision.contextSnapshot).toMatchObject({
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
handoffRequired: true,
modelProfile: "cheap",
});
expect(decision.instruction).toContain("Resolve the missing disposition before creating or revising any new artifacts");
expect(decision.instruction).toContain("Choose **exactly one** outcome");
expect(decision.instruction).toContain("record an explicit continuation path");
});
it("does not queue when the issue already has a valid disposition", () => {
expect(decide({ issue: { ...issue, status: "done" } as any })).toEqual({
kind: "skip",
reason: "issue status done is a valid disposition",
});
});
it("does not queue when a successful run records an accepted next-action path", () => {
expect(decide({ issue: { ...issue, status: "in_review" } as any })).toEqual({
kind: "skip",
reason: "issue status in_review is a valid disposition",
});
expect(decide({ issue: { ...issue, status: "blocked" } as any })).toEqual({
kind: "skip",
reason: "issue status blocked is a valid disposition",
});
expect(decide({ hasPendingInteractionOrApproval: true })).toEqual({
kind: "skip",
reason: "pending interaction or approval owns the next action",
});
expect(decide({ hasActiveExecutionPath: true })).toEqual({
kind: "skip",
reason: "issue already has an active execution path",
});
});
it("does not queue when another wake or dependency path already owns the next action", () => {
expect(decide({ hasQueuedWake: true })).toEqual({
kind: "skip",
reason: "issue already has a queued or deferred wake",
});
expect(decide({ hasExplicitBlockerPath: true })).toEqual({
kind: "skip",
reason: "explicit blocker path owns the next action",
});
});
it("does not queue when a successful run has no progress signal", () => {
expect(decide({ livenessState: null, detectedProgressSummary: null })).toEqual({
kind: "skip",
reason: "successful run did not produce handoff-relevant progress",
});
});
it("does not treat adapter or runtime failures as missing-disposition handoffs", () => {
expect(decide({ run: { ...run, status: "failed", errorCode: "adapter_failed" } as any })).toEqual({
kind: "skip",
reason: "source run did not succeed",
});
});
it("does not queue on missing-comment retry bookkeeping runs", () => {
expect(decide({ run: { ...run, issueCommentStatus: "retry_exhausted" } as any })).toEqual({
kind: "skip",
reason: "missing issue comment retry owns the next action",
});
});
it("does not loop from a corrective handoff run", () => {
expect(decide({
run: {
...run,
id: "run-2",
contextSnapshot: {
issueId: "issue-1",
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
handoffRequired: true,
},
} as any,
})).toEqual({
kind: "skip",
reason: "source run is already a corrective handoff run",
});
});
it("does not queue for issue monitor maintenance runs", () => {
expect(decide({
run: {
...run,
contextSnapshot: {
issueId: "issue-1",
source: "issue.monitor",
wakeReason: "issue_monitor_due",
},
} as any,
})).toEqual({
kind: "skip",
reason: "issue monitor run owns its own recovery path",
});
});
it("uses a stable one-attempt idempotency key", () => {
expect(buildFinishSuccessfulRunHandoffIdempotencyKey({
issueId: "issue-1",
sourceRunId: "run-1",
})).toBe("finish_successful_run_handoff:issue-1:run-1:1");
});
it("allows failed or cancelled corrective wakes to be retried", () => {
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("queued")).toBe(true);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("claimed")).toBe(true);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("completed")).toBe(true);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("failed")).toBe(false);
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("cancelled")).toBe(false);
});
it("builds the required system notice with hidden structured metadata", () => {
const notice = buildSuccessfulRunHandoffRequiredNotice({
issue: {
id: "11111111-1111-4111-8111-111111111111",
identifier: "PAP-1",
title: "Finish backend handoff",
status: "in_progress",
} as any,
run: {
id: "22222222-2222-4222-8222-222222222222",
status: "succeeded",
} as any,
agent: {
id: "33333333-3333-4333-8333-333333333333",
name: "CodexCoder",
} as any,
detectedProgressSummary: "Run produced concrete action evidence: 1 issue comment(s)",
});
expect(notice.body).toBe(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
expect(notice.presentation).toEqual({
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
});
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
expect.objectContaining({
title: "Required action",
rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: "PAP-1" }),
expect.objectContaining({ type: "agent_link", name: "CodexCoder" }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "run_link", runId: "22222222-2222-4222-8222-222222222222" }),
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
expect.objectContaining({ type: "key_value", label: "Detected progress" }),
]),
}),
]));
});
it("builds the exhausted system notice with recovery metadata", () => {
const notice = buildSuccessfulRunHandoffExhaustedNotice({
issue: {
id: "11111111-1111-4111-8111-111111111111",
identifier: "PAP-1",
title: "Finish backend handoff",
status: "in_progress",
} as any,
sourceRun: { id: "22222222-2222-4222-8222-222222222222", status: "succeeded" } as any,
correctiveRun: { id: "44444444-4444-4444-8444-444444444444", status: "failed" } as any,
sourceAssignee: { id: "33333333-3333-4333-8333-333333333333", name: "CodexCoder" } as any,
recoveryIssue: {
id: "55555555-5555-4555-8555-555555555555",
identifier: "PAP-2",
title: "Recover missing next step PAP-1",
status: "todo",
} as any,
recoveryOwner: { id: "66666666-6666-4666-8666-666666666666", name: "CTO" } as any,
latestIssueStatus: "in_progress",
latestHandoffRunStatus: "failed",
missingDisposition: "clear_next_step",
});
expect(notice.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
expect(notice.presentation).toMatchObject({
kind: "system_notice",
tone: "danger",
detailsDefaultOpen: false,
});
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
expect.objectContaining({
title: "Recovery owner",
rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: "PAP-2" }),
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CTO" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "run_link", label: "Source run" }),
expect.objectContaining({ type: "run_link", label: "Corrective handoff run" }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
]));
});
it("recognizes new notices and legacy markdown headings for fallback deduplication", () => {
expect(isSuccessfulRunHandoffRequiredNoticeBody(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toBe(true);
expect(isSuccessfulRunHandoffRequiredNoticeBody("## Successful run missing issue disposition\n\nold body")).toBe(true);
expect(isSuccessfulRunHandoffRequiredNoticeBody("## This issue still needs a next step\n\nold body")).toBe(true);
expect(isSuccessfulRunHandoffRequiredNoticeBody("Unrelated comment")).toBe(false);
});
});
@@ -0,0 +1,405 @@
import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
import type { IssueCommentMetadata, IssueCommentPresentation, RunLivenessState } from "@paperclipai/shared";
import { withRecoveryModelProfileHint } from "./model-profile-hint.js";
export const FINISH_SUCCESSFUL_RUN_HANDOFF_REASON = "finish_successful_run_handoff";
export const SUCCESSFUL_RUN_MISSING_STATE_REASON = "successful_run_missing_state";
export const DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS = 1;
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 const LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES = [
"## This issue still needs a next step",
"## Successful run missing issue disposition",
] as const;
export const SUCCESSFUL_RUN_HANDOFF_OPTIONS = [
"mark_done_or_cancelled",
"send_for_review_or_ask_for_input",
"mark_blocked",
"delegate_or_continue_from_checkpoint",
] as const;
const PRODUCTIVE_SUCCESS_LIVENESS_STATES = new Set<RunLivenessState>([
"advanced",
"completed",
"blocked",
"needs_followup",
]);
const IDEMPOTENT_HANDOFF_WAKE_STATUSES = [
"queued",
"deferred_issue_execution",
"claimed",
"completed",
];
const IDEMPOTENT_HANDOFF_WAKE_STATUS_SET = new Set<string>(IDEMPOTENT_HANDOFF_WAKE_STATUSES);
export function isIdempotentFinishSuccessfulRunHandoffWakeStatus(status: string) {
return IDEMPOTENT_HANDOFF_WAKE_STATUS_SET.has(status);
}
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
type IssueRow = Pick<
typeof issues.$inferSelect,
"id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId" | "executionState"
>;
type AgentRow = Pick<typeof agents.$inferSelect, "id" | "companyId" | "status">;
type NoticeIssue = Pick<typeof issues.$inferSelect, "id" | "identifier" | "title" | "status">;
type NoticeRun = Pick<typeof heartbeatRuns.$inferSelect, "id" | "status">;
type NoticeAgent = Pick<typeof agents.$inferSelect, "id" | "name">;
type NullableNoticeAgent = NoticeAgent | null | undefined;
type NullableNoticeIssue = NoticeIssue | null | undefined;
type NullableNoticeRun = NoticeRun | null | undefined;
export type SuccessfulRunHandoffNotice = {
body: string;
presentation: IssueCommentPresentation;
metadata: IssueCommentMetadata;
};
export type SuccessfulRunHandoffDecision =
| {
kind: "enqueue";
idempotencyKey: string;
payload: Record<string, unknown>;
contextSnapshot: Record<string, unknown>;
instruction: string;
}
| {
kind: "skip";
reason: string;
};
function metadataText(value: unknown, fallback = "unknown") {
const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
const resolved = text.length > 0 ? text : fallback;
return resolved.length > 2000 ? `${resolved.slice(0, 1997)}...` : resolved;
}
function keyValueRow(label: string, value: unknown): IssueCommentMetadata["sections"][number]["rows"][number] {
return { type: "key_value", label, value: metadataText(value) };
}
function issueLinkRow(
label: string,
issue: NullableNoticeIssue,
): IssueCommentMetadata["sections"][number]["rows"][number] {
if (!issue) return keyValueRow(label, "unknown");
return {
type: "issue_link",
label,
issueId: issue.id,
identifier: issue.identifier,
title: issue.title,
};
}
function runLinkRow(
label: string,
run: NullableNoticeRun,
): IssueCommentMetadata["sections"][number]["rows"][number] {
if (!run) return keyValueRow(label, "unknown");
return { type: "run_link", label, runId: run.id, title: run.status };
}
function agentLinkRow(
label: string,
agent: NullableNoticeAgent,
): IssueCommentMetadata["sections"][number]["rows"][number] {
if (!agent) return keyValueRow(label, "unknown");
return { type: "agent_link", label, agentId: agent.id, name: agent.name };
}
function systemNoticePresentation(input: {
tone: IssueCommentPresentation["tone"];
title: string;
}): IssueCommentPresentation {
return {
kind: "system_notice",
tone: input.tone,
title: input.title,
detailsDefaultOpen: false,
};
}
export function isSuccessfulRunHandoffRequiredNoticeBody(body: string) {
const trimmed = body.trim();
return trimmed === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY ||
LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
}
export function buildSuccessfulRunHandoffRequiredNotice(input: {
issue: NoticeIssue;
run: NoticeRun;
agent: NoticeAgent;
detectedProgressSummary: string;
}): SuccessfulRunHandoffNotice {
return {
body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
presentation: systemNoticePresentation({
tone: "warning",
title: "Missing issue disposition",
}),
metadata: {
version: 1,
sections: [
{
title: "Required action",
rows: [
issueLinkRow("Source issue", input.issue),
agentLinkRow("Assignee", input.agent),
keyValueRow("Missing disposition", "clear_next_step"),
keyValueRow(
"Valid dispositions",
"done, cancelled, in_review with an owner, blocked with blockers, delegated follow-up, or explicit continuation",
),
],
},
{
title: "Run evidence",
rows: [
runLinkRow("Successful run", input.run),
keyValueRow("Run status", input.run.status),
keyValueRow("Normalized cause", SUCCESSFUL_RUN_MISSING_STATE_REASON),
keyValueRow("Detected progress", input.detectedProgressSummary),
keyValueRow("Automatic retry", "one corrective handoff wake queued"),
],
},
],
},
};
}
export function buildSuccessfulRunHandoffExhaustedNotice(input: {
issue: NoticeIssue;
sourceRun: NullableNoticeRun;
correctiveRun: NullableNoticeRun;
sourceAssignee: NullableNoticeAgent;
recoveryIssue: NullableNoticeIssue;
recoveryOwner: NullableNoticeAgent;
latestIssueStatus: string;
latestHandoffRunStatus: string;
missingDisposition: string;
}): SuccessfulRunHandoffNotice {
return {
body: SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
presentation: systemNoticePresentation({
tone: "danger",
title: "Missing disposition recovery blocked",
}),
metadata: {
version: 1,
sections: [
{
title: "Recovery owner",
rows: [
issueLinkRow("Source issue", input.issue),
issueLinkRow("Recovery issue", input.recoveryIssue),
agentLinkRow("Recovery owner", input.recoveryOwner),
agentLinkRow("Source assignee", input.sourceAssignee),
keyValueRow("Suggested action", "choose and record a valid issue disposition without copying transcript content"),
],
},
{
title: "Run evidence",
rows: [
runLinkRow("Source run", input.sourceRun),
runLinkRow("Corrective handoff run", input.correctiveRun),
keyValueRow("Latest issue status", input.latestIssueStatus),
keyValueRow("Latest handoff run status", input.latestHandoffRunStatus),
keyValueRow("Normalized cause", SUCCESSFUL_RUN_MISSING_STATE_REASON),
keyValueRow("Missing disposition", input.missingDisposition),
],
},
],
},
};
}
export function buildFinishSuccessfulRunHandoffIdempotencyKey(input: {
issueId: string;
sourceRunId: string;
attempt?: number;
}) {
return [
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
input.issueId,
input.sourceRunId,
String(input.attempt ?? 1),
].join(":");
}
export async function findExistingFinishSuccessfulRunHandoffWake(
db: Db,
input: {
companyId: string;
idempotencyKey: string;
},
) {
return db
.select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status })
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, input.companyId),
eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey),
inArray(agentWakeupRequests.status, IDEMPOTENT_HANDOFF_WAKE_STATUSES),
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
}
function readRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isCorrectiveHandoffRun(run: HeartbeatRunRow) {
const context = readRecord(run.contextSnapshot);
return context.handoffRequired === true ||
readString(context.wakeReason) === FINISH_SUCCESSFUL_RUN_HANDOFF_REASON;
}
function isIssueMonitorMaintenanceRun(run: HeartbeatRunRow) {
const context = readRecord(run.contextSnapshot);
const wakeReason = readString(context.wakeReason);
const source = readString(context.source);
return Boolean(wakeReason?.startsWith("issue_monitor") || source?.startsWith("issue.monitor"));
}
function isProductiveSuccessfulRun(input: {
livenessState: RunLivenessState | null;
detectedProgressSummary: string | null;
}) {
if (input.livenessState && PRODUCTIVE_SUCCESS_LIVENESS_STATES.has(input.livenessState)) return true;
return Boolean(input.detectedProgressSummary);
}
export function buildSuccessfulRunHandoffInstruction(input: {
issueIdentifier: string | null;
sourceRunId: string;
}) {
const issueLabel = input.issueIdentifier ?? "this issue";
return [
`Your previous run on ${issueLabel} succeeded, but the issue is still in \`in_progress\` and Paperclip cannot identify a valid issue disposition.`,
"",
"Resolve the missing disposition before creating or revising any new artifacts. Choose **exactly one** outcome and perform the matching Paperclip action:",
"",
"**Is the issue finished?**",
"1. Mark it `done` (scope complete) or `cancelled` (intentionally stopped).",
"",
"**Does someone else need to look at it?**",
"2. Move it to `in_review` with a real reviewer path — `executionState.currentParticipant`, a human owner via `assigneeUserId`, a pending issue-thread interaction, or a linked pending approval.",
"",
"**Can it not continue right now?**",
"3. Mark it `blocked` with first-class blockers (`blockedByIssueIds`) or a clearly named unblock owner/action.",
"",
"**Is there more work to do?**",
`4. Either delegate follow-up work (create/link a follow-up issue and block this one on it, or close this issue if its scope is independently complete) or record an explicit continuation path with \`resumeIntent: true\`, \`resumeFromRunId: ${input.sourceRunId}\`, and a concrete next action.`,
"",
"Comments, document revisions, work-product writes, and continuation summaries are supporting evidence only — they do not satisfy this handoff unless the issue state/path also records one valid disposition.",
].join("\n");
}
export function decideSuccessfulRunHandoff(input: {
run: HeartbeatRunRow;
issue: IssueRow | null;
agent: AgentRow | null;
livenessState: RunLivenessState | null;
detectedProgressSummary: string | null;
taskKey: string | null;
hasActiveExecutionPath: boolean;
hasQueuedWake: boolean;
hasPendingInteractionOrApproval: boolean;
hasExplicitBlockerPath: boolean;
hasOpenRecoveryIssue: boolean;
hasPauseHold: boolean;
budgetBlocked: boolean;
idempotentWakeExists: boolean;
}): SuccessfulRunHandoffDecision {
const { run, issue, agent } = input;
if (run.status !== "succeeded") return { kind: "skip", reason: "source run did not succeed" };
if (isCorrectiveHandoffRun(run)) return { kind: "skip", reason: "source run is already a corrective handoff run" };
if (isIssueMonitorMaintenanceRun(run)) return { kind: "skip", reason: "issue monitor run owns its own recovery path" };
if (run.issueCommentStatus === "retry_queued" || run.issueCommentStatus === "retry_exhausted") {
return { kind: "skip", reason: "missing issue comment retry owns the next action" };
}
if (!issue) return { kind: "skip", reason: "issue not found" };
if (!agent) return { kind: "skip", reason: "agent not found" };
if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) {
return { kind: "skip", reason: "company scope mismatch" };
}
if (issue.assigneeAgentId !== run.agentId) {
return { kind: "skip", reason: "issue is no longer assigned to the source run agent" };
}
if (issue.assigneeUserId) return { kind: "skip", reason: "issue is human-owned" };
if (issue.status !== "in_progress") return { kind: "skip", reason: `issue status ${issue.status} is a valid disposition` };
if (issue.executionState) return { kind: "skip", reason: "issue has execution policy state" };
if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") {
return { kind: "skip", reason: `agent status ${agent.status} is not invokable` };
}
if (!isProductiveSuccessfulRun(input)) {
return { kind: "skip", reason: "successful run did not produce handoff-relevant progress" };
}
if (input.hasActiveExecutionPath) return { kind: "skip", reason: "issue already has an active execution path" };
if (input.hasQueuedWake) return { kind: "skip", reason: "issue already has a queued or deferred wake" };
if (input.hasPendingInteractionOrApproval) {
return { kind: "skip", reason: "pending interaction or approval owns the next action" };
}
if (input.hasExplicitBlockerPath) return { kind: "skip", reason: "explicit blocker path owns the next action" };
if (input.hasOpenRecoveryIssue) return { kind: "skip", reason: "open recovery issue owns the ambiguity" };
if (input.hasPauseHold) return { kind: "skip", reason: "issue is under an active pause hold" };
if (input.budgetBlocked) return { kind: "skip", reason: "budget hard stop blocks corrective wake" };
if (input.idempotentWakeExists) {
return { kind: "skip", reason: "corrective handoff wake already exists for this source run" };
}
const instruction = buildSuccessfulRunHandoffInstruction({
issueIdentifier: issue.identifier,
sourceRunId: run.id,
});
const payload = withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
sourceIssueId: issue.id,
sourceRunId: run.id,
handoffRequired: true,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
missingDisposition: "clear_next_step",
validDispositionOptions: [...SUCCESSFUL_RUN_HANDOFF_OPTIONS],
detectedProgressSummary: input.detectedProgressSummary,
handoffAttempt: 1,
maxHandoffAttempts: DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
resumeIntent: true,
followUpRequested: true,
resumeFromRunId: run.id,
...(input.taskKey ? { taskKey: input.taskKey } : {}),
instruction,
});
return {
kind: "enqueue",
idempotencyKey: buildFinishSuccessfulRunHandoffIdempotencyKey({
issueId: issue.id,
sourceRunId: run.id,
}),
payload,
instruction,
contextSnapshot: withRecoveryModelProfileHint({
...payload,
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
livenessState: input.livenessState,
}),
};
}
+6
View File
@@ -192,6 +192,9 @@ describe("CommentThread", () => {
authorAgentId: null,
authorUserId: "local-board",
body: "Please continue validation.",
authorType: "user",
presentation: null,
metadata: null,
followUpRequested: true,
createdAt: new Date("2026-03-11T10:00:00.000Z"),
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
@@ -349,6 +352,9 @@ describe("CommentThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "Hello from the comment body",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-03-11T11:00:00.000Z"),
updatedAt: new Date("2026-03-11T11:00:00.000Z"),
}]}
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
describe("IssueBlockedNotice", () => {
it("renders a successful-run next-step notice without requiring blockers", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toContain("This issue still needs a next step.");
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
expect(node.textContent).toContain("Detected progress: Updated the plan");
expect(node.textContent).not.toContain("Work on this issue is blocked until");
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
});
});
+83 -29
View File
@@ -1,5 +1,6 @@
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { Link } from "@/lib/router";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
@@ -7,12 +8,17 @@ export function IssueBlockedNotice({
issueStatus,
blockers,
blockerAttention,
successfulRunHandoff,
agentName,
}: {
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
agentName?: string | null;
}) {
if (blockers.length === 0 && issueStatus !== "blocked") return null;
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
@@ -61,39 +67,87 @@ export function IssueBlockedNotice({
return (
<div
data-blocker-attention-state={blockerAttention?.state}
data-successful-run-handoff={showSuccessfulRunHandoff ? "required" : undefined}
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
{showSuccessfulRunHandoff ? (
<>
<p className="font-medium leading-5">This issue still needs a next step.</p>
<p className="leading-5">
A run finished successfully, but this issue is still open in{" "}
<code className="rounded bg-amber-100 px-1 py-0.5 text-[12px] dark:bg-amber-400/15">
in_progress
</code>{" "}
with no clear owner for the next action.
</p>
<ul className="list-disc space-y-1 pl-5 text-xs leading-5 text-amber-900 dark:text-amber-100">
<li>Mark it done or cancelled.</li>
<li>Send it for review or ask for input.</li>
<li>Mark it blocked with a blocker owner.</li>
<li>Delegate follow-up work or queue a continuation.</li>
</ul>
<div className="flex flex-wrap gap-1.5 text-xs">
{successfulRunHandoff.sourceRunId && successfulRunHandoff.assigneeAgentId ? (
<Link
to={`/agents/${successfulRunHandoff.assigneeAgentId}/runs/${successfulRunHandoff.sourceRunId}`}
className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</Link>
) : successfulRunHandoff.sourceRunId ? (
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</span>
) : null}
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 text-amber-900 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
Corrective wake queued for {agentName ?? "the assignee"}
</span>
</div>
{successfulRunHandoff.detectedProgressSummary ? (
<p className="text-xs leading-5 text-amber-800 dark:text-amber-200">
Detected progress: {successfulRunHandoff.detectedProgressSummary}
</p>
) : null}
</>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
<div className="border-t border-amber-300/60 pt-1.5 dark:border-amber-500/30" />
) : null}
{blockers.length > 0 || issueStatus === "blocked" ? (
<>
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
) : null}
</>
) : null}
</div>
</div>
@@ -836,6 +836,9 @@ describe("IssueChatThread", () => {
authorAgentId: "agent-perf-codex",
authorUserId: null,
body: "Older loaded comment",
authorType: "agent" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
};
@@ -1056,6 +1059,9 @@ describe("IssueChatThread", () => {
authorAgentId: "agent-1",
authorUserId: null,
body: "Agent summary with **markdown**",
authorType: "agent" as const,
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
}];
@@ -1135,6 +1141,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "local-board",
body: "Please continue validation.",
authorType: "user",
presentation: null,
metadata: null,
followUpRequested: true,
createdAt: new Date("2026-03-11T10:00:00.000Z"),
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
@@ -1588,6 +1597,9 @@ describe("IssueChatThread", () => {
authorAgentId: "agent-1",
authorUserId: null,
body: "Agent summary",
authorType: "agent",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
}]}
@@ -1624,6 +1636,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "Need a quick update",
authorType: "user",
presentation: null,
metadata: null,
queueState: "queued",
queueReason: "hold",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
@@ -1653,6 +1668,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "Queue behind active run",
authorType: "user",
presentation: null,
metadata: null,
queueState: "queued",
queueReason: "active_run",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
@@ -1997,6 +2015,9 @@ describe("IssueChatThread", () => {
authorAgentId: null,
authorUserId: "user-1",
body: "hello",
authorType: "user",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-22T12:00:00.000Z"),
updatedAt: new Date("2026-04-22T12:00:00.000Z"),
}]}
+187
View File
@@ -36,6 +36,7 @@ import type {
IssueAttachment,
IssueBlockerAttention,
IssueRelationIssueSummary,
SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
@@ -93,6 +94,16 @@ import { formatAssigneeUserLabel } from "../lib/assignees";
import { useOptionalToastActions } from "../context/ToastContext";
import type { CompanyUserProfile } from "../lib/company-members";
import { timeAgo } from "../lib/timeAgo";
import {
isSuccessfulRunHandoffComment,
isSuccessfulRunHandoffEscalationComment,
} from "../lib/successful-run-handoff";
import { SystemNotice } from "./SystemNotice";
import { buildSystemNoticeProps } from "../lib/system-notice-comment";
import type {
IssueCommentMetadata,
IssueCommentPresentation,
} from "@paperclipai/shared";
import {
describeToolInput,
displayToolName,
@@ -264,6 +275,7 @@ interface IssueChatThreadProps {
activeRun?: ActiveRunForIssue | null;
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
companyId?: string | null;
projectId?: string | null;
issueStatus?: string;
@@ -583,6 +595,9 @@ function commentDateLabel(date: Date | string | undefined): string {
const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
const { onImageClick } = useContext(IssueChatCtx);
if (isSuccessfulRunHandoffComment(text)) {
return <SuccessfulRunHandoffCommentCallout text={text} recessed={recessed} onImageClick={onImageClick} />;
}
return (
<MarkdownBody
className="text-sm leading-6"
@@ -595,6 +610,41 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: {
);
});
export function SuccessfulRunHandoffCommentCallout({
text,
recessed,
onImageClick,
}: {
text: string;
recessed?: boolean;
onImageClick?: (src: string) => void;
}) {
const escalated = isSuccessfulRunHandoffEscalationComment(text);
return (
<div
className={cn(
"rounded-md border px-3 py-2.5 text-sm shadow-sm",
escalated
? "border-red-500/35 bg-red-500/10 text-red-950 dark:text-red-100"
: "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",
)}
style={recessed ? { opacity: 0.55 } : undefined}
>
<div className="flex items-start gap-2">
<AlertTriangle
className={cn(
"mt-1 h-4 w-4 shrink-0",
escalated ? "text-red-600 dark:text-red-300" : "text-amber-600 dark:text-amber-300",
)}
/>
<MarkdownBody className="min-w-0 text-sm leading-6" softBreaks onImageClick={onImageClick}>
{text}
</MarkdownBody>
</div>
</div>
);
}
function humanizeValue(value: string | null) {
if (!value) return "None";
return value.replace(/_/g, " ");
@@ -1901,6 +1951,127 @@ function ExpiredRequestConfirmationActivity({
);
}
function isIssueCommentPresentation(value: unknown): value is IssueCommentPresentation {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return v.kind === "system_notice" || v.kind === "message";
}
function isIssueCommentMetadata(value: unknown): value is IssueCommentMetadata {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return v.version === 1 && Array.isArray(v.sections);
}
function SystemNoticeCommentRow({
message,
anchorId,
}: {
message: ThreadMessage;
anchorId?: string;
}) {
const { onImageClick, agentMap } = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const presentation = isIssueCommentPresentation(custom.presentation) ? custom.presentation : null;
const commentMetadata = isIssueCommentMetadata(custom.commentMetadata) ? custom.commentMetadata : null;
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
const runId = typeof custom.runId === "string" ? custom.runId : null;
const authorType = typeof custom.authorType === "string" ? custom.authorType : null;
const authorName = typeof custom.authorName === "string" ? custom.authorName : null;
const bodyText = message.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n\n");
const [copied, setCopied] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const source = (() => {
const runAgentName = runAgentId ? agentMap?.get(runAgentId)?.name ?? null : null;
if (authorType === "system") {
const label = runAgentName ?? "Paperclip";
if (runAgentId && runId) return { label, href: `/agents/${runAgentId}/runs/${runId}` };
return { label };
}
if (runAgentId && runId) {
return { label: authorName ?? runAgentName ?? "Paperclip", href: `/agents/${runAgentId}/runs/${runId}` };
}
if (authorName) return { label: authorName };
return undefined;
})();
const props = buildSystemNoticeProps({
presentation,
metadata: commentMetadata,
body: (
<MarkdownBody className="text-sm leading-6" softBreaks onImageClick={onImageClick}>
{bodyText}
</MarkdownBody>
),
timestamp: message.createdAt ? new Date(message.createdAt).toISOString() : undefined,
source,
runAgentId,
});
const handleCopy = () => {
void navigator.clipboard.writeText(bodyText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const handleCopyLink = () => {
if (!anchorId || typeof window === "undefined") return;
const url = `${window.location.origin}${window.location.pathname}#${anchorId}`;
void navigator.clipboard.writeText(url).then(() => {
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
});
};
return (
<div id={anchorId} className="group">
<div className="py-1">
<SystemNotice {...props} />
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
{anchorId ? (
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy link"
aria-label="Copy link to system notice"
onClick={handleCopyLink}
>
{copiedLink ? <Check className="h-3.5 w-3.5" /> : <Paperclip className="h-3.5 w-3.5" />}
</button>
) : null}
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy notice text"
aria-label="Copy system notice"
onClick={handleCopy}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
</div>
</div>
);
}
function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
const {
agentMap,
@@ -1933,6 +2104,15 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
? custom.interaction
: null;
if (custom.kind === "system_notice") {
return (
<SystemNoticeCommentRow
message={message}
anchorId={anchorId}
/>
);
}
if (custom.kind === "interaction" && interaction) {
if (interaction.kind === "request_confirmation" && interaction.status === "expired") {
return (
@@ -3077,6 +3257,7 @@ export function IssueChatThread({
activeRun = null,
blockedBy = [],
blockerAttention = null,
successfulRunHandoff = null,
companyId,
projectId,
issueStatus,
@@ -3700,6 +3881,12 @@ export function IssueChatThread({
issueStatus={issueStatus}
blockers={unresolvedBlockers}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
agentName={
successfulRunHandoff?.assigneeAgentId
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null
: null
}
/>
<IssueAssigneePausedNotice agent={assignedAgent} />
</div>
@@ -0,0 +1,398 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueChatThread } from "./IssueChatThread";
import type { IssueChatComment } from "../lib/issue-chat-messages";
import type { Agent } from "@paperclipai/shared";
vi.mock("@assistant-ui/react", () => ({
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
useAui: () => ({ thread: () => ({ append: async () => undefined }) }),
}));
vi.mock("./transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: () => ({
transcriptByRun: new Map(),
hasOutputForRun: () => false,
}),
}));
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: () => <textarea aria-label="Issue chat editor" />,
}));
vi.mock("./InlineEntitySelector", () => ({ InlineEntitySelector: () => null }));
vi.mock("./Identity", () => ({ Identity: ({ name }: { name: string }) => <span>{name}</span> }));
vi.mock("./OutputFeedbackButtons", () => ({ OutputFeedbackButtons: () => null }));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("./AgentIconPicker", () => ({ AgentIcon: () => null }));
vi.mock("./StatusBadge", () => ({ StatusBadge: ({ status }: { status: string }) => <span>{status}</span> }));
vi.mock("./IssueLinkQuicklook", () => ({
IssueLinkQuicklook: ({
children,
to,
}: {
children: ReactNode;
to: string;
}) => <a href={to}>{children}</a>,
}));
vi.mock("../hooks/usePaperclipIssueRuntime", () => ({
usePaperclipIssueRuntime: () => ({}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
window.scrollTo = vi.fn();
root = createRoot(container);
});
afterEach(() => {
act(() => root?.unmount());
container.remove();
});
function renderThread(comments: IssueChatComment[], agentMap?: Map<string, Agent>) {
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={comments}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
agentMap={agentMap}
/>
</MemoryRouter>,
);
});
}
const baseTimestamps = {
createdAt: new Date("2026-05-04T16:32:00.000Z"),
updatedAt: new Date("2026-05-04T16:32:00.000Z"),
};
describe("IssueChatThread system notice routing", () => {
it("renders authorType=system comments as a SystemNotice rather than a user bubble", () => {
const comment: IssueChatComment = {
id: "comment-system",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Paperclip needs a disposition before this issue can continue.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
title: "Required action",
rows: [
{ type: "issue_link", label: "Source issue", issueId: "i1", identifier: "PAP-3440", title: "Recovery" },
{ type: "key_value", label: "Status before", value: "in_progress" },
],
},
],
},
...baseTimestamps,
};
renderThread([comment]);
const row = container.querySelector('[data-message-role="system"]');
expect(row).not.toBeNull();
const status = row?.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("Missing issue disposition");
expect(container.textContent).toContain("Paperclip needs a disposition");
// collapsed by default — metadata identifier should not be visible
expect(container.textContent).not.toContain("PAP-3440");
const toggle = row?.querySelector("button[aria-expanded]") as HTMLButtonElement | null;
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelectorAll('[data-message-role="user"]').length).toBe(0);
});
it("expands metadata when detailsDefaultOpen is true", () => {
const comment: IssueChatComment = {
id: "comment-system-open",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Recovery escalated.",
presentation: {
kind: "system_notice",
tone: "danger",
title: null,
detailsDefaultOpen: true,
},
metadata: {
version: 1,
sections: [
{
rows: [
{ type: "agent_link", label: "Owner", agentId: "agent-cto", name: "CTO" },
],
},
],
},
...baseTimestamps,
};
renderThread([comment]);
const status = container.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("System alert");
expect(container.textContent).toContain("CTO");
const toggle = container.querySelector("button[aria-expanded]");
expect(toggle?.getAttribute("aria-expanded")).toBe("true");
});
it("falls back to legacy user bubble + handoff callout for old text-only comments", () => {
const comment: IssueChatComment = {
id: "comment-legacy",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "## Successful run missing issue disposition\n\nFix this.",
presentation: null,
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
const userRow = container.querySelector('[data-message-role="user"]');
expect(userRow).not.toBeNull();
expect(container.textContent).toContain("Successful run missing issue disposition");
});
it("keeps regular user comments rendering as user bubbles", () => {
const comment: IssueChatComment = {
id: "comment-user",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "Standard user message.",
presentation: null,
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="user"]')).not.toBeNull();
expect(container.textContent).toContain("Standard user message.");
});
it("keeps agent-authored comments rendering as assistant bubbles even with system_notice presentation absent", () => {
const comment: IssueChatComment = {
id: "comment-agent",
companyId: "company-1",
issueId: "issue-1",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
body: "Agent reply",
presentation: null,
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="assistant"]')).not.toBeNull();
});
it("labels system notice source as the originating run agent name when runAgentId is available", () => {
const codexAgent = {
id: "agent-codex",
name: "CodexCoder",
} as unknown as Agent;
const agentMap = new Map<string, Agent>([[codexAgent.id, codexAgent]]);
const comment: IssueChatComment = {
id: "comment-system-runagent",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-issue-chat-01",
runAgentId: "agent-codex",
body: "Paperclip needs a disposition before this issue can continue.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment], agentMap);
const status = container.querySelector('[role="status"]');
expect(status).not.toBeNull();
const sourceLink = status?.querySelector('a[href^="/agents/"]') as HTMLAnchorElement | null;
expect(sourceLink?.getAttribute("href")).toBe("/agents/agent-codex/runs/run-issue-chat-01");
expect(sourceLink?.textContent).toBe("CodexCoder");
expect(sourceLink?.textContent).not.toBe("You");
});
it("shows copy-link feedback on the link button only", async () => {
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const comment: IssueChatComment = {
id: "comment-copy-link",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "System recovery completed.",
presentation: {
kind: "system_notice",
tone: "success",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
const copyLink = container.querySelector('button[aria-label="Copy link to system notice"]') as HTMLButtonElement;
const copyText = container.querySelector('button[aria-label="Copy system notice"]') as HTMLButtonElement;
await act(async () => {
copyLink.click();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith(expect.stringContaining("#comment-comment-copy-link"));
expect(copyLink.querySelector(".lucide-check")).not.toBeNull();
expect(copyText.querySelector(".lucide-check")).toBeNull();
});
it("labels system notice source as Paperclip when no run agent can be resolved", () => {
const comment: IssueChatComment = {
id: "comment-system-no-author",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: null,
runAgentId: null,
body: "System recovery completed.",
presentation: {
kind: "system_notice",
tone: "info",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
const status = container.querySelector('[role="status"]');
expect(status).not.toBeNull();
expect(status?.textContent).toContain("Paperclip");
expect(status?.textContent).not.toContain("You");
});
it("falls back to Paperclip in the system notice header when run agent is unknown to agentMap", () => {
const comment: IssueChatComment = {
id: "comment-system-unknown-agent",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-xyz",
runAgentId: "agent-unknown",
body: "Disposition required.",
presentation: {
kind: "system_notice",
tone: "warning",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
const status = container.querySelector('[role="status"]');
const sourceLink = status?.querySelector('a[href^="/agents/"]') as HTMLAnchorElement | null;
expect(sourceLink?.getAttribute("href")).toBe("/agents/agent-unknown/runs/run-xyz");
expect(sourceLink?.textContent).toBe("Paperclip");
});
it("keeps agent-authored comments as assistant bubbles even when presentation requests system_notice", () => {
const comment: IssueChatComment = {
id: "comment-agent-system",
companyId: "company-1",
issueId: "issue-1",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
body: "Reassigned to ClaudeFixer.",
presentation: {
kind: "system_notice",
tone: "neutral",
title: null,
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="assistant"]')).not.toBeNull();
});
});
+11
View File
@@ -66,6 +66,7 @@ import { buildIssueTree, countDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import { statusBadge } from "../lib/status-colors";
import { workflowSort } from "../lib/workflow-sort";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
import { ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
@@ -1528,6 +1529,16 @@ export function IssuesList({
</span>
)
) : null}
{isSuccessfulRunHandoffRequired(issue) ? (
<span
className="ml-1.5 inline-flex items-center gap-1 rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
aria-label="Needs next step"
title="This issue needs a next step"
>
<CircleDot className="h-3 w-3" />
Needs next step
</span>
) : null}
</>
)}
className={isMutedIssue ? "opacity-70" : undefined}
+12
View File
@@ -21,6 +21,8 @@ import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
import type { Issue } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
const boardStatuses = [
"backlog",
@@ -159,6 +161,16 @@ function KanbanCard({
<span className="text-xs text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{isSuccessfulRunHandoffRequired(issue) ? (
<span
className="inline-flex items-center gap-1 rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
title="This issue needs a next step"
aria-label="Needs next step"
>
<AlertTriangle className="h-3 w-3" />
Next step
</span>
) : null}
{isLive && (
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
+197
View File
@@ -0,0 +1,197 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { ReactElement } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { SystemNotice } from "./SystemNotice";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
describe("SystemNotice", () => {
it("renders the warning tone label and body in a single status container", () => {
const node = render(
<SystemNotice
tone="warning"
body="Paperclip needs a disposition before this issue can continue."
/>,
);
const status = node.querySelectorAll('[role="status"]');
expect(status.length).toBe(1);
expect(status[0]?.getAttribute("aria-label")).toBe("System warning");
expect(node.textContent).toContain(
"Paperclip needs a disposition before this issue can continue.",
);
});
it("uses System alert label for danger tone", () => {
const node = render(
<SystemNotice tone="danger" body="Recovery escalated to CTO." />,
);
const status = node.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("System alert");
});
it("uses neutral System notice label by default", () => {
const node = render(
<SystemNotice tone="neutral" body="Reassigned to ClaudeFixer." />,
);
const status = node.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("System notice");
});
it("collapses metadata details by default and toggles aria-expanded on click", () => {
const node = render(
<SystemNotice
tone="warning"
body="Needs a disposition."
metadata={[
{
title: "Required action",
rows: [
{
kind: "issue",
label: "Source issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
},
],
},
]}
/>,
);
const button = node.querySelector("button[aria-expanded]");
expect(button).not.toBeNull();
expect(button?.getAttribute("aria-expanded")).toBe("false");
expect(button?.getAttribute("aria-controls")).not.toBeNull();
expect(node.textContent).not.toContain("PAP-3440");
act(() => {
(button as HTMLButtonElement).click();
});
const reopened = node.querySelector("button[aria-expanded]");
expect(reopened?.getAttribute("aria-expanded")).toBe("true");
expect(node.textContent).toContain("PAP-3440");
});
it("renders metadata expanded when detailsDefaultOpen is true", () => {
const node = render(
<SystemNotice
tone="warning"
body="Needs a disposition."
detailsDefaultOpen
metadata={[
{
rows: [{ kind: "text", label: "Suggested action", value: "Pick a disposition" }],
},
]}
/>,
);
const button = node.querySelector("button[aria-expanded]");
expect(button?.getAttribute("aria-expanded")).toBe("true");
expect(node.textContent).toContain("Suggested action");
expect(node.textContent).toContain("Pick a disposition");
});
it("hides the details affordance when no metadata is provided", () => {
const node = render(<SystemNotice tone="warning" body="Short notice." />);
expect(node.querySelector("button[aria-expanded]")).toBeNull();
});
it("renders typed metadata rows with hrefs when present", () => {
const node = render(
<SystemNotice
tone="danger"
body="Recovery blocked"
detailsDefaultOpen
metadata={[
{
rows: [
{
kind: "issue",
label: "Recovery issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Disposition recovery",
},
{
kind: "agent",
label: "Owner",
name: "CTO",
href: "/PAP/agents/cto",
},
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892",
status: "succeeded",
},
],
},
]}
/>,
);
const links = Array.from(node.querySelectorAll("a")).map((a) => a.getAttribute("href"));
expect(links).toContain("/PAP/issues/PAP-3440");
expect(links).toContain("/PAP/agents/cto");
expect(links).toContain("/PAP/agents/codexcoder/runs/9cdba892");
expect(node.textContent).toContain("PAP-3440");
expect(node.textContent).toContain("Disposition recovery");
expect(node.textContent).toContain("CTO");
expect(node.textContent).toContain("succeeded");
});
it("renders metadata link rows as plain text when href is missing", () => {
const node = render(
<SystemNotice
tone="neutral"
body="Reassigned"
detailsDefaultOpen
metadata={[
{
rows: [
{ kind: "agent", label: "Reassigned to", name: "ClaudeFixer" },
{ kind: "run", label: "Run", runId: "abc12345" },
{ kind: "issue", label: "Issue", identifier: "PAP-1" },
],
},
]}
/>,
);
expect(node.querySelectorAll("a").length).toBe(0);
expect(node.textContent).toContain("ClaudeFixer");
expect(node.textContent).toContain("abc12345");
expect(node.textContent).toContain("PAP-1");
});
});
+337
View File
@@ -0,0 +1,337 @@
import { useId, useState, type ReactNode } from "react";
import {
ChevronDown,
CircleAlert,
CircleCheck,
Info,
OctagonAlert,
TriangleAlert,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type SystemNoticeTone = "neutral" | "info" | "success" | "warning" | "danger";
export type SystemNoticeMetadataRow =
| { kind: "text"; label: string; value: string }
| { kind: "code"; label: string; value: string }
| { kind: "issue"; label: string; identifier: string; href?: string; title?: string }
| { kind: "agent"; label: string; name: string; href?: string }
| { kind: "run"; label: string; runId: string; href?: string; status?: string };
export type SystemNoticeMetadataSection = {
title?: string;
rows: SystemNoticeMetadataRow[];
};
export type SystemNoticeProps = {
tone?: SystemNoticeTone;
/** Short label that names the system actor + tone, e.g. "System warning". Required so tone is not color-only. */
label?: string;
/** Short visible body — one or two sentences from the system perspective. */
body: ReactNode;
/** Optional small chip for the originating run link. */
source?: { label: string; href?: string };
/** Hidden-by-default metadata. Renders the Details affordance only when present. */
metadata?: SystemNoticeMetadataSection[];
/** Force the details panel open initially. Defaults to false (collapsed). */
detailsDefaultOpen?: boolean;
/** Optional ISO timestamp shown next to the label. */
timestamp?: string;
className?: string;
};
type ToneTokens = {
container: string;
iconWrap: string;
icon: LucideIcon;
iconClass: string;
label: string;
divider: string;
};
const TONE_TOKENS: Record<SystemNoticeTone, ToneTokens> = {
neutral: {
container:
"border-border bg-muted/35 dark:bg-muted/20",
iconWrap: "bg-muted text-foreground/70",
icon: Info,
iconClass: "text-muted-foreground",
label: "text-muted-foreground",
divider: "border-border/70",
},
info: {
container:
"border-sky-300/70 bg-sky-50/70 dark:border-sky-500/30 dark:bg-sky-500/10",
iconWrap: "bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200",
icon: Info,
iconClass: "text-sky-700 dark:text-sky-300",
label: "text-sky-800 dark:text-sky-200",
divider: "border-sky-300/50 dark:border-sky-500/30",
},
success: {
container:
"border-emerald-300/70 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10",
iconWrap: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200",
icon: CircleCheck,
iconClass: "text-emerald-700 dark:text-emerald-300",
label: "text-emerald-800 dark:text-emerald-200",
divider: "border-emerald-300/50 dark:border-emerald-500/30",
},
warning: {
container:
"border-amber-300/70 bg-amber-50/80 dark:border-amber-500/30 dark:bg-amber-500/10",
iconWrap: "bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200",
icon: TriangleAlert,
iconClass: "text-amber-700 dark:text-amber-300",
label: "text-amber-900 dark:text-amber-200",
divider: "border-amber-300/60 dark:border-amber-500/30",
},
danger: {
container:
"border-red-400/60 bg-red-50/80 dark:border-red-500/35 dark:bg-red-500/10",
iconWrap: "bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-200",
icon: OctagonAlert,
iconClass: "text-red-700 dark:text-red-300",
label: "text-red-900 dark:text-red-200",
divider: "border-red-400/50 dark:border-red-500/30",
},
};
function formatTimestamp(ts: string) {
try {
return new Date(ts).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return ts;
}
}
function MetadataRow({ row, tone }: { row: SystemNoticeMetadataRow; tone: ToneTokens }) {
return (
<div className="grid grid-cols-[7.5rem_1fr] gap-x-3 gap-y-0.5 px-3 py-1.5 text-xs">
<div className="truncate text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
{row.label}
</div>
<div className="min-w-0 break-words text-foreground/90">
{(() => {
switch (row.kind) {
case "text":
return <span>{row.value}</span>;
case "code":
return (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground/80">
{row.value}
</code>
);
case "issue": {
const issueLabel = (
<>
<span>{row.identifier}</span>
{row.title ? (
<span className="text-muted-foreground"> {row.title}</span>
) : null}
</>
);
if (row.href) {
return (
<a
href={row.href}
className={cn(
"inline-flex items-center gap-1 rounded-sm font-medium underline-offset-2 hover:underline",
tone.label,
)}
>
{issueLabel}
</a>
);
}
return (
<span className={cn("inline-flex items-center gap-1 font-medium", tone.label)}>
{issueLabel}
</span>
);
}
case "agent":
if (row.href) {
return (
<a
href={row.href}
className={cn(
"inline-flex items-center gap-1 rounded-sm font-medium underline-offset-2 hover:underline",
tone.label,
)}
>
{row.name}
</a>
);
}
return (
<span className={cn("font-medium", tone.label)}>{row.name}</span>
);
case "run": {
const runShort = row.runId.length > 12 ? `${row.runId.slice(0, 8)}` : row.runId;
const inner = (
<>
<code className="rounded bg-muted px-1.5 py-0.5 text-foreground/80">{runShort}</code>
{row.status ? (
<span className={cn("font-sans", tone.label)}>{row.status}</span>
) : null}
</>
);
if (row.href) {
return (
<a
href={row.href}
className="inline-flex items-center gap-2 rounded-sm font-mono text-[11px] underline-offset-2 hover:underline"
>
{inner}
</a>
);
}
return (
<span className="inline-flex items-center gap-2 font-mono text-[11px]">
{inner}
</span>
);
}
}
})()}
</div>
</div>
);
}
export function SystemNotice({
tone = "neutral",
label,
body,
source,
metadata,
detailsDefaultOpen = false,
timestamp,
className,
}: SystemNoticeProps) {
const tokens = TONE_TOKENS[tone];
const ToneIcon = tokens.icon;
const [open, setOpen] = useState(detailsDefaultOpen);
const detailsId = useId();
const hasDetails = Boolean(metadata && metadata.length > 0);
const resolvedLabel =
label ??
{
neutral: "System notice",
info: "System notice",
success: "System notice",
warning: "System warning",
danger: "System alert",
}[tone];
return (
<section
role="status"
aria-label={resolvedLabel}
className={cn(
"relative w-full overflow-hidden rounded-lg border text-sm shadow-[0_1px_0_rgba(15,23,42,0.02)]",
tokens.container,
className,
)}
>
<header className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
<span
className={cn(
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
tokens.iconWrap,
)}
aria-hidden
>
<ToneIcon className={cn("h-4 w-4", tokens.iconClass)} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] font-semibold uppercase tracking-[0.14em]">
<span className={tokens.label}>{resolvedLabel}</span>
{source ? (
<>
<span className="text-muted-foreground/60" aria-hidden>·</span>
{source.href ? (
<a
href={source.href}
className="rounded-sm font-medium normal-case tracking-normal text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{source.label}
</a>
) : (
<span className="font-medium normal-case tracking-normal text-muted-foreground">
{source.label}
</span>
)}
</>
) : null}
{timestamp ? (
<>
<span className="text-muted-foreground/60" aria-hidden>·</span>
<span className="font-medium normal-case tracking-normal text-muted-foreground">
{formatTimestamp(timestamp)}
</span>
</>
) : null}
</div>
<div className="mt-1 break-words text-[14px] leading-6 text-foreground">{body}</div>
</div>
{hasDetails ? (
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={detailsId}
className={cn(
"ml-1 inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-transparent px-2 text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground transition-[background-color,border-color,color]",
"hover:border-border/70 hover:bg-background/70 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<span>{open ? "Hide details" : "Details"}</span>
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform duration-150",
open && "rotate-180",
)}
/>
</button>
) : null}
</header>
{hasDetails && open ? (
<div
id={detailsId}
className={cn(
"border-t bg-background/50 dark:bg-background/30",
tokens.divider,
)}
>
<div className="divide-y divide-border/50 px-1 py-1">
{metadata!.map((section, sectionIdx) => (
<div key={sectionIdx} className="py-1.5 first:pt-2 last:pb-2">
{section.title ? (
<div className="px-3 pb-1 pt-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{section.title}
</div>
) : null}
<div>
{section.rows.map((row, rowIdx) => (
<MetadataRow key={rowIdx} row={row} tone={tokens} />
))}
</div>
</div>
))}
</div>
</div>
) : null}
</section>
);
}
export default SystemNotice;
@@ -94,6 +94,7 @@ function createComment(index: number): IssueChatComment {
id: `long-thread-comment-${String(index + 1).padStart(3, "0")}`,
companyId: "company-long-thread",
issueId: "issue-long-thread",
authorType: authorAgentId ? "agent" : "user",
authorAgentId,
authorUserId: authorAgentId ? null : "user-board",
body: isMarkdown
@@ -101,6 +102,8 @@ function createComment(index: number): IssueChatComment {
: authorAgentId
? plainAssistantBody(index + 1)
: plainUserBody(index + 1),
presentation: null,
metadata: null,
createdAt: atMinute(index),
updatedAt: atMinute(index),
};
+5 -1
View File
@@ -43,17 +43,21 @@ function createAgent(
}
function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
return {
const merged: IssueChatComment = {
id: "comment-default",
companyId: "company-ux",
issueId: "issue-ux",
authorType: overrides.authorAgentId ? "agent" : "user",
authorAgentId: null,
authorUserId: "user-1",
body: "",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
};
return merged;
}
const primaryAgent = createAgent("agent-1", "CodexCoder", "code", "codexcoder");
@@ -23,9 +23,12 @@ function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
id: "comment-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
authorType: overrides.authorAgentId ? "agent" : "user",
authorAgentId: null,
authorUserId: issueThreadInteractionFixtureMeta.currentUserId,
body: "",
presentation: null,
metadata: null,
createdAt,
updatedAt: overrides.updatedAt ?? createdAt,
...overrides,
+204
View File
@@ -0,0 +1,204 @@
import type {
SystemNoticeMetadataSection,
SystemNoticeProps,
} from "../components/SystemNotice";
export type SystemNoticeFixture = {
id: string;
caption: string;
} & SystemNoticeProps;
const HANDOFF_METADATA: SystemNoticeMetadataSection[] = [
{
title: "Recovery owner",
rows: [
{
kind: "issue",
label: "Recovery issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Successful run handoff missing disposition",
},
{
kind: "agent",
label: "Owner",
name: "CTO",
href: "/PAP/agents/cto",
},
{
kind: "text",
label: "Suggested action",
value: "Reassign to a recovery agent and pick a disposition.",
},
],
},
{
title: "Run evidence",
rows: [
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
{
kind: "run",
label: "Recovery run",
runId: "61fdb79b-8012-4676-ac71-2971830e126a",
href: "/PAP/agents/codexcoder/runs/61fdb79b-8012-4676-ac71-2971830e126a",
status: "failed",
},
{
kind: "text",
label: "Normalized cause",
value: "Run completed without issuing a disposition for an in_progress task.",
},
],
},
];
const REQUIRED_METADATA: SystemNoticeMetadataSection[] = [
{
title: "Required action",
rows: [
{
kind: "issue",
label: "Source issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Successful run handoff missing disposition",
},
{
kind: "agent",
label: "Assignee",
name: "CodexCoder",
href: "/PAP/agents/codexcoder",
},
{
kind: "text",
label: "Next step",
value: "Pick done, blocked, or in_review and post a one-line rationale.",
},
],
},
{
title: "Run context",
rows: [
{
kind: "run",
label: "Successful run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
{
kind: "code",
label: "Status before",
value: "in_progress",
},
],
},
];
const NEUTRAL_METADATA: SystemNoticeMetadataSection[] = [
{
rows: [
{
kind: "agent",
label: "Reassigned to",
name: "ClaudeFixer",
href: "/PAP/agents/claudefixer",
},
{
kind: "agent",
label: "From",
name: "CodexCoder",
href: "/PAP/agents/codexcoder",
},
{
kind: "text",
label: "Reason",
value: "Manual reassignment requested by Board.",
},
],
},
];
export const systemNoticeFixtures: readonly SystemNoticeFixture[] = [
{
id: "warning-collapsed",
caption: "Warning · collapsed (default)",
tone: "warning",
label: "System warning",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:32:00.000Z",
body: "Paperclip needs a disposition before this issue can continue.",
metadata: REQUIRED_METADATA,
detailsDefaultOpen: false,
},
{
id: "warning-expanded",
caption: "Warning · expanded",
tone: "warning",
label: "System warning",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:32:00.000Z",
body: "Paperclip needs a disposition before this issue can continue.",
metadata: REQUIRED_METADATA,
detailsDefaultOpen: true,
},
{
id: "danger-collapsed",
caption: "Danger · collapsed (default)",
tone: "danger",
label: "System alert",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:48:00.000Z",
body: "Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.",
metadata: HANDOFF_METADATA,
detailsDefaultOpen: false,
},
{
id: "danger-expanded",
caption: "Danger · expanded",
tone: "danger",
label: "System alert",
source: { label: "Paperclip", href: "/PAP/agents" },
timestamp: "2026-05-04T16:48:00.000Z",
body: "Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.",
metadata: HANDOFF_METADATA,
detailsDefaultOpen: true,
},
{
id: "neutral-collapsed",
caption: "Neutral · collapsed (default)",
tone: "neutral",
label: "System notice",
source: { label: "Paperclip" },
timestamp: "2026-05-04T15:10:00.000Z",
body: "Reassigned to ClaudeFixer.",
metadata: NEUTRAL_METADATA,
detailsDefaultOpen: false,
},
{
id: "neutral-expanded",
caption: "Neutral · expanded",
tone: "neutral",
label: "System notice",
source: { label: "Paperclip" },
timestamp: "2026-05-04T15:10:00.000Z",
body: "Reassigned to ClaudeFixer.",
metadata: NEUTRAL_METADATA,
detailsDefaultOpen: true,
},
{
id: "warning-no-details",
caption: "Warning · no metadata (Details affordance hidden)",
tone: "warning",
label: "System warning",
source: { label: "Paperclip" },
timestamp: "2026-05-04T17:02:00.000Z",
body: "This run paused while waiting on board approval.",
},
];
+9
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",
);
});
});
+6
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",
+4
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,
+16 -1
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,
@@ -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"),
};
+3
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,
+37
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");
});
});
+90
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",
};
}
+143
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);
});
});
+125
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,
};
}
+28 -9
View File
@@ -104,8 +104,14 @@ import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key"
import { shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues";
import { filterIssueDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import {
SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION,
successfulRunHandoffActivityTone,
} from "../lib/successful-run-handoff";
import {
Activity as ActivityIcon,
AlertTriangle,
Archive,
ArrowLeft,
Check,
@@ -578,6 +584,7 @@ type IssueDetailChatTabProps = {
executionRunId: string | null;
blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null;
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
comments: IssueDetailComment[];
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[];
@@ -635,6 +642,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
executionRunId,
blockedBy,
blockerAttention,
successfulRunHandoff,
comments,
locallyQueuedCommentRunIds,
interactions,
@@ -836,6 +844,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
activeRun={resolvedActiveRun}
blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
@@ -1065,16 +1074,25 @@ function IssueDetailActivityTab({
agentMap={agentMap}
hasLiveRuns={hasLiveRuns}
activityEvents={activity ?? []}
renderActivityEvent={(evt) => (
<div className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
renderActivityEvent={(evt) => {
const tone = successfulRunHandoffActivityTone(evt.action);
const isHandoffWarning =
evt.action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION
|| evt.action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION;
return (
<div className={cn("space-y-1.5 rounded-lg border px-3 py-2 text-xs", tone.className)}>
<div className="flex items-center gap-1.5">
{isHandoffWarning ? (
<AlertTriangle className={cn("h-3.5 w-3.5 shrink-0", tone.iconClassName)} />
) : null}
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
<IssueReferenceActivitySummary event={evt} />
</div>
<IssueReferenceActivitySummary event={evt} />
</div>
)}
);
}}
/>
</div>
{linkedApprovals && linkedApprovals.length > 0 && (
@@ -3662,6 +3680,7 @@ export function IssueDetail() {
executionRunId={issue.executionRunId ?? null}
blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null}
successfulRunHandoff={issue.successfulRunHandoff ?? null}
comments={threadComments}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
+403
View File
@@ -0,0 +1,403 @@
import type { ReactNode } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { SystemNotice } from "@/components/SystemNotice";
import { systemNoticeFixtures } from "@/fixtures/systemNoticeFixtures";
import { cn } from "@/lib/utils";
import {
CircleDashed,
FlaskConical,
Layers,
ListChecks,
Sparkles,
} from "lucide-react";
function LabSection({
id,
eyebrow,
title,
description,
accentClassName,
children,
}: {
id?: string;
eyebrow: string;
title: string;
description: string;
accentClassName?: string;
children: ReactNode;
}) {
return (
<section
id={id}
className={cn(
"rounded-[28px] border border-border/70 bg-background/85 p-4 shadow-[0_24px_60px_rgba(15,23,42,0.08)] sm:p-5",
accentClassName,
)}
>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
{eyebrow}
</div>
<h2 className="mt-1 text-xl font-semibold tracking-tight">{title}</h2>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
</div>
</div>
{children}
</section>
);
}
function FixtureFrame({ caption, children }: { caption: string; children: ReactNode }) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<CircleDashed className="h-3.5 w-3.5" />
{caption}
</div>
{children}
</div>
);
}
function MockUserBubble({
authorName,
body,
alignEnd,
}: {
authorName: string;
body: string;
alignEnd?: boolean;
}) {
return (
<div className={cn("flex items-start gap-2.5", alignEnd && "justify-end")}>
{!alignEnd ? (
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
) : null}
<div className={cn("flex min-w-0 max-w-[85%] flex-col", alignEnd && "items-end")}>
<div
className={cn(
"mb-1 px-1 text-sm font-medium text-foreground",
alignEnd ? "text-right" : "text-left",
)}
>
{authorName}
</div>
<div className="min-w-0 max-w-full rounded-2xl bg-muted px-4 py-2.5 text-sm leading-6 text-foreground">
{body}
</div>
</div>
{alignEnd ? (
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
) : null}
</div>
);
}
function MockAgentBubble({ agentName, body }: { agentName: string; body: string }) {
return (
<div className="flex items-start gap-2.5">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{agentName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 max-w-[85%] flex-col">
<div className="mb-1 px-1 text-sm font-medium text-foreground">{agentName}</div>
<div className="min-w-0 max-w-full rounded-2xl border border-border/70 bg-background px-4 py-2.5 text-sm leading-6 text-foreground">
{body}
</div>
</div>
</div>
);
}
const checklist = [
"One container per system notice — no nested chat bubble",
"Tone communicated by icon + label, never color alone",
"Operational evidence hidden behind Details, expanded only on demand",
"Issue, agent, and run metadata render as typed link rows, not raw markdown",
"Hierarchy visibly distinct from user (right-aligned) and agent (left-aligned) bubbles",
];
export function SystemNoticeUxLab() {
const fixtureById = new Map(systemNoticeFixtures.map((f) => [f.id, f] as const));
const warningCollapsed = fixtureById.get("warning-collapsed")!;
const warningExpanded = fixtureById.get("warning-expanded")!;
const dangerCollapsed = fixtureById.get("danger-collapsed")!;
const dangerExpanded = fixtureById.get("danger-expanded")!;
const neutralCollapsed = fixtureById.get("neutral-collapsed")!;
const neutralExpanded = fixtureById.get("neutral-expanded")!;
const warningNoDetails = fixtureById.get("warning-no-details")!;
return (
<div className="space-y-6">
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-[linear-gradient(135deg,rgba(245,158,11,0.10),transparent_28%),linear-gradient(180deg,rgba(8,145,178,0.08),transparent_44%),var(--background)] shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr)_320px]">
<div className="p-6 sm:p-7">
<div className="inline-flex items-center gap-2 rounded-full border border-amber-500/25 bg-amber-500/[0.08] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-300">
<FlaskConical className="h-3.5 w-3.5" />
System Notice Lab
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight">
First-class system notice treatment
</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
Replaces the current pattern where a Paperclip-authored warning renders inside a user-style
chat bubble. The notice is one container, system-styled, with hidden-by-default operational
metadata. Tone is conveyed by icon, label, and color together so it stays accessible.
</p>
<div className="mt-5 flex flex-wrap items-center gap-2">
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
PAP-3525 plan
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
phase 1 UX
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
tones: warning · danger · neutral
</Badge>
</div>
</div>
<aside className="border-t border-border/60 bg-background/70 p-6 lg:border-l lg:border-t-0">
<div className="mb-4 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
<ListChecks className="h-4 w-4 text-amber-700 dark:text-amber-300" />
What this lab proves
</div>
<div className="space-y-3">
{checklist.map((line) => (
<div
key={line}
className="rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-muted-foreground"
>
{line}
</div>
))}
</div>
</aside>
</div>
</div>
<LabSection
id="tones"
eyebrow="Tone matrix"
title="Three tones, two states"
description="Each tone pairs a unique icon and tone label so the notice is recognizable without color. Collapsed is the default; the Details affordance reveals operational metadata only when reviewers ask for it."
accentClassName="bg-[linear-gradient(180deg,rgba(245,158,11,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-5">
<FixtureFrame caption={warningCollapsed.caption}>
<SystemNotice {...warningCollapsed} />
</FixtureFrame>
<FixtureFrame caption={warningExpanded.caption}>
<SystemNotice {...warningExpanded} />
</FixtureFrame>
<FixtureFrame caption={dangerCollapsed.caption}>
<SystemNotice {...dangerCollapsed} />
</FixtureFrame>
<FixtureFrame caption={dangerExpanded.caption}>
<SystemNotice {...dangerExpanded} />
</FixtureFrame>
<FixtureFrame caption={neutralCollapsed.caption}>
<SystemNotice {...neutralCollapsed} />
</FixtureFrame>
<FixtureFrame caption={neutralExpanded.caption}>
<SystemNotice {...neutralExpanded} />
</FixtureFrame>
<FixtureFrame caption={warningNoDetails.caption}>
<SystemNotice {...warningNoDetails} />
</FixtureFrame>
</div>
</LabSection>
<LabSection
id="hierarchy"
eyebrow="Hierarchy in thread"
title="Distinct from user and agent comments"
description="Side-by-side with adjacent comment types so reviewers can confirm the system row reads as a system row — full width, no avatar gutter, no chat bubble — while user and agent comments keep their existing rounded bubbles."
accentClassName="bg-[linear-gradient(180deg,rgba(8,145,178,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/70 p-4">
<MockUserBubble
authorName="Riley Board"
body="Why does this issue keep waking back up without a clear next step?"
alignEnd
/>
<MockAgentBubble
agentName="CodexCoder"
body="The previous run completed without picking a disposition. I'll wait for the new system notice to surface so the recovery owner is unambiguous."
/>
<SystemNotice
tone="danger"
label="System alert"
source={{ label: "Paperclip", href: "/PAP/agents" }}
timestamp="2026-05-04T16:48:00.000Z"
body="Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner."
metadata={[
{
title: "Recovery owner",
rows: [
{
kind: "issue",
label: "Recovery issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Successful run handoff missing disposition",
},
{
kind: "agent",
label: "Owner",
name: "CTO",
href: "/PAP/agents/cto",
},
],
},
{
title: "Run evidence",
rows: [
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
],
},
]}
/>
<MockUserBubble
authorName="Riley Board"
body="Thanks — assigning the recovery owner now."
alignEnd
/>
</div>
</LabSection>
<div className="grid gap-5 xl:grid-cols-2">
<LabSection
eyebrow="Before"
title="Today's nested treatment"
description="The same content rendered through the existing user-bubble + warning-callout path. Two containers, same gray background as user comments, and the warning icon is forced inside a chat row."
accentClassName="bg-[linear-gradient(180deg,rgba(244,63,94,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-3 rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="flex items-start gap-2.5">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>YO</AvatarFallback>
</Avatar>
<div className="flex min-w-0 max-w-[85%] flex-col">
<div className="mb-1 px-1 text-sm font-medium text-foreground">You</div>
<div className="min-w-0 max-w-full rounded-2xl bg-muted px-4 py-2.5 text-sm leading-6 text-foreground">
<div className="rounded-md border border-red-500/35 bg-red-500/10 px-3 py-2.5 text-sm text-red-950 dark:text-red-100">
<div className="flex items-start gap-2">
<Sparkles className="mt-1 h-4 w-4 shrink-0 text-red-600 dark:text-red-300" />
<div className="min-w-0">
<p className="m-0 font-semibold">Successful run handoff missing</p>
<ul className="mt-1.5 list-disc space-y-0.5 pl-4 text-[13px] leading-5">
<li>Source issue: PAP-3440</li>
<li>Source run: 9cdba892-c7ca-4d93-8604-4843873b127c</li>
<li>Recovery run: 61fdb79b-8012-4676-ac71-2971830e126a</li>
<li>Status before: in_progress</li>
<li>Normalized cause: Run completed without disposition</li>
<li>Recovery owner: CTO</li>
<li>Suggested action: Reassign to recovery agent</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<p className="px-1 text-xs text-muted-foreground">
Author reads as <span className="font-medium text-foreground">You</span> even though the
author is the Paperclip system. Two containers stack the warning inside a user-style
bubble, and operational evidence is always visible.
</p>
</div>
</LabSection>
<LabSection
eyebrow="After"
title="System notice replacement"
description="One container, system-authored label, hidden details. The chat surface keeps user and agent bubbles unchanged."
accentClassName="bg-[linear-gradient(180deg,rgba(16,185,129,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-3 rounded-2xl border border-border/70 bg-background/70 p-4">
<SystemNotice {...dangerCollapsed} />
<p className="px-1 text-xs text-muted-foreground">
Same content. The visible body is one short system sentence; reviewers expand{" "}
<span className="font-medium text-foreground">Details</span> only when they need run
evidence. Tone is reinforced by the octagon icon and the &quot;System alert&quot; label,
not just red.
</p>
</div>
</LabSection>
</div>
<Card className="gap-4 border-border/70 bg-background/85 py-0">
<CardHeader className="px-5 pt-5 pb-0">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<Layers className="h-4 w-4 text-amber-700 dark:text-amber-300" />
Implementation notes
</div>
<CardTitle className="text-lg">Handoff to engineering</CardTitle>
<CardDescription>
What the Phase 4 UI implementation should preserve from this design.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-5 pt-0 text-sm text-muted-foreground">
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Component</div>
Use <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">{`<SystemNotice />`}</code>{" "}
from <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">@/components/SystemNotice</code>.
It accepts <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">tone</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">label</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">body</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">metadata</code>, and{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">detailsDefaultOpen</code>.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Routing in IssueChatThread</div>
Comments where{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">authorType === &quot;system&quot;</code>{" "}
or{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">presentation.kind === &quot;system_notice&quot;</code>{" "}
should render as a SystemNotice row at full content width never inside an{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">IssueChatUserMessage</code>{" "}
or assistant bubble.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Accessibility</div>
The Details button has{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-expanded</code>{" "}
and{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-controls</code>{" "}
wired to the panel id. The container exposes{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">role=&quot;status&quot;</code>{" "}
and an{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-label</code>{" "}
equal to the visible tone label so screen readers announce tone with text.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Legacy fallback</div>
Existing comments without{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">presentation</code>{" "}
keep rendering through the current{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">SuccessfulRunHandoffCommentCallout</code>{" "}
string-detector. The new contract is opt-in for the system generators in Phase 5.
</div>
</CardContent>
</Card>
</div>
);
}
export default SystemNoticeUxLab;
@@ -84,6 +84,7 @@ function ScenarioCard({
function createComment(overrides: Partial<StoryComment>): StoryComment {
const createdAt = overrides.createdAt ?? new Date("2026-04-20T14:00:00.000Z");
const authorAgentId = overrides.authorAgentId ?? null;
return {
id: "comment-default",
companyId,
@@ -91,6 +92,9 @@ function createComment(overrides: Partial<StoryComment>): StoryComment {
authorAgentId: null,
authorUserId: currentUserId,
body: "",
authorType: authorAgentId ? "agent" : "user",
presentation: null,
metadata: null,
createdAt,
updatedAt: overrides.updatedAt ?? createdAt,
...overrides,
@@ -384,6 +388,42 @@ const issueChatComments: IssueChatComment[] = [
runId: "run-issue-chat-01",
runAgentId: codexAgent.id,
}),
createComment({
id: "comment-issue-system-warning",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-issue-chat-01",
runAgentId: codexAgent.id,
body: "Paperclip needs a disposition before this issue can continue.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
title: "Required action",
rows: [
{ type: "issue_link", label: "Source issue", issueId: issueId, identifier: "PAP-3440", title: "Successful run handoff" },
{ type: "agent_link", label: "Assignee", agentId: codexAgent.id, name: codexAgent.name },
{ type: "key_value", label: "Status before", value: "in_progress" },
],
},
{
title: "Run evidence",
rows: [
{ type: "run_link", label: "Successful run", runId: "run-issue-chat-01", title: "succeeded" },
{ type: "key_value", label: "Normalized cause", value: "Run completed without disposition" },
],
},
],
},
createdAt: new Date("2026-04-20T13:54:00.000Z"),
}),
createComment({
id: "comment-issue-queued",
body: "@QAChecker please do a quick visual pass after the Storybook build is green.",
@@ -0,0 +1,196 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
import { KanbanBoard } from "@/components/KanbanBoard";
import { SuccessfulRunHandoffCommentCallout } from "@/components/IssueChatThread";
import { Identity } from "@/components/Identity";
import { cn, relativeTime } from "@/lib/utils";
import { formatIssueActivityAction } from "@/lib/activity-format";
import {
SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION,
SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION,
successfulRunHandoffActivityTone,
} from "@/lib/successful-run-handoff";
import { createIssue, storybookAgents } from "../fixtures/paperclipData";
function ActivityExample({ action }: { action: string }) {
const tone = successfulRunHandoffActivityTone(action);
const isWarning = action !== SUCCESSFUL_RUN_HANDOFF_RESOLVED_ACTION;
return (
<div className={cn("space-y-1.5 rounded-lg border px-3 py-2 text-xs", tone.className)}>
<div className="flex items-center gap-1.5">
{isWarning ? <AlertTriangle className={cn("h-3.5 w-3.5 shrink-0", tone.iconClassName)} /> : null}
<Identity name="System" size="sm" />
<span>{formatIssueActivityAction(action)}</span>
<span className="ml-auto shrink-0">{relativeTime(new Date(Date.now() - 3 * 60_000))}</span>
</div>
</div>
);
}
function SuccessfulRunHandoffStates() {
return (
<StoryFrame>
<section className="grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
<PinnedNoticePanel />
<ActivityEventsPanel />
</section>
<section className="grid gap-4 lg:grid-cols-[0.9fr_1.1fr]">
<IssueCardPanel />
<EscalationCommentPanel />
</section>
</StoryFrame>
);
}
function handoffIssue() {
return createIssue({
id: "issue-handoff",
identifier: "PAP-3053",
issueNumber: 3053,
title: "Add board-visible handoff affordances and activity copy",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-codex",
successfulRunHandoff: {
state: "required",
required: true,
sourceRunId: "9cdba892-c7ca-4d93-8604-4843873b127c",
correctiveRunId: "61fdb79b-8012-4676-ac71-2971830e126a",
assigneeAgentId: "agent-codex",
detectedProgressSummary: "Updated the plan and created the first implementation notes.",
createdAt: new Date(),
},
});
}
function StoryFrame({ children, title = "Board-visible handoff states" }: { children: ReactNode; title?: string }) {
return (
<main className="min-h-screen bg-background p-4 text-foreground sm:p-8">
<div className="mx-auto max-w-6xl space-y-5">
<div>
<div className="text-xs font-medium uppercase text-muted-foreground">Successful-run next-step review</div>
<h1 className="mt-1 text-2xl font-semibold">{title}</h1>
</div>
{children}
</div>
</main>
);
}
function PinnedNoticePanel() {
const issue = handoffIssue();
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">A. Pinned needs-next-step notice</div>
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
successfulRunHandoff={issue.successfulRunHandoff}
agentName="CodexCoder"
/>
</div>
);
}
function ActivityEventsPanel() {
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">B. Activity stream events</div>
<div className="space-y-2">
<ActivityExample action={SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION} />
<ActivityExample action="issue.successful_run_handoff_resolved" />
<ActivityExample action={SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION} />
</div>
</div>
);
}
function IssueCardPanel() {
const issue = handoffIssue();
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">C. Issue card indicator</div>
<KanbanBoard
issues={[
issue,
createIssue({
id: "issue-review",
identifier: "PAP-3054",
issueNumber: 3054,
title: "Review completed next-step recovery",
status: "in_review",
priority: "high",
assigneeAgentId: "agent-cto",
}),
]}
agents={storybookAgents}
onUpdateIssue={() => {}}
/>
</div>
);
}
function EscalationCommentPanel() {
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-3 text-sm font-medium text-muted-foreground">D. Escalation comment callout</div>
<SuccessfulRunHandoffCommentCallout
text={[
"Paperclip exhausted the bounded successful-run handoff correction for this issue, but it still has no valid issue disposition.",
"",
"This is not a runtime/adapter crash report. The source run succeeded; the remaining problem is the missing `done`, `in_review`, `blocked`, delegated follow-up, or explicit continuation path.",
"",
"- Source issue: [PAP-3053](/PAP/issues/PAP-3053)",
"- Source run: [`9cdba892-c7ca-4d93-8604-4843873b127c`](/PAP/agents/agent-codex/runs/9cdba892-c7ca-4d93-8604-4843873b127c)",
"- Corrective handoff run: [`61fdb79b-8012-4676-ac71-2971830e126a`](/PAP/agents/agent-codex/runs/61fdb79b-8012-4676-ac71-2971830e126a)",
"- Source assignee: [CodexCoder](/PAP/agents/codexcoder)",
"- Latest issue status: `in_progress`",
"- Latest handoff run status: `succeeded`",
"- Normalized cause: `successful_run_missing_state`",
"- Missing disposition: `no_clear_next_step`",
"- Suggested manager action: choose and record a valid issue disposition without copying transcript content.",
"",
"Moving it to `blocked` with an explicit recovery owner so the missing disposition is visible and owned.",
].join("\n")}
/>
</div>
);
}
function SuccessfulRunHandoffPinnedNotice() {
return <StoryFrame title="Pinned needs-next-step notice"><PinnedNoticePanel /></StoryFrame>;
}
function SuccessfulRunHandoffActivityEvents() {
return <StoryFrame title="Activity stream events"><ActivityEventsPanel /></StoryFrame>;
}
function SuccessfulRunHandoffIssueCard() {
return <StoryFrame title="Issue card indicator"><IssueCardPanel /></StoryFrame>;
}
function SuccessfulRunHandoffEscalationComment() {
return <StoryFrame title="Escalation comment callout"><EscalationCommentPanel /></StoryFrame>;
}
const meta = {
title: "Paperclip/Successful Run Handoff",
component: SuccessfulRunHandoffStates,
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof SuccessfulRunHandoffStates>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllStates: Story = {};
export const PinnedNotice: Story = { render: () => <SuccessfulRunHandoffPinnedNotice /> };
export const ActivityEvents: Story = { render: () => <SuccessfulRunHandoffActivityEvents /> };
export const IssueCardIndicator: Story = { render: () => <SuccessfulRunHandoffIssueCard /> };
export const EscalationComment: Story = { render: () => <SuccessfulRunHandoffEscalationComment /> };
+18
View File
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { IssueChatUxLab } from "@/pages/IssueChatUxLab";
import { InviteUxLab } from "@/pages/InviteUxLab";
import { RunTranscriptUxLab } from "@/pages/RunTranscriptUxLab";
import { SystemNoticeUxLab } from "@/pages/SystemNoticeUxLab";
function StoryFrame({ children }: { children: React.ReactNode }) {
return (
@@ -61,6 +62,23 @@ export const RunTranscriptFixtures: Story = {
},
};
export const SystemNoticeTreatment: Story = {
name: "System Notice Treatment",
render: () => (
<StoryFrame>
<SystemNoticeUxLab />
</StoryFrame>
),
parameters: {
docs: {
description: {
story:
"Renders the first-class system notice (PAP-3525 plan): warning, danger, and neutral tones in collapsed and expanded states, an in-thread hierarchy comparison against user and agent bubbles, and a before/after replacement of the current nested user-bubble + warning-callout pattern.",
},
},
},
};
export const InviteAndAccessFlow: Story = {
name: "Invite And Access Flow",
render: () => (