| 1 | import type { |
| 2 | OrchestrationV2ConversationMessage, |
| 3 | OrchestrationV2Notification, |
| 4 | OrchestrationV2NotificationSource, |
| 5 | OrchestrationV2Subagent, |
| 6 | OrchestrationV2TurnItem, |
| 7 | ThreadId, |
| 8 | } from "@t3tools/contracts"; |
| 9 | |
| 10 | type NotificationOutcome = OrchestrationV2Notification["outcome"]; |
| 11 | |
| 12 | /** A piece of background work an adapter can name, by kind. */ |
| 13 | export type BackgroundWork = { |
| 14 | /** Subagent title, command, or monitor description. */ |
| 15 | readonly label?: string | undefined; |
| 16 | } & ( |
| 17 | | { readonly kind: "subagent"; readonly childThreadId?: ThreadId | undefined } |
| 18 | | { readonly kind: "command"; readonly exitCode?: number | undefined } |
| 19 | | { readonly kind: "monitor" } |
| 20 | | { readonly kind: "background_task" } |
| 21 | ); |
| 22 | |
| 23 | /** Background work an adapter saw finish or report, as the user should read it. */ |
| 24 | export type BackgroundWorkReport = BackgroundWork & { readonly outcome: NotificationOutcome }; |
| 25 | |
| 26 | const LABEL_MAX_LENGTH = 80; |
| 27 | |
| 28 | export function reportLabel(label: string | undefined): string | undefined { |
| 29 | const firstLine = label?.trim().split("\n")[0]?.trim(); |
| 30 | if (firstLine === undefined || firstLine.length === 0) return undefined; |
| 31 | return firstLine.length > LABEL_MAX_LENGTH |
| 32 | ? `${firstLine.slice(0, LABEL_MAX_LENGTH - 1)}…` |
| 33 | : firstLine; |
| 34 | } |
| 35 | |
| 36 | const KIND_NOUN: Record<BackgroundWork["kind"], readonly [string, string]> = { |
| 37 | subagent: ["Subagent", "subagents"], |
| 38 | command: ["Command", "commands"], |
| 39 | monitor: ["Monitor", "monitors"], |
| 40 | background_task: ["Background task", "background tasks"], |
| 41 | }; |
| 42 | |
| 43 | function outcomeVerb(kind: BackgroundWork["kind"], outcome: NotificationOutcome) { |
| 44 | switch (outcome) { |
| 45 | case "failed": |
| 46 | return "failed"; |
| 47 | case "cancelled": |
| 48 | return "was stopped"; |
| 49 | case "updated": |
| 50 | return kind === "monitor" ? "reported new output" : "updated"; |
| 51 | case "completed": |
| 52 | case "unknown": |
| 53 | return "finished"; |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | function combinedOutcome(outcomes: ReadonlyArray<NotificationOutcome>): NotificationOutcome { |
| 58 | if (outcomes.includes("failed")) return "failed"; |
| 59 | if (outcomes.includes("cancelled")) return "cancelled"; |
| 60 | if (outcomes.length > 0 && outcomes.every((outcome) => outcome === "completed")) { |
| 61 | return "completed"; |
| 62 | } |
| 63 | return outcomes.includes("updated") ? "updated" : "unknown"; |
| 64 | } |
| 65 | |
| 66 | function exitSuffix(report: BackgroundWorkReport): string { |
| 67 | return report.kind === "command" && report.exitCode !== undefined |
| 68 | ? ` (exit ${report.exitCode})` |
| 69 | : ""; |
| 70 | } |
| 71 | |
| 72 | function namedReport(report: BackgroundWorkReport, capitalize: boolean): string { |
| 73 | const noun = KIND_NOUN[report.kind][0]; |
| 74 | const label = reportLabel(report.label); |
| 75 | const named = `${capitalize ? noun : noun.toLowerCase()}${label === undefined ? "" : ` "${label}"`}`; |
| 76 | return `${named}${exitSuffix(report)}`; |
| 77 | } |
| 78 | |
| 79 | function joinNames(names: ReadonlyArray<string>): string { |
| 80 | if (names.length <= 1) return names.join(""); |
| 81 | return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`; |
| 82 | } |
| 83 | |
| 84 | function reportsSummary( |
| 85 | reports: readonly [BackgroundWorkReport, ...ReadonlyArray<BackgroundWorkReport>], |
| 86 | outcome: NotificationOutcome, |
| 87 | ): string { |
| 88 | const [first] = reports; |
| 89 | if (reports.length === 1) { |
| 90 | const label = reportLabel(first.label); |
| 91 | const noun = KIND_NOUN[first.kind][0]; |
| 92 | return `${noun}${label === undefined ? "" : ` "${label}"`} ${outcomeVerb(first.kind, first.outcome)}${exitSuffix(first)}`; |
| 93 | } |
| 94 | const verb = |
| 95 | outcome === "failed" |
| 96 | ? "failed" |
| 97 | : outcome === "cancelled" |
| 98 | ? "were stopped" |
| 99 | : outcome === "updated" |
| 100 | ? "updated" |
| 101 | : "finished"; |
| 102 | if (reports.length > 3 || reports.every((report) => reportLabel(report.label) === undefined)) { |
| 103 | const kinds = new Set(reports.map((report) => report.kind)); |
| 104 | const noun = kinds.size === 1 ? KIND_NOUN[first.kind][1] : "background tasks"; |
| 105 | return `${reports.length} ${noun} ${verb}`; |
| 106 | } |
| 107 | return `${joinNames(reports.map((report, index) => namedReport(report, index === 0)))} ${verb}`; |
| 108 | } |
| 109 | |
| 110 | /** Reports of one kind share it; mixed kinds are generic background work. */ |
| 111 | function reportsSource( |
| 112 | reports: readonly [BackgroundWorkReport, ...ReadonlyArray<BackgroundWorkReport>], |
| 113 | ): OrchestrationV2NotificationSource { |
| 114 | const [first, ...rest] = reports; |
| 115 | if (rest.some((report) => report.kind !== first.kind)) return { kind: "background_task" }; |
| 116 | switch (first.kind) { |
| 117 | case "subagent": |
| 118 | // One subagent is the thing the row opens; several open nothing. |
| 119 | return rest.length === 0 && first.childThreadId !== undefined |
| 120 | ? { kind: "subagent", childThreadId: first.childThreadId } |
| 121 | : { kind: "subagent" }; |
| 122 | case "command": |
| 123 | case "monitor": |
| 124 | case "background_task": |
| 125 | return { kind: first.kind }; |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * A notification that says which background work a provider reported. Null |
| 131 | * when nothing is known, so the caller keeps its generic notification. |
| 132 | */ |
| 133 | export function backgroundWorkNotification( |
| 134 | reports: readonly [BackgroundWorkReport, ...ReadonlyArray<BackgroundWorkReport>], |
| 135 | ): OrchestrationV2Notification; |
| 136 | export function backgroundWorkNotification( |
| 137 | reports: ReadonlyArray<BackgroundWorkReport>, |
| 138 | ): OrchestrationV2Notification | null; |
| 139 | export function backgroundWorkNotification( |
| 140 | reports: ReadonlyArray<BackgroundWorkReport>, |
| 141 | ): OrchestrationV2Notification | null { |
| 142 | const [first, ...rest] = reports; |
| 143 | if (first === undefined) return null; |
| 144 | const outcome = combinedOutcome(reports.map((report) => report.outcome)); |
| 145 | return { |
| 146 | source: reportsSource([first, ...rest]), |
| 147 | outcome, |
| 148 | summary: reportsSummary([first, ...rest], outcome), |
| 149 | }; |
| 150 | } |
| 151 | |
| 152 | function delegatedTaskReport( |
| 153 | task: OrchestrationV2Subagent | undefined, |
| 154 | ): Extract<BackgroundWorkReport, { readonly kind: "subagent" }> { |
| 155 | const outcome: NotificationOutcome = |
| 156 | task?.status === "failed" |
| 157 | ? "failed" |
| 158 | : task?.status === "cancelled" || task?.status === "interrupted" |
| 159 | ? "cancelled" |
| 160 | : task?.status === "completed" |
| 161 | ? "completed" |
| 162 | : "unknown"; |
| 163 | return { |
| 164 | kind: "subagent", |
| 165 | // Delegated tasks are titled by their prompt unless the caller named them. |
| 166 | label: task?.title?.trim() || task?.prompt, |
| 167 | outcome, |
| 168 | ...(task?.childThreadId == null ? {} : { childThreadId: task.childThreadId }), |
| 169 | }; |
| 170 | } |
| 171 | |
| 172 | /** Summarizes the delegated tasks one completion delivery reports, out of all its parent run delegated. */ |
| 173 | function delegatedCompletionNotification( |
| 174 | completion: NonNullable<OrchestrationV2ConversationMessage["delegatedCompletion"]>, |
| 175 | tasks: ReadonlyArray<OrchestrationV2Subagent>, |
| 176 | ): OrchestrationV2Notification { |
| 177 | const taskIds = completion.taskIds; |
| 178 | const reports = taskIds.map((taskId) => |
| 179 | delegatedTaskReport(tasks.find((task) => task.id === taskId)), |
| 180 | ); |
| 181 | const delegatedByRun = tasks.filter( |
| 182 | (task) => task.origin === "app_owned" && task.runId === completion.parentRunId, |
| 183 | ).length; |
| 184 | const outcome = combinedOutcome(reports.map((report) => report.outcome)); |
| 185 | const verb = outcome === "failed" ? "failed" : outcome === "cancelled" ? "stopped" : "finished"; |
| 186 | const [only] = reports; |
| 187 | if (reports.length === 1 && only !== undefined) { |
| 188 | const label = reportLabel(only.label); |
| 189 | return { |
| 190 | source: { |
| 191 | kind: "delegated_task", |
| 192 | taskIds, |
| 193 | ...(only.childThreadId === undefined ? {} : { childThreadId: only.childThreadId }), |
| 194 | }, |
| 195 | outcome, |
| 196 | summary: `Delegated task${label === undefined ? "" : ` "${label}"`} ${verb}`, |
| 197 | }; |
| 198 | } |
| 199 | const labels = reports.flatMap((report) => reportLabel(report.label) ?? []); |
| 200 | const count = |
| 201 | delegatedByRun > taskIds.length |
| 202 | ? `${taskIds.length} of ${delegatedByRun}` |
| 203 | : `${taskIds.length}`; |
| 204 | return { |
| 205 | source: { kind: "delegated_task", taskIds }, |
| 206 | outcome, |
| 207 | summary: `${count} delegated tasks ${verb}${labels.length === 0 ? "" : `: ${labels.join(", ")}`}`, |
| 208 | }; |
| 209 | } |
| 210 | |
| 211 | /** Keep the delivery message intact while projecting its trigger as an activity. */ |
| 212 | export function notificationTurnItem( |
| 213 | item: OrchestrationV2TurnItem, |
| 214 | message: Pick<OrchestrationV2ConversationMessage, "notification" | "delegatedCompletion">, |
| 215 | tasks: ReadonlyArray<OrchestrationV2Subagent>, |
| 216 | ): OrchestrationV2TurnItem { |
| 217 | if (item.type !== "user_message") return item; |
| 218 | const notification = |
| 219 | message.delegatedCompletion === undefined |
| 220 | ? message.notification |
| 221 | : delegatedCompletionNotification(message.delegatedCompletion, tasks); |
| 222 | if (notification === undefined) return item; |
| 223 | const { |
| 224 | type: _type, |
| 225 | messageId: _messageId, |
| 226 | inputIntent: _inputIntent, |
| 227 | text: _text, |
| 228 | attachments: _attachments, |
| 229 | createdBy: _createdBy, |
| 230 | creationSource: _creationSource, |
| 231 | scheduledTaskId: _scheduledTaskId, |
| 232 | ...base |
| 233 | } = item; |
| 234 | return { ...base, type: "notification", ...notification }; |
| 235 | } |