Files
PaperClipAI/ui/src/lib/issue-chat-messages.ts
T
DottaandPaperclip 27d35314be feat: connect experimental Cursor Cloud to native Runner (#18069)
## Thinking Path

> - Paperclip helps people manage AI agents at work.
> - Native Runner owns execution and task authority.
> - Operators need an explicit native Cursor Cloud choice.
> - Tools/files need company, task, session and run scope.
> - This connects setup and lifecycle to Runner Core.
> - Legacy agents retain their selected runtime/history.

## Linked Issues or Issue Description

Depends on #18068. Managed-only API placement and API-owned Stop
containment are in #18310. Refs #5664, #11803, #17299, and #17471. No
duplicate native integration was found.

## What Changed

- Add explicit native setup, Test, saved settings and dispatch with
no-prompt account/catalog/callback checks.
- Scope MCP to company, agent, task, session and run through
authenticated HTTPS. Register verified artifacts/work products; preserve
unpriced accounting, completion and budgets.
- Connect Question/Plan continuation, cold handoff and admitted fresh
sessions; recheck waiting admission under the task lock.
- Present audited Stop intent while preserving physical containment
proof; bound typed Stop metadata. Preserve title edits through
preference/navigation readiness.
- Select one live/history representation per run to prevent duplicate
settled cards. Integrate upstream task/read-state/retry/schema contracts
and repair two test fixtures.

## Verification

Core #18068 merged normally as `3910352c923a`. Product head
`5a60f541c982` has passing repository CI/build/browser checks, including
required `ci / verify` and `ci / e2e`, plus a fresh exact-head Greptile
5/5 review with zero unresolved threads. Two non-required external app
checks remain pending: Paperclip Review is queued waiting for its
assigned agent; Superagent Security Scan is reviewing the diff. These
are not claimed completed. The normal merge endpoint enforces the
repository rules; no policy or status override is used. The dependent
Top PR is addressing its fresh deferred-Stop review finding.

Product `5a60f541c982` passed 270 UI and 20 selected
Cloud/native/disk-full tests, UI/server typechecks, tokens and maps.
Top’s clean normal merge passed tokens/maps/diff checks; its matrix is
pending. Earlier receipts retain Core’s 47 Rust, 4 compiled-native
retirement and 19 settlement controls, normal Runner build, and runtime
9af build/recursive typecheck. Reproduced loading/shutdown CI fixture
failures passed 7/7 browser and 121/121 unit tests, including a
broken-unsubscribe negative control. No timeout/oracle was relaxed.

The actual-parent restack adopts 75 mainline paths, including
schema/permission and generic runtime changes, beyond the prior two
fixture repairs and client projection fix. The full difference from
hosted `9af1a5de42a2` is therefore larger than four files; backend
source also differs. The merged config-read checks match Core391, and
the Cursor placement/runtime/relay/selection modules match frozen9af;
selected affected regressions pass. Paid proof keeps its actual 9af
source/image identity. The separately tested live/history card fix is
not claimed deployed on that host.

On 9af, original saved settings survived reopening and fresh no-prompt
Test authenticated catalog/model/binding; no new Save is claimed. Fresh
Linux admission passed. With `claude-sonnet-4-5`, Artifact downloaded
exact 27 bytes and matched 121 canonical identities in complete
S3-served history. First Stop’s strict quiet FAILED on a 10.373-second
gap despite independent cancellation/no late artifact. One targeted
unchanged-sampler retry retained all 24 samples: 35.076 seconds quiet /
maximum gap 4.584 seconds, 67 ordered canonical identities, and separate
CANCELLED/artifact-absent status after the original 180-second deadline.
The pre-reload duplicate settled card is disclosed. Exact-ID cleanup for
the three new provider agents is awaiting authorization, so full grades
and the fresh Artifact’s post-cleanup download remain blocked. A
historical deleted-provider artifact was newly downloaded on 9af;
original F226 Question/Plan/reconnect and cleanup proof keep their
identities.

Retained failures: stale fixture 132/133; E8 48/135 with 87 reset-hook
failures; Mac 17 SQL failures/skips; earlier
title/startup/custody/Stop/deletion failures with unknown causes/bounded
recoveries; `dea023` Stop’s warning/50 duplicate identities; pre-repair
ACK and original Core CI failures. WSP’s 251-assertion, 7/8-suite
attempt failed physical PostgreSQL teardown; the unchanged wrapper rerun
passed 128. Retry alone establishes no cause or full local-suite pass.
All paid attempts remain recorded; vendor dollar cost is UNKNOWN.

## Risks

Experimental: Cursor independently acknowledges neither the effective
model nor instruction digest before the first prompt. Explicit values
are submitted and checked through behavior; unpriced provider dollar
cost stays UNKNOWN.

Callbacks need authenticated reachable HTTPS. Scope, revocation,
budgets, completion and cleanup remain authoritative. Operator Stop
intent is separate from physical containment. Saved legacy agents retain
their selected runtime. Prior shared title-save/deletion failures remain
disclosed.

## Model Used

OpenAI Codex, GPT-6 family, assisted with reasoning, repository tools,
code execution, browser verification, and delegated code review. The
serving model’s exact API ID and context window are not exposed. No
value is inferred from the model catalog.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used and the limits of available model
metadata
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have linked public issues or described the issue in this PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch describes the change and contains no internal ticket ID
- [x] I have run the scoped local tests reported above; pending gates
are stated separately
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented the risks
- [x] All required CI gates pass on the current head
- [x] Fresh Greptile is 5/5 with no unresolved findings on the current
head
- [x] I will address all reviewer comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-03 09:01:39 -06:00

1304 lines
44 KiB
TypeScript

import type { ExecutionProjection } from "@paperclipai/shared";
import type {
ReasoningMessagePart,
TextMessagePart,
ThreadAssistantMessage,
ThreadMessage,
ToolCallMessagePart,
ThreadSystemMessage,
ThreadUserMessage,
} from "@assistant-ui/react";
import type { Agent, IssueComment } from "@paperclipai/shared";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { formatAssigneeUserLabel } from "./assignees";
import { isOperatorInterruptedRun } from "./interrupt-handoff";
import {
buildIssueThreadInteractionSummary,
shouldHideInteractionCard,
type IssueThreadInteraction,
} from "./issue-thread-interactions";
import type { IssueTimelineEvent } from "./issue-timeline-events";
import { isLiveIssueRun } from "./liveIssueIds";
import { findUIAdapter } from "../adapters/registry";
import {
summarizeNotice,
} from "./transcriptPresentation";
type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
type JsonObject = { [key: string]: JsonValue };
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled", "interrupted"]);
export interface IssueChatComment extends IssueComment {
runId?: string | null;
runAgentId?: string | null;
interruptedRunId?: string | null;
clientId?: string;
clientStatus?: "pending" | "queued";
queueState?: "queued";
queueTargetRunId?: string | null;
queueReason?: "hold" | "active_run" | "other";
followUpRequested?: boolean;
/** Causal conversation slot: the run that actually consumed this input. */
consumedByRunId?: string | null;
/** Same-turn PRP steering acknowledgement for this human input. */
steeredIntoRunId?: string | null;
conversationAnchorAt?: Date | string | null;
conversationAnchorSequence?: number;
}
export interface IssueChatLinkedRun {
execution?: ExecutionProjection | null;
runId: string;
runtimeMode?: "legacy" | "native";
status: string;
agentId: string;
adapterType?: string;
agentName?: string;
responsibleUserId?: string | null;
createdAt: Date | string;
startedAt: Date | string | null;
finishedAt?: Date | string | null;
hasStoredOutput?: boolean;
logBytes?: number | null;
errorCode?: string | null;
error?: string | null;
scheduledRetryAt?: string | null;
nextAction?: string | null;
resultJson?: Record<string, unknown> | null;
}
export interface IssueChatTranscriptEntry {
kind:
| "assistant"
| "thinking"
| "user"
| "tool_call"
| "tool_result"
| "init"
| "result"
| "stderr"
| "system"
| "stdout"
| "diff"
| "provider_activity"
| "workspace_change"
| "workspace_file_reference"
| "runtime_request"
| "run_result"
| "run_terminal";
ts: string;
text?: string;
delta?: boolean;
name?: string;
input?: unknown;
toolUseId?: string;
toolName?: string;
content?: string;
isError?: boolean;
subtype?: string;
errors?: string[];
model?: string;
sessionId?: string;
inputTokens?: number;
outputTokens?: number;
cachedTokens?: number;
costUsd?: number;
changeType?: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
family?: "plan" | "tool_execution" | "research" | "delegation" | "model_identity" | "context" | "artifact" | "review" | "hook" | "memory" | "safety" | "terminal" | "wait" | "provider_notice";
eventType?: string;
status?: "running" | "completed" | "failed" | "interrupted" | "informational" | "pending" | "resolved" | "expired" | "cancelled";
title?: string;
summary?: string;
payload?: Record<string, unknown>;
}
const ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES = 30;
// Adapters whose backends stream verbosely can declare a wider window via
// their UI adapter module (transcriptPresentation.maxVisibleEntries); every
// adapter without a declaration keeps the long-standing 30-entry window.
// Resolved through the registry so shared code never branches on adapter
// identities.
function issueChatTranscriptMaxVisibleEntries(adapterType: string | null | undefined): number {
if (!adapterType) return ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES;
return (
findUIAdapter(adapterType)?.transcriptPresentation?.maxVisibleEntries ??
ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES
);
}
type MessageWithOrder = {
createdAtMs: number;
order: number;
message: ThreadMessage;
};
type SortBoundaryItem = {
createdAtMs: number;
runId?: string | null;
};
export interface StableThreadMessageCacheEntry {
fingerprint: string;
message: ThreadMessage;
}
function toDate(value: Date | string | null | undefined) {
return value instanceof Date ? value : new Date(value ?? Date.now());
}
function toTimestamp(value: Date | string | null | undefined) {
return toDate(value).getTime();
}
function fingerprintThreadMessage(message: ThreadMessage) {
return JSON.stringify(message);
}
function issueChatMessageCustom(message: ThreadMessage): Record<string, unknown> {
const custom = message.metadata?.custom;
return custom && typeof custom === "object" && !Array.isArray(custom)
? custom as Record<string, unknown>
: {};
}
function isLiveRunThreadMessage(message: ThreadMessage) {
return message.role === "assistant"
&& message.status?.type === "running"
&& issueChatMessageCustom(message)["kind"] === "live-run";
}
export function preserveReadableStreamingRetraction(previousText: string, nextText: string) {
if (!previousText || !nextText) return nextText;
if (nextText.length >= previousText.length && nextText.startsWith(previousText)) {
return revealCompleteStreamingWords(previousText, nextText);
}
const overlapLength = longestSuffixPrefixOverlap(previousText, nextText);
if (overlapLength >= 8 && overlapLength < previousText.length) {
const removedPrefix = previousText.slice(0, previousText.length - overlapLength);
if (isQuietStreamingRemovalBoundary(removedPrefix)) {
return nextText;
}
return nextText;
}
if (nextText.length >= previousText.length || !previousText.startsWith(nextText)) {
return revealCompleteStreamingWords(previousText, nextText);
}
const nextLength = nextText.length;
if (previousText[nextLength] === "\n") return nextText;
const nextLineBreak = previousText.indexOf("\n", nextLength);
if (nextLineBreak === -1) return previousText;
return previousText.slice(0, nextLineBreak);
}
function revealCompleteStreamingWords(previousText: string, nextText: string) {
if (nextText.length <= previousText.length || !nextText.startsWith(previousText)) {
return nextText;
}
const addedText = nextText.slice(previousText.length);
if (!addedText) return nextText;
const boundaryIndex = lastReadableWordBoundary(addedText);
if (boundaryIndex === -1) return nextText;
return previousText + addedText.slice(0, boundaryIndex + 1);
}
function lastReadableWordBoundary(text: string) {
let index = -1;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (/\s/.test(char) || /[.,;:!?)}\]"'`]/.test(char)) {
index = i;
}
}
return index;
}
function longestSuffixPrefixOverlap(previousText: string, nextText: string) {
const maxLength = Math.min(previousText.length, nextText.length);
for (let length = maxLength; length > 0; length -= 1) {
if (previousText.endsWith(nextText.slice(0, length))) {
return length;
}
}
return 0;
}
function isQuietStreamingRemovalBoundary(removedPrefix: string) {
return /(?:\n\s*\n|\n|[.!?]\s+)$/.test(removedPrefix);
}
function smoothLiveRunRetractions(
message: ThreadMessage,
previousMessage: ThreadMessage | undefined,
): ThreadMessage {
if (!previousMessage || !isLiveRunThreadMessage(message) || !isLiveRunThreadMessage(previousMessage)) {
return message;
}
let changed = false;
const content = message.content.map((part, index) => {
if (part.type !== "text" && part.type !== "reasoning") return part;
const previousPart = previousMessage.content[index];
if (previousPart?.type !== part.type) return part;
const text = preserveReadableStreamingRetraction(previousPart.text, part.text);
if (text === part.text) return part;
changed = true;
return { ...part, text };
});
return changed ? ({ ...message, content } as ThreadMessage) : message;
}
export function stabilizeThreadMessages(
messages: readonly ThreadMessage[],
previousMessages: readonly ThreadMessage[],
previousById: ReadonlyMap<string, StableThreadMessageCacheEntry>,
) {
const nextById = new Map<string, StableThreadMessageCacheEntry>();
let sameSequence = previousMessages.length === messages.length;
const stabilizedMessages = messages.map((message, index) => {
const cached = previousById.get(message.id);
const displayMessage = smoothLiveRunRetractions(message, cached?.message);
const fingerprint = fingerprintThreadMessage(displayMessage);
const stableMessage =
cached && cached.fingerprint === fingerprint
? cached.message
: displayMessage;
nextById.set(message.id, {
fingerprint,
message: stableMessage,
});
if (sameSequence && previousMessages[index] !== stableMessage) {
sameSequence = false;
}
return stableMessage;
});
return {
messages: sameSequence ? previousMessages : stabilizedMessages,
cache: nextById,
};
}
function sortByCreated<T extends { createdAt: Date | string; id: string }>(items: readonly T[]) {
return [...items].sort((a, b) => {
const diff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
if (diff !== 0) return diff;
return a.id.localeCompare(b.id);
});
}
function dedupeInteractionsById(
interactions: readonly IssueThreadInteraction[],
): IssueThreadInteraction[] {
const byId = new Map<string, IssueThreadInteraction>();
for (const interaction of interactions) {
const previous = byId.get(interaction.id);
if (!previous) {
byId.set(interaction.id, interaction);
continue;
}
const previousUpdatedAt = toTimestamp(previous.updatedAt);
const nextUpdatedAt = toTimestamp(interaction.updatedAt);
if (
nextUpdatedAt > previousUpdatedAt
|| (nextUpdatedAt === previousUpdatedAt
&& previous.status === "pending"
&& interaction.status !== "pending")
) {
byId.set(interaction.id, interaction);
}
}
return [...byId.values()];
}
export function latestSameRunHandoffTimestamp(args: {
interactionCreatedAtMs: number;
sourceRunId: string;
comments: readonly IssueChatComment[];
timelineEvents: readonly IssueTimelineEvent[];
linkedRuns: readonly IssueChatLinkedRun[];
liveRuns: readonly LiveRunForIssue[];
}) {
const {
interactionCreatedAtMs,
sourceRunId,
comments,
timelineEvents,
linkedRuns,
liveRuns,
} = args;
const handoffItems: SortBoundaryItem[] = [
...comments.map((comment) => ({
createdAtMs: toTimestamp(comment.createdAt),
runId: comment.runId ?? null,
})),
...timelineEvents.map((event) => ({
createdAtMs: toTimestamp(event.createdAt),
runId: event.runId ?? null,
})),
];
const barrierItems: SortBoundaryItem[] = [
...handoffItems,
...linkedRuns.map((run) => ({
createdAtMs: toTimestamp(runTimestamp(run)),
runId: run.runId,
})),
...liveRuns.map((run) => ({
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
runId: run.id,
})),
];
const barrierAtMs = barrierItems
.filter((item) => item.createdAtMs > interactionCreatedAtMs && item.runId !== sourceRunId)
.reduce<number | null>(
(earliest, item) =>
earliest === null ? item.createdAtMs : Math.min(earliest, item.createdAtMs),
null,
);
return handoffItems
.filter((item) =>
item.createdAtMs > interactionCreatedAtMs
&& item.runId === sourceRunId
&& (barrierAtMs === null || item.createdAtMs < barrierAtMs)
)
.reduce<number | null>(
(latest, item) =>
latest === null ? item.createdAtMs : Math.max(latest, item.createdAtMs),
null,
);
}
function normalizeJsonValue(input: unknown): JsonValue {
if (
input === null ||
typeof input === "string" ||
typeof input === "number" ||
typeof input === "boolean"
) {
return input;
}
if (Array.isArray(input)) {
return input.map((entry) => normalizeJsonValue(entry));
}
if (typeof input === "object" && input) {
const entries = Object.entries(input as Record<string, unknown>).map(([key, value]) => [
key,
normalizeJsonValue(value),
]);
return Object.fromEntries(entries) as JsonObject;
}
return String(input);
}
function normalizeToolArgs(input: unknown): JsonObject {
if (typeof input === "object" && input && !Array.isArray(input)) {
return normalizeJsonValue(input) as JsonObject;
}
if (input === undefined) return {};
return { value: normalizeJsonValue(input) };
}
function stringifyUnknown(value: unknown) {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function mergePartText(
previous: TextMessagePart | ReasoningMessagePart,
next: TextMessagePart | ReasoningMessagePart,
) {
if (!previous.text) return next.text;
if (!next.text) return previous.text;
if (
previous.text.endsWith("\n")
|| next.text.startsWith("\n")
|| previous.text.endsWith(" ")
|| next.text.startsWith(" ")
) {
return `${previous.text}${next.text}`;
}
return previous.type === "text"
? `${previous.text} ${next.text}`
: `${previous.text}\n${next.text}`;
}
function formatDiffBlock(lines: string[]) {
return `\`\`\`diff\n${lines.join("\n")}\n\`\`\``;
}
function isIssueChatRenderableTranscriptEntry(entry: IssueChatTranscriptEntry) {
return entry.kind !== "init"
&& entry.kind !== "stderr"
&& entry.kind !== "stdout"
&& entry.kind !== "system"
// The classic task interface intentionally remains unchanged. Structured
// PRP surfaces are rendered by TaskChatThread and remain available in run
// details when the classic interface is enabled.
&& entry.kind !== "workspace_change"
&& entry.kind !== "workspace_file_reference"
&& entry.kind !== "runtime_request"
&& entry.kind !== "run_result"
&& entry.kind !== "run_terminal";
}
function compactIssueChatTranscript(
entries: readonly IssueChatTranscriptEntry[],
maxVisibleEntries = ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES,
): readonly IssueChatTranscriptEntry[] {
const renderable = entries
.map((entry, fullIndex) => ({ entry, fullIndex }))
.filter(({ entry }) => isIssueChatRenderableTranscriptEntry(entry));
if (renderable.length <= maxVisibleEntries) {
return entries;
}
let startPos = Math.max(0, renderable.length - maxVisibleEntries);
while (
startPos > 0
&& renderable[startPos]?.entry.kind === "diff"
&& renderable[startPos - 1]?.entry.kind === "diff"
) {
startPos -= 1;
}
const keptRenderablePositions = new Set<number>();
for (let pos = startPos; pos < renderable.length; pos += 1) {
keptRenderablePositions.add(pos);
}
// Keep the matching tool call when the visible tail starts at a tool result.
for (let pos = startPos; pos < renderable.length; pos += 1) {
const entry = renderable[pos]?.entry;
if (entry?.kind !== "tool_result" || !entry.toolUseId) continue;
for (let scan = pos - 1; scan >= 0; scan -= 1) {
const candidate = renderable[scan]?.entry;
if (candidate?.kind === "tool_call" && candidate.toolUseId === entry.toolUseId) {
keptRenderablePositions.add(scan);
break;
}
}
}
const keptFullIndices = new Set<number>();
for (const pos of keptRenderablePositions) {
const fullIndex = renderable[pos]?.fullIndex;
if (fullIndex !== undefined) keptFullIndices.add(fullIndex);
}
const compactedEntries = entries.filter((_entry, index) => keptFullIndices.has(index));
return compactedEntries;
}
function createAssistantMetadata(custom: Record<string, unknown>) {
return {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom,
} as const;
}
function effectiveCommentAuthorAgentId(comment: IssueChatComment) {
return comment.authorAgentId ?? comment.runAgentId ?? comment.derivedAuthorAgentId ?? null;
}
function effectiveCommentRunId(comment: IssueChatComment) {
return comment.runId ?? comment.derivedCreatedByRunId ?? null;
}
function effectiveCommentRunAgentId(comment: IssueChatComment) {
return comment.runAgentId ?? effectiveCommentAuthorAgentId(comment);
}
function effectiveCommentAuthorType(comment: IssueChatComment) {
return effectiveCommentAuthorAgentId(comment) ? "agent" : comment.authorType;
}
function authorNameForComment(
comment: IssueChatComment,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
userLabelMap?: ReadonlyMap<string, string> | null,
options?: { isSystemNotice?: boolean },
) {
const authorAgentId = effectiveCommentAuthorAgentId(comment);
if (authorAgentId) {
return agentMap?.get(authorAgentId)?.name ?? (options?.isSystemNotice ? "Paperclip" : authorAgentId.slice(0, 8));
}
const authorUserId = comment.authorUserId ?? null;
if (!authorUserId) return options?.isSystemNotice ? "Paperclip" : "You";
const userLabel = userLabelMap?.get(authorUserId)?.trim();
if (userLabel) return userLabel;
return formatAssigneeUserLabel(authorUserId, currentUserId, userLabelMap) ?? "You";
}
function formatStatusLabel(status: string) {
return status.replace(/_/g, " ");
}
function createCommentMessage(args: {
comment: IssueChatComment;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
companyId?: string | null;
projectId?: string | null;
}): ThreadMessage {
const { comment, agentMap, currentUserId, userLabelMap, companyId, projectId } = args;
const createdAt = toDate(comment.createdAt);
const isSystemAuthor = comment.authorType === "system";
// Presentation can route a comment to the system-notice renderer even when it
// is agent-authored (e.g. a recovery owner's short status update), letting it
// collapse like a system notice while keeping the real author's name/link.
// Comments without a presentation keep today's routing (graceful fallback for
// old data both directions).
const renderAsSystemNotice = isSystemAuthor || comment.presentation?.kind === "system_notice";
const authorAgentId = effectiveCommentAuthorAgentId(comment);
const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap, {
isSystemNotice: isSystemAuthor,
});
const custom = {
kind: renderAsSystemNotice ? "system_notice" : "comment",
commentId: comment.id,
anchorId: `comment-${comment.id}`,
authorName,
authorType: effectiveCommentAuthorType(comment),
authorAgentId,
authorUserId: comment.authorUserId,
// Responsible user this agent comment rode the authority of (the open cross-task write design (attribution)).
onBehalfOfUserId: comment.onBehalfOfUserId ?? null,
companyId: companyId ?? comment.companyId,
projectId: projectId ?? null,
runId: effectiveCommentRunId(comment),
runAgentId: effectiveCommentRunAgentId(comment),
clientStatus: comment.clientStatus ?? null,
queueState: comment.queueState ?? null,
queueTargetRunId: comment.queueTargetRunId ?? null,
queueReason: comment.queueReason ?? null,
interruptedRunId: comment.interruptedRunId ?? null,
followUpRequested: comment.followUpRequested === true,
presentation: comment.presentation ?? null,
commentMetadata: comment.metadata ?? null,
origin: comment.origin ?? "comment",
fastResponseRequestId: comment.fastResponseRequestId ?? null,
deletedAt: comment.deletedAt ? toDate(comment.deletedAt).toISOString() : null,
deletedByType: comment.deletedByType ?? null,
deletedByAgentId: comment.deletedByAgentId ?? null,
deletedByUserId: comment.deletedByUserId ?? null,
deletedByRunId: comment.deletedByRunId ?? null,
sourceTrust: comment.sourceTrust ?? null,
};
const contentText = comment.deletedAt ? "" : comment.body;
if (renderAsSystemNotice) {
const message: ThreadSystemMessage = {
id: comment.id,
role: "system",
createdAt,
content: [{ type: "text", text: contentText }],
metadata: { custom },
};
return message;
}
if (authorAgentId) {
const message: ThreadAssistantMessage = {
id: comment.id,
role: "assistant",
createdAt,
content: [{ type: "text", text: contentText }],
status: { type: "complete", reason: "stop" },
metadata: createAssistantMetadata(custom),
};
return message;
}
const message: ThreadUserMessage = {
id: comment.id,
role: "user",
createdAt,
content: [{ type: "text", text: contentText }],
attachments: [],
metadata: { custom },
};
return message;
}
function createTimelineEventMessage(args: {
event: IssueTimelineEvent;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
}) {
const { event, agentMap, currentUserId, userLabelMap } = args;
const actorName = event.actorType === "agent"
? (agentMap?.get(event.actorId)?.name ?? event.actorId.slice(0, 8))
: event.actorType === "system"
? "System"
: (formatAssigneeUserLabel(event.actorId, currentUserId, userLabelMap) ?? "Board");
const lines: string[] = [
event.followUpRequested ? `${actorName} requested follow-up` : `${actorName} updated this issue`,
];
if (event.statusChange) {
lines.push(
`Status: ${event.statusChange.from ?? "none"} -> ${event.statusChange.to ?? "none"}`,
);
}
if (event.assigneeChange) {
const from = event.assigneeChange.from.agentId
? (agentMap?.get(event.assigneeChange.from.agentId)?.name ?? event.assigneeChange.from.agentId.slice(0, 8))
: (formatAssigneeUserLabel(event.assigneeChange.from.userId, currentUserId, userLabelMap) ?? "Unassigned");
const to = event.assigneeChange.to.agentId
? (agentMap?.get(event.assigneeChange.to.agentId)?.name ?? event.assigneeChange.to.agentId.slice(0, 8))
: (formatAssigneeUserLabel(event.assigneeChange.to.userId, currentUserId, userLabelMap) ?? "Unassigned");
lines.push(`Assignee: ${from} -> ${to}`);
}
if (event.workspaceChange) {
lines.push(
`Workspace: ${event.workspaceChange.from.label ?? "none"} -> ${event.workspaceChange.to.label ?? "none"}`,
);
}
const message: ThreadSystemMessage = {
id: `activity:${event.id}`,
role: "system",
createdAt: toDate(event.createdAt),
content: [{ type: "text", text: lines.join("\n") }],
metadata: {
custom: {
kind: "event",
anchorId: `activity-${event.id}`,
eventId: event.id,
actorName,
actorType: event.actorType,
actorId: event.actorId,
statusChange: event.statusChange ?? null,
assigneeChange: event.assigneeChange ?? null,
workspaceChange: event.workspaceChange ?? null,
followUpRequested: event.followUpRequested === true,
},
},
};
return message;
}
function createInteractionMessage(interaction: IssueThreadInteraction) {
const message: ThreadSystemMessage = {
id: `interaction:${interaction.id}`,
role: "system",
createdAt: toDate(interaction.createdAt),
content: [{ type: "text", text: buildIssueThreadInteractionSummary(interaction) }],
metadata: {
custom: {
kind: "interaction",
anchorId: `interaction-${interaction.id}`,
interaction,
},
},
};
return message;
}
function runTimestamp(run: IssueChatLinkedRun) {
return run.finishedAt ?? run.startedAt ?? run.createdAt;
}
export interface SegmentTiming {
startMs: number;
endMs: number;
}
export function isCoTSegmentActive(args: {
isMessageRunning: boolean;
segmentIndex: number;
segmentCount: number;
}) {
const { isMessageRunning, segmentIndex, segmentCount } = args;
if (!isMessageRunning) return false;
if (segmentCount <= 0 || segmentIndex < 0) return true;
return segmentIndex === segmentCount - 1;
}
function computeSegmentTimings(entries: readonly IssueChatTranscriptEntry[]): SegmentTiming[] {
const timings: SegmentTiming[] = [];
let inSegment = false;
let segStart = 0;
let segEnd = 0;
for (const entry of entries) {
const ts = new Date(entry.ts).getTime();
const isCoT =
entry.kind === "thinking" ||
entry.kind === "tool_call" ||
entry.kind === "tool_result" ||
entry.kind === "diff" ||
entry.kind === "provider_activity" ||
(entry.kind === "result" && ((entry.isError && !!entry.errors?.length) || !!entry.text));
const isText = entry.kind === "assistant" && !!entry.text;
if (isCoT) {
if (!inSegment) {
inSegment = true;
segStart = ts;
}
segEnd = ts;
} else if (isText && inSegment) {
timings.push({ startMs: segStart, endMs: segEnd });
inSegment = false;
}
}
if (inSegment) {
timings.push({ startMs: segStart, endMs: segEnd });
}
return timings;
}
export function formatDurationWords(ms: number | null) {
if (ms === null || !Number.isFinite(ms) || ms <= 0) return null;
const totalSeconds = Math.max(1, Math.round(ms / 1000));
if (totalSeconds < 60) {
return `${totalSeconds} second${totalSeconds === 1 ? "" : "s"}`;
}
const totalMinutes = Math.round(totalSeconds / 60);
if (totalMinutes < 60) {
return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) {
return `${hours} hour${hours === 1 ? "" : "s"}`;
}
return `${hours} hour${hours === 1 ? "" : "s"} ${minutes} minute${minutes === 1 ? "" : "s"}`;
}
function runDurationLabel(run: {
status: string;
createdAt: Date | string;
startedAt: Date | string | null;
finishedAt?: Date | string | null;
errorCode?: string | null;
resultJson?: Record<string, unknown> | null;
}) {
const start = run.startedAt ?? run.createdAt;
const end = run.finishedAt ?? null;
const durationMs = end ? Math.max(0, toTimestamp(end) - toTimestamp(start)) : null;
const durationText = formatDurationWords(durationMs);
const stopReason = typeof run.resultJson?.stopReason === "string" ? run.resultJson.stopReason : null;
switch (run.status) {
case "succeeded":
return durationText ? `Worked for ${durationText}` : "Finished work";
case "failed":
case "error":
return durationText ? `Failed after ${durationText}` : "Run failed";
case "timed_out":
return durationText ? `Timed out after ${durationText}` : "Run timed out";
case "cancelled":
if (isOperatorInterruptedRun(run.resultJson, run.errorCode)) {
return durationText ? `Interrupted by board after ${durationText}` : "Interrupted by board";
}
if (stopReason === "paused") {
return durationText ? `Paused by board after ${durationText}` : "Paused by board";
}
return durationText ? `Cancelled after ${durationText}` : "Run cancelled";
case "queued":
return "Queued";
case "running":
return "Working...";
default:
return formatStatusLabel(run.status);
}
}
function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map<string, Agent>) {
const agentName = run.agentName ?? agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
const message: ThreadSystemMessage = {
id: `run:${run.runId}`,
role: "system",
createdAt: toDate(runTimestamp(run)),
content: [{ type: "text", text: `${agentName} run ${run.runId.slice(0, 8)} ${formatStatusLabel(run.status)}` }],
metadata: {
custom: {
kind: "run",
anchorId: `run-${run.runId}`,
runId: run.runId,
runAgentId: run.agentId,
runAgentName: agentName,
runStatus: run.status,
execution: run.execution,
runOperatorInterrupted: isOperatorInterruptedRun(run.resultJson, run.errorCode),
},
},
};
return message;
}
function createHistoricalTranscriptMessage(args: {
run: IssueChatLinkedRun;
transcript: readonly IssueChatTranscriptEntry[];
hasOutput: boolean;
agentMap?: Map<string, Agent>;
}) {
const { run, transcript, hasOutput, agentMap } = args;
const agentName = run.agentName ?? agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
const compactedTranscript = compactIssueChatTranscript(transcript, issueChatTranscriptMaxVisibleEntries(run.adapterType));
const { parts, notices, segments } = buildAssistantPartsFromTranscript(compactedTranscript);
const waitingText = hasOutput ? "" : "Run finished";
const content = parts.length > 0
? parts
: waitingText
? [{ type: "text", text: waitingText } satisfies TextMessagePart]
: [];
const message: ThreadAssistantMessage = {
id: `run-assistant:${run.runId}`,
role: "assistant",
createdAt: toDate(run.startedAt ?? run.createdAt),
content,
status: { type: "complete", reason: "stop" },
metadata: createAssistantMetadata({
kind: "historical-run",
anchorId: `run-${run.runId}`,
runId: run.runId,
runAgentId: run.agentId,
runAgentName: agentName,
runStatus: run.status,
execution: run.execution,
runOperatorInterrupted: isOperatorInterruptedRun(run.resultJson, run.errorCode),
notices,
waitingText,
chainOfThoughtLabel: runDurationLabel(run),
chainOfThoughtSegments: segments,
}),
};
return message;
}
export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTranscriptEntry[]): {
parts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>>;
notices: string[];
segments: SegmentTiming[];
} {
const orderedParts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>> = [];
const toolParts = new Map<string, ToolCallMessagePart<JsonObject, unknown>>();
const toolIndices = new Map<string, number>();
const notices: string[] = [];
let pendingDiffLines: string[] = [];
let pendingDiffParentId: string | undefined;
const flushPendingDiff = () => {
if (pendingDiffLines.length === 0) return;
orderedParts.push({
type: "text",
text: formatDiffBlock(pendingDiffLines),
parentId: pendingDiffParentId,
});
pendingDiffLines = [];
pendingDiffParentId = undefined;
};
for (const [index, entry] of entries.entries()) {
if (entry.kind === "diff") {
pendingDiffParentId ??= `diff-group:${index}`;
pendingDiffLines.push(entry.text ?? "");
continue;
}
flushPendingDiff();
if (entry.kind === "assistant" && entry.text) {
orderedParts.push({ type: "text", text: entry.text });
continue;
}
if (entry.kind === "provider_activity") {
const toolCallId = `provider-activity-${index}`;
const args = normalizeToolArgs({
family: entry.family ?? "provider_notice",
eventType: entry.eventType ?? "provider.notice.recorded",
status: entry.status ?? "informational",
title: entry.title ?? "Provider activity",
summary: entry.summary ?? "",
payload: entry.payload ?? {},
});
orderedParts.push({
type: "tool-call",
toolCallId,
toolName: "paperclip_provider_activity",
args,
argsText: "",
...(entry.status === "running" ? {} : { result: { status: entry.status ?? "informational" } }),
});
continue;
}
if (entry.kind === "thinking" && entry.text) {
orderedParts.push({ type: "reasoning", text: entry.text });
continue;
}
if (entry.kind === "tool_call") {
const toolCallId = entry.toolUseId || `tool-${index}`;
const nextPart: ToolCallMessagePart<JsonObject, unknown> = {
type: "tool-call",
toolCallId,
toolName: entry.name || "tool",
args: normalizeToolArgs(entry.input),
argsText: stringifyUnknown(entry.input),
};
if (!toolParts.has(toolCallId)) {
toolIndices.set(toolCallId, orderedParts.length);
orderedParts.push(nextPart);
} else {
const existingIndex = toolIndices.get(toolCallId);
if (existingIndex !== undefined) {
orderedParts[existingIndex] = nextPart;
}
}
toolParts.set(toolCallId, nextPart);
continue;
}
if (entry.kind === "tool_result") {
const toolCallId = entry.toolUseId || `tool-result-${index}`;
const existing = toolParts.get(toolCallId);
const existingResult = typeof existing?.result === "string" ? existing.result : "";
const result = entry.delta
? `${existingResult}${entry.content ?? ""}`
: (entry.content || existingResult);
const nextPart: ToolCallMessagePart<JsonObject, unknown> = {
type: "tool-call",
toolCallId,
toolName: existing?.toolName || entry.toolName || "tool",
args: existing?.args ?? {},
argsText: existing?.argsText ?? "",
result,
isError: entry.isError === true,
};
if (existing) {
const existingIndex = toolIndices.get(toolCallId);
if (existingIndex !== undefined) {
orderedParts[existingIndex] = nextPart;
}
} else {
toolIndices.set(toolCallId, orderedParts.length);
orderedParts.push(nextPart);
}
toolParts.set(toolCallId, nextPart);
continue;
}
if (entry.kind === "init") continue;
if (entry.kind === "stderr") continue;
if (entry.kind === "stdout") continue;
if (entry.kind === "system") continue;
if (entry.kind === "result") {
if (entry.isError && entry.errors?.length) {
for (const error of entry.errors) {
orderedParts.push({ type: "reasoning", text: `Run error: ${summarizeNotice(error)}` });
}
} else if (entry.text) {
orderedParts.push({
type: "reasoning",
text: entry.isError
? `Run error: ${summarizeNotice(entry.text)}`
: summarizeNotice(entry.text),
});
}
continue;
}
}
flushPendingDiff();
const mergedParts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>> = [];
for (const part of orderedParts) {
if (part.type === "tool-call") {
mergedParts.push(part);
continue;
}
const previous = mergedParts.at(-1);
if (previous && previous.type === part.type && previous.parentId === part.parentId) {
mergedParts[mergedParts.length - 1] = {
...previous,
text: mergePartText(previous, part),
};
continue;
}
mergedParts.push(part);
}
return {
parts: mergedParts,
notices,
segments: computeSegmentTimings(entries),
};
}
function normalizeLiveRuns(
liveRuns: readonly LiveRunForIssue[],
activeRun: ActiveRunForIssue | null | undefined,
issueId: string | undefined,
issueStatus: string | null | undefined,
) {
const deduped = new Map<string, LiveRunForIssue>();
for (const run of liveRuns) {
if (!isLiveIssueRun(run, issueStatus)) continue;
deduped.set(run.id, run);
}
if (activeRun && isLiveIssueRun(activeRun, issueStatus)) {
deduped.set(activeRun.id, {
id: activeRun.id,
status: activeRun.status,
invocationSource: activeRun.invocationSource,
triggerDetail: activeRun.triggerDetail,
contextCommentId: activeRun.contextCommentId,
contextWakeCommentId: activeRun.contextWakeCommentId,
startedAt: activeRun.startedAt ? toDate(activeRun.startedAt).toISOString() : null,
finishedAt: activeRun.finishedAt ? toDate(activeRun.finishedAt).toISOString() : null,
createdAt: toDate(activeRun.createdAt).toISOString(),
agentId: activeRun.agentId,
agentName: activeRun.agentName,
adapterType: activeRun.adapterType,
logBytes: activeRun.logBytes,
lastOutputBytes: activeRun.lastOutputBytes,
issueId: activeRun.issueId ?? issueId,
livenessState: activeRun.livenessState,
livenessReason: activeRun.livenessReason,
continuationAttempt: activeRun.continuationAttempt,
lastUsefulActionAt: activeRun.lastUsefulActionAt ? toDate(activeRun.lastUsefulActionAt).toISOString() : null,
nextAction: activeRun.nextAction,
outputSilence: activeRun.outputSilence,
currentStatusMessage: activeRun.currentStatusMessage ?? null,
currentStatusUpdatedAt: activeRun.currentStatusUpdatedAt
? toDate(activeRun.currentStatusUpdatedAt).toISOString()
: null,
currentToolName: activeRun.currentToolName ?? null,
lastAssistantSnippet: activeRun.lastAssistantSnippet ?? null,
lastEventAt: activeRun.lastEventAt
? toDate(activeRun.lastEventAt).toISOString()
: null,
});
}
return [...deduped.values()].sort((a, b) => toTimestamp(a.createdAt) - toTimestamp(b.createdAt));
}
function createLiveRunMessage(args: {
run: LiveRunForIssue;
transcript: readonly IssueChatTranscriptEntry[];
}) {
const { run, transcript } = args;
const compactedTranscript = compactIssueChatTranscript(transcript, issueChatTranscriptMaxVisibleEntries(run.adapterType));
const { parts, notices, segments } = buildAssistantPartsFromTranscript(compactedTranscript);
const waitingText =
run.status === "queued"
? "Queued..."
: parts.length > 0
? ""
: "Working...";
const content = parts;
const message: ThreadAssistantMessage = {
id: `run-assistant:${run.id}`,
role: "assistant",
createdAt: toDate(run.startedAt ?? run.createdAt),
content,
status: { type: "running" },
metadata: createAssistantMetadata({
kind: "live-run",
runId: run.id,
runAgentId: run.agentId,
runAgentName: run.agentName,
runStatus: run.status,
execution: run.execution,
adapterType: run.adapterType,
notices,
waitingText,
chainOfThoughtLabel: runDurationLabel(run),
chainOfThoughtSegments: segments,
currentStatusMessage: run.currentStatusMessage ?? null,
currentStatusUpdatedAt: run.currentStatusUpdatedAt ?? null,
currentToolName: run.currentToolName ?? null,
lastAssistantSnippet: run.lastAssistantSnippet ?? null,
lastEventAt: run.lastEventAt ?? null,
}),
};
return message;
}
/** The durable AI interaction owns repair and its receipt; don't also show the
* escalation's diagnostic card for that same failure. Keep unmatched notices. */
export function isRedundantAiRecoveryNotice(
comment: IssueChatComment,
interactions: readonly IssueThreadInteraction[] = [],
): boolean {
return comment.presentation?.kind === "system_notice"
&& ["AI connection needs attention", "Configuration incomplete"].includes(comment.presentation.title ?? "")
&& Boolean(comment.metadata?.sourceRunId)
&& interactions.some((interaction) => interaction.kind === "connection_intent"
&& interaction.payload.purpose === "ai"
&& interaction.sourceRunId === comment.metadata?.sourceRunId);
}
export function buildIssueChatMessages(args: {
comments: readonly IssueChatComment[];
interactions?: readonly IssueThreadInteraction[];
timelineEvents: readonly IssueTimelineEvent[];
linkedRuns: readonly IssueChatLinkedRun[];
liveRuns: readonly LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>;
hasOutputForRun?: (runId: string) => boolean;
includeSucceededRunsWithoutOutput?: boolean;
issueId?: string;
companyId?: string | null;
projectId?: string | null;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
issueStatus?: string | null;
}) {
const {
comments,
interactions = [],
timelineEvents,
linkedRuns,
liveRuns,
activeRun,
transcriptsByRunId,
hasOutputForRun,
includeSucceededRunsWithoutOutput = false,
issueId,
companyId,
projectId,
agentMap,
currentUserId,
userLabelMap,
issueStatus,
} = args;
const orderedMessages: MessageWithOrder[] = [];
for (const comment of sortByCreated(comments)) {
if (isRedundantAiRecoveryNotice(comment, interactions)) continue;
orderedMessages.push({
createdAtMs: toTimestamp(comment.createdAt),
order: 1,
message: createCommentMessage({ comment, agentMap, currentUserId, userLabelMap, companyId, projectId }),
});
}
// A live-event cache patch and the polling response can briefly contain the
// same interaction. Collapse by id before constructing messages, preferring
// the newest/terminal snapshot so a resolved connection card updates in its
// existing thread slot instead of flashing a duplicate beside itself.
for (const interaction of sortByCreated(dedupeInteractionsById(interactions))) {
// A card IssueThreadInteractionCard never renders — a degenerate
// `ask_user_questions` (e.g. the onboarding `Test / A` placeholder) or a
// stale sibling superseded by a newer question (PAP-437) — is skipped here so
// it leaves no empty message slot in the thread (PAP-424, plan from PAP-420).
if (shouldHideInteractionCard(interaction)) continue;
const createdAtMs = toTimestamp(interaction.createdAt);
const handoffAtMs = interaction.kind === "request_confirmation" && interaction.sourceRunId
? latestSameRunHandoffTimestamp({
interactionCreatedAtMs: createdAtMs,
sourceRunId: interaction.sourceRunId,
comments,
timelineEvents,
linkedRuns,
liveRuns,
})
: null;
orderedMessages.push({
createdAtMs: handoffAtMs ?? createdAtMs,
order: 2,
message: createInteractionMessage(interaction),
});
}
for (const event of sortByCreated(timelineEvents)) {
orderedMessages.push({
createdAtMs: toTimestamp(event.createdAt),
order: 0,
message: createTimelineEventMessage({ event, agentMap, currentUserId, userLabelMap }),
});
}
const linkedRunsById = new Map(linkedRuns.map((run) => [run.runId, run]));
const liveRunsById = new Map(normalizeLiveRuns(liveRuns, activeRun, issueId, issueStatus).map((run) => [run.id, run]));
// History can settle before the live cache drops the same run. Select one
// representation before creating messages: terminal history is authoritative;
// otherwise keep the streaming snapshot and its live metadata.
for (const [runId, run] of linkedRunsById) {
if (!liveRunsById.has(runId)) continue;
if (TERMINAL_RUN_STATUSES.has(run.status)) liveRunsById.delete(runId);
else linkedRunsById.delete(runId);
}
for (const run of [...linkedRunsById.values()].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) {
const transcript = transcriptsByRunId?.get(run.runId) ?? [];
const hasRunOutput = transcript.length > 0 || (hasOutputForRun?.(run.runId) ?? false);
if (hasRunOutput || run.status !== "succeeded") {
// Always use the transcript message for non-succeeded runs (even before
// transcript data loads) so the message type and fold header are stable
// from initial render — avoids a flash when transcripts arrive later.
orderedMessages.push({
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
order: 2,
message: createHistoricalTranscriptMessage({
run,
transcript,
hasOutput: hasRunOutput,
agentMap,
}),
});
continue;
}
if (!includeSucceededRunsWithoutOutput) continue;
orderedMessages.push({
createdAtMs: toTimestamp(runTimestamp(run)),
order: 2,
message: createHistoricalRunMessage(run, agentMap),
});
}
for (const run of liveRunsById.values()) {
orderedMessages.push({
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
order: 3,
message: createLiveRunMessage({
run,
transcript: transcriptsByRunId?.get(run.id) ?? [],
}),
});
}
return orderedMessages
.sort((a, b) => {
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
if (a.order !== b.order) return a.order - b.order;
return a.message.id.localeCompare(b.message.id);
})
.map((entry) => entry.message);
}