mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task conversations show requests in the feed and above the message composer. > - Earlier fixes kept dismissed questions in history, but excluded completion reviews and other request kinds. > - Closing those forms left a pending badge, and the server could still demand a response. > - This pull request saves human dismissal and removes the request from composer reminders and ordinary completion gates. > - People can continue the conversation and reopen the request from history. ## Linked Issues or Issue Description Related work: #15229 and #15392. **What happened?** Dismissing a completion review closed its form but left it counted as pending. It could return after reload, block a retry, or cause the agent to demand another response. Raw runtime requests could also lose their only usable control when dismissed. **Expected behavior** Dismissal leaves an accessible request in the feed and clears the composer form and pending count. It persists after reload. It does not submit an answer, approve an action, or wake the agent. **Steps to reproduce** 1. Open a task with a pending completion review or another composer request. 2. Dismiss the request, then reload the task. 3. Check that the composer stays clear and the feed entry can reopen the request. ## What Changed - Add nullable dismissal fields and a human-only dismissal endpoint with company, private-task access, resolver, and locked review-policy checks. - Persist saved-request dismissal on the server. Keep browser persistence for raw runtime requests and existing question dismissals. - Keep confirmations, choices, item verdicts, suggested tasks, and runtime requests accessible in the feed. - Exclude dismissed ordinary requests from completion and retry gates. Tell agents not to repeat deferred requests. - Preserve tool, secret, connection, plan, company-policy, and configured review authorization gates. Expire deferred non-question requests when their task completes. - Preserve the selected form when another dismissal finishes saving. Keep reopened reviews visible through delayed comment refreshes. - Add a completion-review Storybook test drive and update design, API, database, and capability documentation. ## Verification - `pnpm -r typecheck`: passed. The final UI changes also passed the UI typecheck. - `pnpm build`: passed. The final UI changes also passed the UI production build. - Focused UI and transcript tests: 550 passed across nine files after merging the current base branch. - Focused database, completion, and prompt tests: 105 passed across three files, including two review-policy regressions that failed before the fix. The tests reject a review requester excluded by current policy, preserve the pending gate on denial, and permit an independent reviewer to defer the request. - Route regression suite: 81 passed, including denial before reading or mutating a private-task request. - `pnpm check:token-gates`: passed. - Map and Storybook structural checks: passed; 30 map/catalog script tests passed. - Browser: used the production task-thread components in the completion-review fixture. Dismissed, reloaded, reopened from the feed, and dismissed again. No pending badge or composer takeover remained. Story callbacks simulate API saves; separate server tests cover persistence and permissions. - `pnpm test:run`: stopped after the general-server group reported one failure in `workspace-runtime.test.ts`: "returns a bounded structured conflict and exposes only same-company workspace references". The same test passed in isolation (1 passed, 178 skipped). The cause of the full-suite failure is not established. The full PR CI suite subsequently passed on e243cbb001; its two authorization review findings are fixed in9e1b4478b3. Fresh [full CI](https://github.com/paperclipai/paperclip/actions/runs/39871944846) passes on9e1b4478b3, including the previously failing server and browser shards. All 54 executed checks passed; two optional Storybook deployment/visual jobs were skipped. The legacy security status also passed. Fresh Greptile review is 5/5, the security scan passes, and no review threads remain open. This full CI run settles the merge gate; the earlier local workspace-suite failure remains recorded above because its cause was not established. - Additional verification: the original failing browser journey passed locally after the delayed-refresh fix. The expanded page-reload case also passes on9e1b4478b3(52.7s): dismiss, reload, reopen, approve, and receive the actual tool failure in the continuing task. It exposed a fixture reading an accepted request before tool execution finished; the fixture now waits for a stored execution result. The first reload attempt stopped on a still-loading page at the default five-second assertion; the reload now uses the suite’s navigation allowance. A controlled server-restart run timed out waiting for server health before the UI assertions. Local route-suite runs timed out during concurrent module loading; sequential imports passed all 81 tests, including the privacy regression. - Initial verification failures: a missing SDK build and a story fixture type error were fixed. PostgreSQL bootstrap then failed because the host had exhausted its semaphore allowance. After explicit operator approval, 64 verified abandoned PostgreSQL semaphore sets were reclaimed; no live process was stopped. The database regressions then passed. The raw-runtime regression exposed a missing feed control; the implementation now preserves that entry and the regression passes. ## Risks - Apply the additive database migration before the new server code. It adds two nullable columns and does not change existing decisions. - A dismissal is shared task attention state. Only a human with task read access and current resolver/review authority can persist it. Review policy is checked under the task lock. The operation is idempotent and records the dismissing user. - Raw runtime dismissal remains specific to a browser. Saved interaction dismissal persists across browsers. - A deferred review is not an approval. Tests retain protected-action and configured review gates. - The browser walkthrough uses a fixture. This branch has not been deployed to a live instance. ## Model Used OpenAI Codex, GPT-6, with code execution and browser tools. The exact backend model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
566 lines
25 KiB
TypeScript
566 lines
25 KiB
TypeScript
import type { Agent } from "@paperclipai/shared";
|
|
import type { CompanyUserProfile } from "./company-members";
|
|
import { formatReviewPolicyValue } from "./review-policy";
|
|
|
|
type ActivityDetails = Record<string, unknown> | null | undefined;
|
|
|
|
type ActivityParticipant = {
|
|
type: "agent" | "user";
|
|
agentId?: string | null;
|
|
userId?: string | null;
|
|
};
|
|
|
|
type ActivityIssueReference = {
|
|
id?: string | null;
|
|
identifier?: string | null;
|
|
title?: string | null;
|
|
};
|
|
|
|
interface ActivityFormatOptions {
|
|
agentMap?: Map<string, Agent>;
|
|
userProfileMap?: Map<string, CompanyUserProfile>;
|
|
currentUserId?: string | null;
|
|
}
|
|
|
|
const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
|
"case.created": "created",
|
|
"case.updated": "updated",
|
|
"case.fields_changed": "updated details on",
|
|
"case.status_changed": "changed status on",
|
|
"case.issue_linked": "linked a task to",
|
|
"case.issue_unlinked": "unlinked a task from",
|
|
"case.child_linked": "linked a child to",
|
|
"case.attachment_added": "attached a file to",
|
|
"case.label_added": "added a label to",
|
|
"case.label_removed": "removed a label from",
|
|
"case.document_revised": "updated a document on",
|
|
"case.handoff_requested": "requested a handoff for",
|
|
"case.handoff_applied": "handed off",
|
|
"case.handoff_delivered": "delivered a handoff for",
|
|
"case.handoff_retry_requested": "retried a handoff for",
|
|
"case.handoff_cancelled": "cancelled a handoff for",
|
|
"case.handoff_completed": "completed",
|
|
"issue.created": "created",
|
|
"issue.updated": "updated",
|
|
"issue.read_marked": "read",
|
|
"issue.read_unmarked": "marked unread",
|
|
"issue.checked_out": "checked out",
|
|
"issue.released": "released",
|
|
"issue.comment_added": "commented on",
|
|
"issue.comment_cancelled": "cancelled a queued comment on",
|
|
"issue.queued_comment_edited": "edited a queued comment on",
|
|
"issue.queued_comments_reordered": "reordered queued comments on",
|
|
"issue.queued_comment_discarded": "discarded a queued comment on",
|
|
"issue.comment_deleted": "deleted a comment on",
|
|
"issue.attachment_added": "attached file to",
|
|
"issue.attachment_removed": "removed attachment from",
|
|
"issue.document_created": "created document for",
|
|
"issue.document_updated": "updated document on",
|
|
"issue.document_locked": "locked document on",
|
|
"issue.document_unlocked": "unlocked document on",
|
|
"issue.document_deleted": "deleted document from",
|
|
"issue.monitor_scheduled": "scheduled monitor on",
|
|
"issue.monitor_triggered": "triggered monitor for",
|
|
"issue.monitor_cleared": "cleared monitor on",
|
|
"issue.monitor_skipped": "skipped monitor for",
|
|
"issue.monitor_exhausted": "exhausted monitor on",
|
|
"issue.monitor_recovery_wake_queued": "queued monitor recovery for",
|
|
"issue.monitor_recovery_issue_created": "created monitor recovery for",
|
|
"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",
|
|
"issue.accepted_plan_decomposition_updated": "updated accepted-plan decomposition on",
|
|
"issue.recovery_action_opened": "opened a recovery action on",
|
|
"issue.workspace_recovered": "recovered saved work for",
|
|
"issue.workspace_recovery_requested": "requested workspace recovery for",
|
|
"issue.recovery_action_resolved": "resolved the recovery action on",
|
|
"issue.recovery_action_escalated": "escalated the recovery action on",
|
|
"agent.created": "created",
|
|
"agent.updated": "updated",
|
|
"agent.paused": "paused",
|
|
"agent.resumed": "resumed",
|
|
"agent.error_cleared": "cleared error on",
|
|
"agent.terminated": "terminated",
|
|
"agent.key_created": "created API key for",
|
|
"agent.budget_updated": "updated budget for",
|
|
"agent.runtime_session_reset": "reset session for",
|
|
"heartbeat.invoked": "invoked heartbeat for",
|
|
"heartbeat.cancelled": "cancelled heartbeat for",
|
|
"heartbeat.output_stale_source_resolved": "system-folded stale run on",
|
|
"heartbeat.output_stale_recovery_recursion_refused": "refused recovery-on-recovery for",
|
|
"approval.created": "requested approval",
|
|
"approval.approved": "approved",
|
|
"approval.rejected": "rejected",
|
|
// Interaction outcomes (PAP-16506). An agent may now resolve one — including a
|
|
// review of its own work — so these must read as outcomes in the feed instead
|
|
// of falling through to the raw "issue thread interaction accepted" action id.
|
|
// `details.interactionKind` sharpens the wording; see INTERACTION_OUTCOME_LABELS.
|
|
"issue.thread_interaction_created": "asked for a decision on",
|
|
"issue.thread_interaction_accepted": "accepted the request on",
|
|
"issue.thread_interaction_rejected": "rejected the request on",
|
|
"issue.thread_interaction_answered": "answered the request on",
|
|
"issue.thread_interaction_withdrawn": "withdrew the request on",
|
|
"issue.thread_interaction_cancelled": "cancelled the request on",
|
|
"issue.thread_interaction_skipped": "skipped the request on",
|
|
"issue.thread_interaction_dismissed": "deferred the request on",
|
|
"issue.thread_interaction_expired": "expired the request on",
|
|
"issue.thread_interaction_item_verdicts_submitted": "submitted verdicts on",
|
|
"issue.stalled_review_decided": "recorded a review verdict on",
|
|
"project.created": "created",
|
|
"project.updated": "updated",
|
|
"project.deleted": "deleted",
|
|
"goal.created": "created",
|
|
"goal.updated": "updated",
|
|
"goal.deleted": "deleted",
|
|
"cost.reported": "reported cost for",
|
|
"cost.recorded": "recorded cost for",
|
|
"company.created": "created organization",
|
|
"company.updated": "updated organization",
|
|
"company.archived": "archived",
|
|
"company.reactivated": "reactivated",
|
|
"company.budget_updated": "updated budget for",
|
|
"audit.exported": "exported the agent audit log for",
|
|
"tool_app.connected": "connected",
|
|
"tool_app.oauth_connected": "connected credentials for",
|
|
"tool_app.oauth_failed": "failed to connect credentials for",
|
|
"tool_app.oauth_access_finalized": "finished credential access for",
|
|
"tool_app.finished": "finished setup for",
|
|
"tool_app.reconnected": "reconnected",
|
|
"tool_connection.created": "created",
|
|
"tool_connection.updated": "updated",
|
|
"tool_connection.archived": "removed",
|
|
"tool_connection.catalog_refresh": "refreshed actions for",
|
|
"tool_connection.installs_synced": "changed agent installs for",
|
|
"tool_connection.install_access_extended": "extended agent access for",
|
|
"tool_connection.grant_audience_replaced": "changed human access for",
|
|
"tool_connection.grant_added": "added credentials to",
|
|
"tool_connection.grant_revoked": "revoked credentials from",
|
|
"tool_connection.grant_delegated": "delegated credentials for",
|
|
"tool_connection.grant_delegation_revoked": "revoked credential delegation for",
|
|
};
|
|
|
|
const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
|
"issue.created": "created the issue",
|
|
"issue.updated": "updated the issue",
|
|
"issue.checked_out": "checked out the issue",
|
|
"issue.released": "released the issue",
|
|
"issue.comment_added": "added a comment",
|
|
"issue.comment_cancelled": "cancelled a queued comment",
|
|
"issue.queued_comment_edited": "edited a queued comment",
|
|
"issue.queued_comments_reordered": "reordered queued comments",
|
|
"issue.queued_comment_discarded": "discarded a queued comment",
|
|
"issue.comment_deleted": "deleted a comment",
|
|
"issue.feedback_vote_saved": "saved feedback on an AI output",
|
|
"issue.attachment_added": "added an attachment",
|
|
"issue.attachment_removed": "removed an attachment",
|
|
"issue.document_created": "created a document",
|
|
"issue.document_updated": "updated a document",
|
|
"issue.document_locked": "locked a document",
|
|
"issue.document_unlocked": "unlocked a document",
|
|
"issue.document_deleted": "deleted a document",
|
|
"issue.monitor_scheduled": "scheduled a monitor",
|
|
"issue.monitor_triggered": "triggered a monitor",
|
|
"issue.monitor_cleared": "cleared a monitor",
|
|
"issue.monitor_skipped": "skipped a monitor",
|
|
"issue.monitor_exhausted": "exhausted a monitor",
|
|
"issue.monitor_recovery_wake_queued": "queued a monitor recovery wake",
|
|
"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",
|
|
"issue.cross_issue_influence_cap_rejected": "hit the per-run cross-task write cap",
|
|
"issue.cross_issue_influence_observed": "made a cross-task write",
|
|
"issue.attribution_spoof_rejected": "tried to choose its own responsible user",
|
|
"issue.recovery_action_opened": "Opened a source-scoped recovery action",
|
|
"issue.workspace_recovered": "Saved work recovered.",
|
|
"issue.workspace_recovery_requested": "Requested workspace recovery",
|
|
"issue.recovery_action_resolved": "Resolved the recovery action",
|
|
"issue.recovery_action_escalated": "Escalated the recovery action",
|
|
"issue.accepted_plan_decomposition_updated": "updated the accepted-plan decomposition",
|
|
"agent.created": "created an agent",
|
|
"agent.updated": "updated the agent",
|
|
"agent.paused": "paused the agent",
|
|
"agent.resumed": "resumed the agent",
|
|
"agent.error_cleared": "cleared the agent error",
|
|
"agent.terminated": "terminated the agent",
|
|
"heartbeat.invoked": "invoked a heartbeat",
|
|
"heartbeat.cancelled": "cancelled a heartbeat",
|
|
"heartbeat.output_stale_source_resolved": "System folded a stale run",
|
|
"heartbeat.output_stale_recovery_recursion_refused": "Refused recovery-on-recovery escalation",
|
|
"approval.created": "requested approval",
|
|
"approval.approved": "approved",
|
|
"approval.rejected": "rejected",
|
|
"issue.thread_interaction_created": "asked for a decision",
|
|
"issue.thread_interaction_accepted": "accepted the request",
|
|
"issue.thread_interaction_rejected": "rejected the request",
|
|
"issue.thread_interaction_answered": "answered the request",
|
|
"issue.thread_interaction_withdrawn": "withdrew the request",
|
|
"issue.thread_interaction_cancelled": "cancelled the request",
|
|
"issue.thread_interaction_skipped": "skipped the request",
|
|
"issue.thread_interaction_dismissed": "deferred the request",
|
|
"issue.thread_interaction_expired": "expired the request",
|
|
"issue.thread_interaction_item_verdicts_submitted": "submitted verdicts on the request",
|
|
"issue.stalled_review_decided": "recorded a review verdict",
|
|
};
|
|
|
|
/**
|
|
* `issue.stalled_review_decided` carries the verb the actor chose, so the line
|
|
* names the verdict ("approved the review") rather than the generic action.
|
|
* Mirrors `StalledReviewDecisionAction` in shared.
|
|
*/
|
|
const STALLED_REVIEW_DECISION_LABELS: Record<string, string> = {
|
|
approve: "approved the review",
|
|
request_changes: "requested changes on the review",
|
|
send_back: "sent the review back to work",
|
|
};
|
|
|
|
/**
|
|
* `issue.thread_interaction_accepted` / `_rejected` fire for *every* interaction
|
|
* kind, not only for a review. A task suggestion or a question is accepted, not
|
|
* approved, so the kind on the event picks the verb. Kinds absent from a map
|
|
* keep the neutral "accepted the request" wording from the tables above, which
|
|
* is also the fallback for an event that carries no kind.
|
|
*/
|
|
const INTERACTION_ACCEPTED_LABELS: Record<string, string> = {
|
|
request_confirmation: "approved the request",
|
|
request_checkbox_confirmation: "approved the request",
|
|
suggest_tasks: "accepted the task suggestions",
|
|
ask_user_questions: "accepted the answers",
|
|
};
|
|
|
|
const INTERACTION_REJECTED_LABELS: Record<string, string> = {
|
|
request_confirmation: "rejected the request",
|
|
request_checkbox_confirmation: "rejected the request",
|
|
suggest_tasks: "declined the task suggestions",
|
|
ask_user_questions: "declined the questions",
|
|
};
|
|
|
|
/**
|
|
* Kind-aware wording for an interaction outcome, or `null` when the tables
|
|
* above already say it well enough.
|
|
*/
|
|
function formatInteractionOutcomeLabel(action: string, details: ActivityDetails): string | null {
|
|
const table = action === "issue.thread_interaction_accepted"
|
|
? INTERACTION_ACCEPTED_LABELS
|
|
: action === "issue.thread_interaction_rejected"
|
|
? INTERACTION_REJECTED_LABELS
|
|
: null;
|
|
if (!table) return null;
|
|
const kind = typeof details?.interactionKind === "string" ? details.interactionKind : null;
|
|
return kind ? table[kind] ?? null : null;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function humanizeValue(value: unknown): string {
|
|
if (typeof value !== "string") return String(value ?? "none");
|
|
return value.replace(/_/g, " ");
|
|
}
|
|
|
|
function isActivityParticipant(value: unknown): value is ActivityParticipant {
|
|
const record = asRecord(value);
|
|
if (!record) return false;
|
|
return record.type === "agent" || record.type === "user";
|
|
}
|
|
|
|
function isActivityIssueReference(value: unknown): value is ActivityIssueReference {
|
|
return asRecord(value) !== null;
|
|
}
|
|
|
|
function readParticipants(details: ActivityDetails, key: string): ActivityParticipant[] {
|
|
const value = details?.[key];
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter(isActivityParticipant);
|
|
}
|
|
|
|
function readIssueReferences(details: ActivityDetails, key: string): ActivityIssueReference[] {
|
|
const value = details?.[key];
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter(isActivityIssueReference);
|
|
}
|
|
|
|
function formatUserLabel(userId: string | null | undefined, options: ActivityFormatOptions = {}): string {
|
|
if (!userId || userId === "local-board") return "Board";
|
|
if (options.currentUserId && userId === options.currentUserId) return "You";
|
|
const profile = options.userProfileMap?.get(userId);
|
|
if (profile) return profile.label;
|
|
return `user ${userId.slice(0, 5)}`;
|
|
}
|
|
|
|
function formatParticipantLabel(participant: ActivityParticipant, options: ActivityFormatOptions): string {
|
|
if (participant.type === "agent") {
|
|
const agentId = participant.agentId ?? "";
|
|
return options.agentMap?.get(agentId)?.name ?? "agent";
|
|
}
|
|
return formatUserLabel(participant.userId, options);
|
|
}
|
|
|
|
function formatIssueReferenceLabel(reference: ActivityIssueReference): string {
|
|
if (reference.identifier) return reference.identifier;
|
|
if (reference.title) return reference.title;
|
|
if (reference.id) return reference.id.slice(0, 8);
|
|
return "task";
|
|
}
|
|
|
|
function formatChangedEntityLabel(
|
|
singular: string,
|
|
plural: string,
|
|
labels: string[],
|
|
): string {
|
|
if (labels.length <= 0) return plural;
|
|
if (labels.length === 1) return `${singular} ${labels[0]}`;
|
|
return `${labels.length} ${plural}`;
|
|
}
|
|
|
|
function readNumber(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
return null;
|
|
}
|
|
|
|
function readStringArrayLength(value: unknown): number {
|
|
if (!Array.isArray(value)) return 0;
|
|
return value.filter((entry) => typeof entry === "string" && entry.length > 0).length;
|
|
}
|
|
|
|
function formatAcceptedPlanDecompositionDetail(details: ActivityDetails): string | null {
|
|
if (!details) return null;
|
|
const status = typeof details.status === "string" ? details.status : null;
|
|
const requested = readNumber(details.requestedChildCount);
|
|
const totalChildren = readStringArrayLength(details.childIssueIds);
|
|
const newlyCreated = readStringArrayLength(details.newlyCreatedChildIssueIds);
|
|
const reused = Math.max(0, totalChildren - newlyCreated);
|
|
const parts: string[] = [];
|
|
if (newlyCreated > 0) parts.push(`created ${newlyCreated} new`);
|
|
if (reused > 0) parts.push(`reused ${reused} existing`);
|
|
if (parts.length === 0 && requested !== null) parts.push(`${requested} requested`);
|
|
const summary = parts.length > 0 ? parts.join(", ") : null;
|
|
if (status === "completed" && summary) return `decomposition completed (${summary})`;
|
|
if (status === "completed") return "decomposition completed";
|
|
if (status === "in_flight" && summary) return `decomposition in flight (${summary})`;
|
|
return summary;
|
|
}
|
|
|
|
function formatIssueUpdatedVerb(details: ActivityDetails): string | null {
|
|
if (!details) return null;
|
|
const previous = asRecord(details._previous) ?? {};
|
|
if (details.status !== undefined) {
|
|
const from = previous.status;
|
|
const to = humanizeValue(details.status === "in_review" && details.externalConversationState === "waiting" ? "idle" : details.status);
|
|
return from
|
|
? `changed status from ${humanizeValue(from)} to ${to} on`
|
|
: `changed status to ${to} on`;
|
|
}
|
|
if (details.priority !== undefined) {
|
|
const from = previous.priority;
|
|
return from
|
|
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
|
|
: `changed priority to ${humanizeValue(details.priority)} on`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatAssigneeName(details: ActivityDetails, options: ActivityFormatOptions): string | null {
|
|
if (!details) return null;
|
|
const agentId = details.assigneeAgentId;
|
|
const userId = details.assigneeUserId;
|
|
if (typeof agentId === "string" && agentId) {
|
|
return options.agentMap?.get(agentId)?.name ?? "agent";
|
|
}
|
|
if (typeof userId === "string" && userId) {
|
|
return formatUserLabel(userId, options);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatIssueUpdatedAction(details: ActivityDetails, options: ActivityFormatOptions = {}): string | null {
|
|
if (!details) return null;
|
|
const previous = asRecord(details._previous) ?? {};
|
|
const parts: string[] = [];
|
|
|
|
if (details.status !== undefined) {
|
|
const from = previous.status;
|
|
const to = humanizeValue(details.status === "in_review" && details.externalConversationState === "waiting" ? "idle" : details.status);
|
|
parts.push(
|
|
from
|
|
? `changed the status from ${humanizeValue(from)} to ${to}`
|
|
: `changed the status to ${to}`,
|
|
);
|
|
}
|
|
if (details.priority !== undefined) {
|
|
const from = previous.priority;
|
|
parts.push(
|
|
from
|
|
? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}`
|
|
: `changed the priority to ${humanizeValue(details.priority)}`,
|
|
);
|
|
}
|
|
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
|
|
const assigneeName = formatAssigneeName(details, options);
|
|
parts.push(assigneeName ? `made ${assigneeName} responsible for the task` : "cleared the responsible");
|
|
}
|
|
if (details.reviewPolicy !== undefined) {
|
|
// `null` is the default ("anyone can approve"), so it must not read as
|
|
// "changed the review policy to none" (PAP-16506).
|
|
parts.push(`changed who can approve to ${formatReviewPolicyValue(details.reviewPolicy)}`);
|
|
}
|
|
if (details.title !== undefined) parts.push("updated the title");
|
|
if (details.description !== undefined) parts.push("updated the description");
|
|
|
|
return parts.length > 0 ? parts.join(", ") : null;
|
|
}
|
|
|
|
function formatStructuredIssueChange(input: {
|
|
action: string;
|
|
details: ActivityDetails;
|
|
options: ActivityFormatOptions;
|
|
forIssueDetail: boolean;
|
|
}): string | null {
|
|
const details = input.details;
|
|
if (!details) return null;
|
|
|
|
if (input.action === "issue.blockers_updated") {
|
|
const added = readIssueReferences(details, "addedBlockedByIssues").map(formatIssueReferenceLabel);
|
|
const removed = readIssueReferences(details, "removedBlockedByIssues").map(formatIssueReferenceLabel);
|
|
if (added.length > 0 && removed.length === 0) {
|
|
const changed = formatChangedEntityLabel("blocker", "blockers", added);
|
|
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
|
|
}
|
|
if (removed.length > 0 && added.length === 0) {
|
|
const changed = formatChangedEntityLabel("blocker", "blockers", removed);
|
|
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
|
|
}
|
|
return input.forIssueDetail ? "updated blockers" : "updated blockers on";
|
|
}
|
|
|
|
if (input.action === "issue.reviewers_updated" || input.action === "issue.approvers_updated") {
|
|
const added = readParticipants(details, "addedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
|
|
const removed = readParticipants(details, "removedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
|
|
const singular = input.action === "issue.reviewers_updated" ? "reviewer" : "approver";
|
|
const plural = input.action === "issue.reviewers_updated" ? "reviewers" : "approvers";
|
|
if (added.length > 0 && removed.length === 0) {
|
|
const changed = formatChangedEntityLabel(singular, plural, added);
|
|
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
|
|
}
|
|
if (removed.length > 0 && added.length === 0) {
|
|
const changed = formatChangedEntityLabel(singular, plural, removed);
|
|
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
|
|
}
|
|
return input.forIssueDetail ? `updated ${plural}` : `updated ${plural} on`;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function formatActivityVerb(
|
|
action: string,
|
|
details?: Record<string, unknown> | null,
|
|
options: ActivityFormatOptions = {},
|
|
): string {
|
|
if (action.startsWith("tool_gateway.")) {
|
|
const rawTool = typeof details?.tool === "string"
|
|
? details.tool
|
|
: typeof details?.upstreamToolName === "string"
|
|
? details.upstreamToolName
|
|
: "an app action";
|
|
const tool = rawTool.replace(/[._-]+/g, " ");
|
|
const isTest = details?.source === "test";
|
|
if (action === "tool_gateway.call_completed") return `${isTest ? "tested" : "used"} ${tool} on`;
|
|
if (action === "tool_gateway.call_allowed") return `${isTest ? "started a test of" : "was allowed to use"} ${tool} on`;
|
|
if (action === "tool_gateway.call_denied") return `was blocked from using ${tool} on`;
|
|
if (action === "tool_gateway.approval_requested") return `asked to use ${tool} on`;
|
|
if (action === "tool_gateway.session_created") return "opened an app session for";
|
|
if (action === "tool_gateway.session_rejected") return "was blocked from opening an app session for";
|
|
if (action === "tool_gateway.discovery") return "discovered app actions for";
|
|
}
|
|
|
|
if (action === "issue.updated") {
|
|
const issueUpdatedVerb = formatIssueUpdatedVerb(details);
|
|
if (issueUpdatedVerb) return issueUpdatedVerb;
|
|
}
|
|
|
|
if (action === "issue.stalled_review_decided") {
|
|
const decision = typeof details?.action === "string" ? details.action : null;
|
|
const label = decision ? STALLED_REVIEW_DECISION_LABELS[decision] : null;
|
|
if (label) return `${label} on`;
|
|
}
|
|
|
|
const outcomeLabel = formatInteractionOutcomeLabel(action, details);
|
|
if (outcomeLabel) return `${outcomeLabel} on`;
|
|
|
|
const structuredChange = formatStructuredIssueChange({
|
|
action,
|
|
details,
|
|
options,
|
|
forIssueDetail: false,
|
|
});
|
|
if (structuredChange) return structuredChange;
|
|
|
|
return ACTIVITY_ROW_VERBS[action] ?? action.replace(/[._]/g, " ");
|
|
}
|
|
|
|
export function formatIssueActivityAction(
|
|
action: string,
|
|
details?: Record<string, unknown> | null,
|
|
options: ActivityFormatOptions = {},
|
|
): string {
|
|
if (action === "issue.updated") {
|
|
const issueUpdatedAction = formatIssueUpdatedAction(details, options);
|
|
if (issueUpdatedAction) return issueUpdatedAction;
|
|
}
|
|
|
|
const structuredChange = formatStructuredIssueChange({
|
|
action,
|
|
details,
|
|
options,
|
|
forIssueDetail: true,
|
|
});
|
|
if (structuredChange) return structuredChange;
|
|
|
|
if (action === "issue.accepted_plan_decomposition_updated") {
|
|
const detail = formatAcceptedPlanDecompositionDetail(details);
|
|
if (detail) return detail;
|
|
}
|
|
|
|
if (action === "issue.stalled_review_decided") {
|
|
const decision = typeof details?.action === "string" ? details.action : null;
|
|
const label = decision ? STALLED_REVIEW_DECISION_LABELS[decision] : null;
|
|
if (label) return label;
|
|
}
|
|
|
|
const outcomeLabel = formatInteractionOutcomeLabel(action, details);
|
|
if (outcomeLabel) return outcomeLabel;
|
|
|
|
if (action.startsWith("issue.monitor_") && details) {
|
|
const serviceName = typeof details.serviceName === "string" && details.serviceName.trim()
|
|
? details.serviceName.trim()
|
|
: null;
|
|
const base = ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
|
|
return serviceName ? `${base} for ${serviceName}` : base;
|
|
}
|
|
|
|
if (
|
|
(
|
|
action === "issue.document_created" ||
|
|
action === "issue.document_updated" ||
|
|
action === "issue.document_locked" ||
|
|
action === "issue.document_unlocked" ||
|
|
action === "issue.document_deleted"
|
|
) &&
|
|
details
|
|
) {
|
|
const key = typeof details.key === "string" ? details.key : "document";
|
|
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
|
|
return `${ISSUE_ACTIVITY_LABELS[action] ?? action} ${key}${title}`;
|
|
}
|
|
|
|
return ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
|
|
}
|